singe/assets/Menu.singe
2026-09-11 19:21:07 -05:00

307 lines
8.3 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/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 disc has reached the grid behind it.
function menuActive()
return (not toolsActive()) and (discGetFrame() >= DISC_GRID_START)
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()
-- 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()
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())
DISC_GRID_START = 180
DISC_LAST_FRAME = 359
INTRO_DONE = false
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()
discPlay()
if (not SHOW_INTRO) then
discSkipToFrame(DISC_GRID_START)
end
-- Prime the pump
gameSelect(GAME_SELECTED)
end