singe/assets/Menu.singe
2026-09-07 23:44:37 -05:00

571 lines
16 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The bundled game menu: an RmlUi document (Singe/Menu.rml styled by Singe/menu.rcss) over the
-- disc's neon grid, filled and driven from here. The script keeps the selection, places every
-- region of the document from the overlay size (so the attract video it draws into the overlay
-- lands in the hole the document leaves for it) and launches games with scriptPush.
-- Singe/MenuClassic.singe is the previous, sprite drawn menu, kept for one release.
dofile("Singe/Framework.singe")
local lfs = require("lfs")
-- Audio delay calibration: a click every second and a flash scheduled the engine's measured
-- device queue plus the candidate value after it. The click and the disc audio share one mixer
-- device, so when the player sees and hears them as one event the candidate is the machine's delay.
function calibrationBegin()
CALIBRATING = true
CAL_ORIGINAL = singeGetAudioCalibration()
CAL_VALUE = CAL_ORIGINAL
CAL_LATENCY = singeGetAudioLatency()
CAL_BEAT = singeGetTicks() - CAL_PERIOD
CAL_CLICK_DONE = true
CAL_FLASH_DONE = true
if VIDEO_ATTRACT then
videoPause(VIDEO_ATTRACT)
end
guiSetValue(GUI, DOCUMENT, "calLatency", tostring(CAL_LATENCY))
calibrationShowValue()
menuShowPage(true)
end
function calibrationEnd(save)
if save then
singeSetAudioCalibration(CAL_VALUE)
end
CALIBRATING = false
menuElement("calibration"):SetClass("flash", false)
menuShowPage(false)
if VIDEO_ATTRACT then
videoPlay(VIDEO_ATTRACT)
end
end
function calibrationInput(what)
local delta = 0
if what == SWITCH_LEFT then
delta = -CAL_STEP_COARSE
elseif what == SWITCH_RIGHT then
delta = CAL_STEP_COARSE
elseif what == SWITCH_UP then
delta = CAL_STEP_FINE
elseif what == SWITCH_DOWN then
delta = -CAL_STEP_FINE
elseif what == SWITCH_START1 or what == SWITCH_START2 then
CAL_VALUE = 0
calibrationShowValue()
elseif what == SWITCH_BUTTON1 then
calibrationEnd(true)
elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then
calibrationEnd(false)
end
if delta ~= 0 then
CAL_VALUE = math.max(-CAL_LIMIT, math.min(CAL_LIMIT, CAL_VALUE + delta))
calibrationShowValue()
end
end
function calibrationShowValue()
guiSetValue(GUI, DOCUMENT, "calValue", tostring(CAL_VALUE))
end
function calibrationUpdate()
local now = singeGetTicks()
local gap = CAL_LATENCY + CAL_VALUE -- flash this long after the click; negative means before
local flash = false
-- Start a new beat? Whichever of click and flash comes first goes on the beat.
if now - CAL_BEAT >= CAL_PERIOD then
CAL_BEAT = now
CAL_CLICK_AT = now + math.max(0, -gap)
CAL_FLASH_AT = now + math.max(0, gap)
CAL_CLICK_DONE = false
CAL_FLASH_DONE = false
end
if not CAL_CLICK_DONE and now >= CAL_CLICK_AT then
soundPlay(SND_CLICK)
CAL_CLICK_DONE = true
end
if not CAL_FLASH_DONE and now >= CAL_FLASH_AT then
flash = true
CAL_FLASH_DONE = true
end
-- The page is white for the one frame of the flash and black otherwise.
menuElement("calibration"):SetClass("flash", flash)
end
function cleanTitle(a)
local output = string.lower(a)
output = string.gsub(output, '%p', ' ')
output = string.gsub(output, '^ +', '')
output = string.gsub(output, '^the ', '')
output = string.gsub(output, '^a ', '')
output = string.gsub(output, '^an ', '')
return output
end
function compareTitles(a, b)
return cleanTitle(a.TITLE) < cleanTitle(b.TITLE)
end
-- Makes a game the selection, wrapping at either end of the list, and shows its assets.
function gameSelect(index)
if index < 1 then
index = GAME_COUNT
elseif index > GAME_COUNT then
index = 1
end
menuRow(GAME_SELECTED):SetClass("selected", false)
GAME_SELECTED = index
menuRow(GAME_SELECTED):SetClass("selected", true)
LIST_SCROLL_PENDING = true
unloadGameAssets()
loadGameAssets()
end
-- Starts the selected game. The menu comes back from the top when it ends.
function gameStart()
SHUTDOWN_FROM_PUSH = true
scriptPush(GAME_LIST[GAME_SELECTED])
end
function loadGameAssets()
local game = GAME_LIST[GAME_SELECTED]
VIDEO_ATTRACT = videoLoad(game.ATTRACT)
if game.AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK)
end
videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START)
-- The document wraps the text itself.
menuArt("poster", game.CABINET)
menuArt("marquee", game.MARQUEE)
guiSetValue(GUI, DOCUMENT, "title", menuEscape(game.TITLE))
guiSetValue(GUI, DOCUMENT, "credits", menuLabel("Year", game.YEAR) .. menuLabel("Genre", game.GENRE) .. menuLabel("Platform", game.PLATFORM))
guiSetValue(GUI, DOCUMENT, "developer", menuLabel("Developer", game.DEVELOPER))
guiSetValue(GUI, DOCUMENT, "publisher", menuLabel("Publisher", game.PUBLISHER))
guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE))
guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION))
menuElement("description").scroll_top = 0
end
-- The game page takes input once the disc has reached the grid behind it.
function menuActive()
return (not CALIBRATING) and (discGetFrame() >= DISC_GRID_START)
end
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
-- own file is reached from there through "../"; the engine's files are named as they are.
function menuArt(id, file)
local path = tostring(file or "")
if path:sub(1, 6):lower() ~= "singe/" then
path = "../" .. path
end
menuElement(id).style.decorator = 'image("' .. path .. '" contain)'
end
-- Fills the game list with one row per game and routes their clicks here.
function menuBuildList()
local rows = {}
for index, game in ipairs(GAME_LIST) do
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. '</div>'
end
menuElement("list").inner_rml = table.concat(rows)
for index = 1, GAME_COUNT do
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
end
guiSetHandler(GUI, DOCUMENT, "start", "click", menuStartClick)
end
function menuElement(id)
return rmlui.contexts["gui" .. GUI].documents["menu"]:GetElementById(id)
end
-- Text on its way into the document, with RML's special characters made harmless.
function menuEscape(text)
local output = tostring(text or "")
output = string.gsub(output, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
end
-- One "Label: value" fragment of a details line.
function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
end
-- Lays the document out from the overlay size. The list fills the left, the poster stands beside
-- it, the marquee sits over the attract video on the right, the details span both under them and
-- the key hints run along the bottom. The video rectangle is the same one videoDraw is given.
function menuLayout()
local width = overlayGetWidth()
local height = overlayGetHeight()
local sideX = MARGIN + LIST_W + GAP + POSTER_W + GAP
local sideW = width - MARGIN - sideX
local listH = height - MARGIN * 2 - FOOTER_H - GAP
local posterX = MARGIN + LIST_W + GAP
local footerY = height - MARGIN - FOOTER_H
local infoY = 0
MARQUEE_H = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
VIDEO_X = sideX
VIDEO_Y = MARGIN + MARQUEE_H + GAP
VIDEO_W = sideW
VIDEO_H = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
infoY = VIDEO_Y + VIDEO_H + GAP
menuPlace("list", MARGIN, MARGIN, LIST_W, listH)
menuPlace("poster", posterX, MARGIN, POSTER_W, VIDEO_Y + VIDEO_H - MARGIN)
menuPlace("marquee", sideX, MARGIN, sideW, MARQUEE_H)
menuPlace("video", VIDEO_X, VIDEO_Y, VIDEO_W, VIDEO_H)
menuPlace("info", posterX, infoY, width - MARGIN - posterX, MARGIN + listH - infoY)
menuPlace("footer", MARGIN, footerY, width - MARGIN * 2, FOOTER_H)
end
-- Rows that fit in the list at once: what left and right page by.
function menuPageSize()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
if row.offset_height <= 0 then
return 1
end
return math.max(1, math.floor(list.client_height / row.offset_height))
end
function menuPlace(id, x, y, w, h)
local element = menuElement(id)
element:SetClass("placed", true)
element.style.left = x .. "px"
element.style.top = y .. "px"
element.style.width = w .. "px"
element.style.height = h .. "px"
end
function menuRow(index)
return menuElement("game" .. index)
end
-- A click on a row selects its game; a click on the selected row starts it.
function menuRowClick(gui, document, id, event, value)
local index = tonumber(id:sub(5))
if not menuActive() then
return
end
if index == GAME_SELECTED then
gameStart()
else
gameSelect(index)
end
end
-- Brings the selected row into view once the document has been laid out.
function menuScrollToSelection()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
local top = row.offset_top - list.scroll_top
if list.client_height <= 0 then
return
end
if top < 0 then
row:ScrollIntoView(true)
elseif top + row.offset_height > list.client_height then
row:ScrollIntoView(false)
end
LIST_SCROLL_PENDING = false
end
-- The game page or the calibration page; never both.
function menuShowPage(calibrating)
menuElement("games"):SetClass("hidden", calibrating)
menuElement("calibration"):SetClass("hidden", not calibrating)
end
function menuStartClick(gui, document, id, event, value)
if menuActive() then
gameStart()
end
end
function onInputPressed(what)
if CALIBRATING then
calibrationInput(what)
return
end
-- Are we displaying the grid background?
if menuActive() then
if what == SWITCH_SERVICE then
calibrationBegin()
elseif what == SWITCH_UP then
gameSelect(GAME_SELECTED - 1)
elseif what == SWITCH_DOWN then
gameSelect(GAME_SELECTED + 1)
elseif what == SWITCH_LEFT then
-- A page back, wrapping to the end from the first game.
if GAME_SELECTED == 1 then
gameSelect(GAME_COUNT)
else
gameSelect(math.max(1, GAME_SELECTED - menuPageSize()))
end
elseif what == SWITCH_RIGHT then
if GAME_SELECTED == GAME_COUNT then
gameSelect(1)
else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + menuPageSize()))
end
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
gameStart()
end
end
end
function onOverlayUpdate()
-- Loop the grid. A machine too slow to show every disc frame can step over the last one, after
-- which the engine starts the disc again from the top; once the intro has played, that is a loop.
if discGetFrame() >= DISC_LAST_FRAME or (INTRO_DONE and discGetFrame() < DISC_GRID_START) then
discSkipToFrame(DISC_GRID_START)
end
if discGetFrame() >= DISC_GRID_START then
INTRO_DONE = true
end
colorBackground(0, 0, 0, 0)
overlayClear()
if CALIBRATING then
calibrationUpdate()
guiDraw(GUI)
return(OVERLAY_UPDATED)
end
-- Are we displaying the grid background?
if menuActive() then
-- Attract Mode Video, under the hole the document leaves for it
videoDraw(VIDEO_ATTRACT, VIDEO_X, VIDEO_Y, VIDEO_X + VIDEO_W, VIDEO_Y + VIDEO_H)
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
end
if LIST_SCROLL_PENDING then
menuScrollToSelection()
end
guiDraw(GUI)
end
return(OVERLAY_UPDATED)
end
function onShutdown()
unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH)
if GUI then
guiDelete(GUI)
end
if SND_CLICK then
soundUnload(SND_CLICK)
end
end
function saveConfig(showIntro)
if GAME_COUNT > 0 then
-- Save what game we're currently viewing
local cfg = io.open(CONFIG_FILE, "w")
if cfg then
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
cfg:write("SHOW_INTRO = " .. tostring(showIntro) .. "\n")
cfg:close()
else
debugPrint("Unable to write " .. CONFIG_FILE)
end
end
end
function unloadGameAssets()
-- Nothing is loaded when there are no games.
if VIDEO_ATTRACT then
videoUnload(VIDEO_ATTRACT)
VIDEO_ATTRACT = nil
end
end
-- Adds the entries of one games.dat. container is the game database the file came from, or nil.
local function loadGamesDat(source, container)
GAMES = {}
local ok, err = pcall(dofile, source)
if not ok then
debugPrint(source .. ": " .. tostring(err) .. " Skipped.")
GAMES = {}
return
end
for _, value in pairs(GAMES or {}) do
if container then
-- Packed game: the engine opens its files through the database, and so does the menu.
value.CONTAINER = container
for _, key in ipairs({ "CABINET", "MARQUEE", "ATTRACT" }) do
if value[key] and value[key]:sub(1, 6):lower() ~= "singe/" then
value[key] = container .. "/" .. value[key]
end
end
end
table.insert(GAME_LIST, value)
GAME_COUNT = GAME_COUNT + 1
end
GAMES = {}
end
-- Search for games.dat files in subdirectories and inside .game databases
GAME_LIST = {}
GAME_COUNT = 0
for dir in lfs.dir(".") do
if dir ~= "." and dir ~= ".." then
local dirattr = lfs.attributes(dir)
if dirattr.mode == "directory" then
for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil)
end
end
elseif dirattr.mode == "file" and dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
end
end
end
table.sort(GAME_LIST, compareTitles)
if GAME_COUNT == 0 then
debugPrint("No games found! Exiting.")
singeQuit()
else
overlaySetResolution(discGetWidth(), discGetHeight())
DISC_GRID_START = 180
DISC_LAST_FRAME = 359
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else
-- follows from the overlay size in menuLayout. The marquee and video keep the classic menu's
-- 320x75 and 320x200 proportions.
MARGIN = 10
GAP = 8
LIST_W = 200
POSTER_W = 180
FOOTER_H = 24
MARQUEE_ASPECT_W = 320
MARQUEE_ASPECT_H = 75
VIDEO_ASPECT_W = 320
VIDEO_ASPECT_H = 200
INTRO_DONE = false
LIST_SCROLL_PENDING = false
SHUTDOWN_FROM_PUSH = false
-- Audio delay calibration screen (SWITCH_SERVICE)
CALIBRATING = false
CAL_PERIOD = 1000
CAL_STEP_COARSE = 10
CAL_STEP_FINE = 1
CAL_LIMIT = 1000
SND_CLICK = soundLoad("Singe/click.wav")
-- Load configuration
SHOW_INTRO = true
CONFIG_FILE = singeGetDataPath() .. "menu.dat"
local confattr = lfs.attributes(CONFIG_FILE)
if confattr then
dofile(CONFIG_FILE)
-- A damaged menu.dat must not take the menu down with it.
GAME_SELECTED = tonumber(GAME_SELECTED) or 1
if GAME_SELECTED > GAME_COUNT then
GAME_SELECTED = GAME_COUNT
end
else
GAME_SELECTED = 1
end
-- The document: the size of the overlay, composited over it every frame, and first in line
-- for the mouse and the switches so its rows and Start button can be clicked.
GUI = guiNew(overlayGetWidth(), overlayGetHeight())
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
guiSetInput(GUI, true)
menuLayout()
menuBuildList()
discPlay()
if (not SHOW_INTRO) then
discSkipToFrame(DISC_GRID_START)
end
-- Prime the pump
gameSelect(GAME_SELECTED)
end