singe/assets/Menu.singe

549 lines
17 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]
-- 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)
menuRefreshNotice()
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/<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.
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_BUTTON3 and menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
menuUpdateGame()
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)
-- What this machine holds, by the id and version each games.dat carries. VERSION is stamped
-- into a packed game by the service when it is published; a game without one is version 0.
-- This table is the only record of installed versions anywhere: the service keeps none.
MENU_VERSIONS = {}
MENU_UPDATES = {}
for _, game in ipairs(GAME_LIST) do
if game.GAME_ID then
MENU_VERSIONS[game.GAME_ID] = math.floor(tonumber(game.VERSION) or 0)
end
end
-- Asks the service which of the games here have a newer version. No sign-in is needed to ask;
-- the answer arrives whenever it arrives and the renderers pick it up. Called once at startup
-- and again whenever the Get Games tool opens.
function menuCheckVersions()
local held = {}
for id, version in pairs(MENU_VERSIONS) do
held[#held + 1] = { id = id, version = version }
end
if #held == 0 then
return
end
masterVersions(held, function(ok, updates)
if not ok then
return
end
MENU_UPDATES = {}
for _, update in ipairs(updates) do
MENU_UPDATES[update.id] = update
end
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
end)
end
-- The newer version the service offers for a game, or nil.
function menuUpdateFor(game)
return game.GAME_ID and MENU_UPDATES[game.GAME_ID] or nil
end
-- Fetches the newer version of the selected game into place, saying how it goes where the
-- renderers show a notice. The download needs a sign-in, which the Online Account tool does.
function menuUpdateGame()
local game = GAME_LIST[GAME_SELECTED]
local update = menuUpdateFor(game)
if not update then
return false
end
if not masterSignedIn() then
menuNotice("Sign in first, in the Online Account tool (SERVICE), to fetch the update.")
return true
end
if MENU_UPDATING then
return true
end
MENU_UPDATING = true
shopInstall({ id = update.id, slug = update.slug, title = update.title or game.TITLE,
version = update.latest, sha256 = update.sha256, target = game.CONTAINER },
function(text)
menuNotice("Updating: " .. text)
end,
function(ok, text)
MENU_UPDATING = false
-- The list first, since refreshing it resets the notice to whatever the selection
-- warrants; then what happened, which stays until the selection moves.
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
menuNotice(ok and (text .. " Version " .. update.latest .. " plays from now on.") or text)
end)
return true
end
-- One line the renderers show under the game, for whatever the menu has to say.
MENU_NOTICE = nil
function menuNotice(text)
MENU_NOTICE = text
if MENU_RENDER.notice then
MENU_RENDER.notice(text)
end
end
-- The notice for the selected game when nothing is in flight: the update on offer, or, failing
-- that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
function menuRefreshNotice()
if MENU_UPDATING then
return
end
local update = GAME_SELECTED and menuUpdateFor(GAME_LIST[GAME_SELECTED]) or nil
if update then
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
elseif MENU_RELEASE then
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
else
menuNotice(nil)
end
end
-- Once a day, unless the settings say never: is there a newer Singe for this machine? The
-- answer is only a line on screen and a service tool; nothing is fetched or changed until a
-- person opens that tool and says so. The last look is remembered in the save store.
MENU_RELEASE = nil
local RELEASE_CHECK_EVERY = 24 * 60 * 60
function menuCheckRelease(force)
if not force and SINGE_UPDATE_CHECK == false then
return
end
local info = singeGetSystemInfo()
local last = tonumber(saveGet("releaseCheckedAt", 0)) or 0
if not force and (os.time() - last) < RELEASE_CHECK_EVERY then
MENU_RELEASE = saveGet("releaseOffered", nil)
if type(MENU_RELEASE) ~= "table" then
MENU_RELEASE = nil
end
menuRefreshNotice()
return
end
masterRelease(info.platform, info.version, function(ok, reply)
if not ok then
return
end
saveSet("releaseCheckedAt", os.time())
MENU_RELEASE = reply.newer and reply or nil
saveSet("releaseOffered", MENU_RELEASE or false)
menuRefreshNotice()
end)
end
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()
menuCheckVersions()
menuCheckRelease(false)
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