--[[ * * 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] 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) MENU_RENDER.showGame(game) end -- The game page takes input once the disc has reached the grid behind it. function menuActive() return (not toolsActive()) and (discGetFrame() >= DISC_GRID_START) 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 - MENU_RENDER.pageSize())) end elseif what == SWITCH_RIGHT then if GAME_SELECTED == GAME_COUNT then gameSelect(1) else gameSelect(math.min(GAME_COUNT, GAME_SELECTED + MENU_RENDER.pageSize())) 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() -- 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 attract clip runs between the two frames the game's entry names. if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START) end MENU_RENDER.frame() end return(OVERLAY_UPDATED) end function onShutdown() unloadGameAssets() saveConfig(not SHUTDOWN_FROM_PUSH) MENU_RENDER.finish() 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 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 INTRO_DONE = false SHUTDOWN_FROM_PUSH = false -- The service tools share this click with the audio delay tool. 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 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() discPlay() if (not SHOW_INTRO) then discSkipToFrame(DISC_GRID_START) end -- Prime the pump gameSelect(GAME_SELECTED) end