singe/assets/Menu.singe
2026-09-13 22:06:39 -05:00

373 lines
12 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. 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
-- 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"
-- 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 "")
local found = (path ~= "") and lfs.attributes(path) or nil
-- 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/<game>/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.
if (found == nil) or (found.mode == "directory") 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 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_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.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