384 lines
12 KiB
Text
384 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 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 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
|