--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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. Everything the menu *is* lives here: the game list read from every -- games.dat, the selection and how it moves, the attract video, launching a game and coming back, -- and the service tools. Nothing here draws. -- -- Drawing is a renderer, chosen once at startup by whether this machine has a GPU device: -- -- Singe/MenuDocument.singe an RmlUi document, which needs the GPU device the 3D scene uses -- Singe/MenuOverlay.singe sprites and fontPrint straight into the overlay, which needs nothing -- -- The two draw the same menu and take the same keys. A renderer provides the MENU_RENDER table; -- see the top of either file for what is in it. dofile("Singe/Framework.singe") dofile("Singe/Master.singe") dofile("Singe/Tools.singe") local lfs = require("lfs") 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 it. function gameSelect(index) if index < 1 then index = GAME_COUNT elseif index > GAME_COUNT then index = 1 end unloadGameAssets() GAME_SELECTED = index 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] -- An entry without an attract clip, or naming one that is not there, shows its art and no -- video rather than taking the whole menu down with a videoLoad that cannot open it. if menuFileExists(game.ATTRACT) then 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 or 0) else debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.") end MENU_RENDER.showGame(game) end -- Where a game's art should be when it is not there. Extracted with the other support files, so -- it is always present whatever a game does or does not ship. MENU_MISSING_ART = "Singe/missing.png" -- Whether a name is a file the engine can open. Asked of the index rather than of the file: -- io.open, which this used to use, has to hand Lua a real file it can read with the C library, so -- every picture it was asked about was unpacked out of the .game into data//cache first -- -- and the menu asks about two of them for every game in the library, none of which it then opens -- that way: the sprite loader and the document's texture loader both read straight out of the -- database. lfs.attributes goes through the same virtual filesystem and answers from the index, -- opening nothing. function menuFileExists(file) local path = tostring(file or "") local found = (path ~= "") and lfs.attributes(path) or nil return (found ~= nil) and (found.mode ~= "directory") end -- A readable art path: the game's own when it can be opened, the missing-art picture when not. function menuArtPath(file) local path = tostring(file or "") if not menuFileExists(path) then if path ~= "" then debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.") end return MENU_MISSING_ART end return path end -- How far left and right move. A page is a screenful, which is what the renderer answers, but in a -- library smaller than a screenful every press then lands on the first game or the last and nothing -- in between can be reached that way at all. A third of the library is never more than three -- presses from one end to the other, whatever size it is. function pageStep() return math.max(1, math.min(MENU_RENDER.pageSize(), math.floor(GAME_COUNT / 3))) end -- The game page takes input once the backdrop has finished the intro and settled on the grid. function menuActive() return (not toolsActive()) and MENU_RENDER.backdropReady() end -- In MODE_FULL every key arrives here as its keysym rather than as a switch, which is what the -- account tool's typing needs; the switches it still cares about are the pad's, which keep working. function onKeyPressed(keysym, scancode) if toolsActive() then toolsTyped(keysym) end end function onInputPressed(what) -- The service tools take every switch while they are up, including the one that closes them. if toolsActive() then toolsInput(what) return end -- Are we displaying the grid background? if menuActive() then if what == SWITCH_SERVICE then toolsBegin() 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 - pageStep())) end elseif what == SWITCH_RIGHT then if GAME_SELECTED == GAME_COUNT then gameSelect(1) else gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep())) 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() -- The intro, then the grid behind the menu, and the overlay cleared ready to be drawn into. MENU_RENDER.backdrop() -- The network never blocks the frame; it makes progress here instead. masterPump() if toolsActive() then toolsUpdate() MENU_RENDER.tools() return(OVERLAY_UPDATED) end -- Are we displaying the grid background? if menuActive() then -- The page keys take the details over from the walk they do by themselves; the walk picks -- up from wherever they leave it once its pause is up. They arrive as a scancode rather -- than a switch because they are not one: a cabinet has no page keys and loses nothing. local key = keyboardGetLastDown() if key == SCANCODE.PAGEDOWN.value then MENU_RENDER.scrollDetails(1) elseif key == SCANCODE.PAGEUP.value then MENU_RENDER.scrollDetails(-1) end -- The attract clip runs between the two frames the game's entry names. if VIDEO_ATTRACT and GAME_LIST[GAME_SELECTED].ATTRACT_END then if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START or 0) end end MENU_RENDER.frame() end return(OVERLAY_UPDATED) end function onShutdown() -- With no games found there is no renderer and nothing was loaded; quitting is all there is. if MENU_RENDER == nil then return end unloadGameAssets() saveConfig(not SHUTDOWN_FROM_PUSH) MENU_RENDER.finish() if SND_INTRO_VOICE ~= SOUND_ERROR_INVALID then -- A game started before the intro's sound had finished; it wants the speakers to itself. soundStop(SND_INTRO_VOICE) end if SND_INTRO then soundUnload(SND_INTRO) 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") -- The file is written whole, so a renderer forced here has to be written back or it -- would last exactly one run. if MENU_OVERLAY then cfg:write("MENU_OVERLAY = true\n") end 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 MENU_RENDER.hideGame() 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. -- -- The extension is tested before the mode, and that order matters: the engine presents a packed -- game through the VFS, so lfs.attributes reports a .game as a *directory*, not a file. Testing -- the mode first sent every container down the loose-directory branch, which loads its games.dat -- with no container -- and then every CABINET, MARQUEE and ATTRACT path in it named a file that -- exists only inside the database. A packed library could not show a single piece of its art. GAME_LIST = {} GAME_COUNT = 0 for dir in lfs.dir(".") do if dir ~= "." and dir ~= ".." then local dirattr = lfs.attributes(dir) if dir:sub(-5):lower() == ".game" then loadGamesDat(dir .. "/games.dat", dir) elseif dirattr and (dirattr.mode == "directory") then for file in lfs.dir(dir .. "/.") do if file == "games.dat" then loadGamesDat(dir .. "/games.dat", nil) end end end end end table.sort(GAME_LIST, compareTitles) if GAME_COUNT == 0 then debugPrint("No games found! Exiting.") singeQuit() else overlaySetResolution(discGetWidth(), discGetHeight()) -- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the -- grid section it loops begins, and its last frame. The first two are not the same moment -- -- the menu comes up while the music is still fading out behind it, and only then does the -- picture start repeating. All three come from util/renderMenuVideo.py, which prints them -- when it makes the video; the grid section is a whole number of turns of the grid, so -- playing it over and over has no seam in it. A renderer that draws its own grid uses none -- of them: it knows where its own logo went. DISC_MENU_FRAME = 222 DISC_GRID_START = 300 DISC_LAST_FRAME = 419 SHUTDOWN_FROM_PUSH = false -- The service tools share this click with the audio delay tool. SND_CLICK = soundLoad("Singe/click.wav") -- The intro's sound. It belongs to the menu rather than to either renderer: one draws the -- intro and the other plays a recording of it, and both want the same sound over the top at -- the same moment. Keeping it out of the video is also what stops it being stored twice. SND_INTRO = soundLoad("Singe/menuIntro.flac") SND_INTRO_VOICE = SOUND_ERROR_INVALID -- 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 renderer. A GUI document is drawn through the GPU device the 3D scene uses, so without -- one there is no document to build and guiNew would end the script rather than answer false; -- the overlay renderer needs nothing but the overlay every game already draws into. Set -- MENU_OVERLAY = true in menu.dat to use it on a machine that has a GPU, which is how the -- overlay path gets tested. if singeHasGpu() and not MENU_OVERLAY then dofile("Singe/MenuDocument.singe") else dofile("Singe/MenuOverlay.singe") end MENU_RENDER.begin() MENU_RENDER.layout() MENU_RENDER.buildList() MENU_RENDER.backdropBegin(SHOW_INTRO) if SHOW_INTRO then SND_INTRO_VOICE = soundPlay(SND_INTRO) end -- Prime the pump gameSelect(GAME_SELECTED) end