--[[ * * 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: 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") 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 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 toolsActive()) 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] = '
' .. menuEscape(game.TITLE) .. '
' 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, "&", "&") output = string.gsub(output, "<", "<") output = string.gsub(output, ">", ">") return output end -- One "Label: value" fragment of a details line. function menuLabel(label, value) return '' .. label .. ': ' .. menuEscape(value) .. '   ' 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 function menuStartClick(gui, document, id, event, value) if menuActive() then gameStart() 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 - 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 toolsActive() then toolsUpdate() 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 -- The service tools (SWITCH_SERVICE) share this click with the audio delay screen. 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