310 lines
8.6 KiB
Text
310 lines
8.6 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
|
|
|
|
|
|
-- 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 - 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()
|
|
|
|
-- 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_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())
|
|
|
|
-- Where the engine intro ends and the grid section begins in Singe/menuBackground.mkv, and its
|
|
-- last frame. A renderer that draws its own grid uses the first as the end of the intro and
|
|
-- never reaches the second.
|
|
DISC_GRID_START = 180
|
|
DISC_LAST_FRAME = 359
|
|
|
|
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()
|
|
|
|
MENU_RENDER.backdropBegin(SHOW_INTRO)
|
|
|
|
-- Prime the pump
|
|
gameSelect(GAME_SELECTED)
|
|
end
|