571 lines
16 KiB
Text
571 lines
16 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: an RmlUi document (Singe/Menu.rml styled by Singe/menu.rcss) over the
|
|
-- disc's neon grid, filled and driven from here. The script keeps the selection, places every
|
|
-- region of the document from the overlay size (so the attract video it draws into the overlay
|
|
-- lands in the hole the document leaves for it) and launches games with scriptPush.
|
|
-- Singe/MenuClassic.singe is the previous, sprite drawn menu, kept for one release.
|
|
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local lfs = require("lfs")
|
|
|
|
|
|
-- Audio delay calibration: a click every second and a flash scheduled the engine's measured
|
|
-- device queue plus the candidate value after it. The click and the disc audio share one mixer
|
|
-- device, so when the player sees and hears them as one event the candidate is the machine's delay.
|
|
function calibrationBegin()
|
|
CALIBRATING = true
|
|
CAL_ORIGINAL = singeGetAudioCalibration()
|
|
CAL_VALUE = CAL_ORIGINAL
|
|
CAL_LATENCY = singeGetAudioLatency()
|
|
CAL_BEAT = singeGetTicks() - CAL_PERIOD
|
|
CAL_CLICK_DONE = true
|
|
CAL_FLASH_DONE = true
|
|
if VIDEO_ATTRACT then
|
|
videoPause(VIDEO_ATTRACT)
|
|
end
|
|
guiSetValue(GUI, DOCUMENT, "calLatency", tostring(CAL_LATENCY))
|
|
calibrationShowValue()
|
|
menuShowPage(true)
|
|
end
|
|
|
|
|
|
function calibrationEnd(save)
|
|
if save then
|
|
singeSetAudioCalibration(CAL_VALUE)
|
|
end
|
|
CALIBRATING = false
|
|
menuElement("calibration"):SetClass("flash", false)
|
|
menuShowPage(false)
|
|
if VIDEO_ATTRACT then
|
|
videoPlay(VIDEO_ATTRACT)
|
|
end
|
|
end
|
|
|
|
|
|
function calibrationInput(what)
|
|
local delta = 0
|
|
|
|
if what == SWITCH_LEFT then
|
|
delta = -CAL_STEP_COARSE
|
|
elseif what == SWITCH_RIGHT then
|
|
delta = CAL_STEP_COARSE
|
|
elseif what == SWITCH_UP then
|
|
delta = CAL_STEP_FINE
|
|
elseif what == SWITCH_DOWN then
|
|
delta = -CAL_STEP_FINE
|
|
elseif what == SWITCH_START1 or what == SWITCH_START2 then
|
|
CAL_VALUE = 0
|
|
calibrationShowValue()
|
|
elseif what == SWITCH_BUTTON1 then
|
|
calibrationEnd(true)
|
|
elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then
|
|
calibrationEnd(false)
|
|
end
|
|
|
|
if delta ~= 0 then
|
|
CAL_VALUE = math.max(-CAL_LIMIT, math.min(CAL_LIMIT, CAL_VALUE + delta))
|
|
calibrationShowValue()
|
|
end
|
|
end
|
|
|
|
|
|
function calibrationShowValue()
|
|
guiSetValue(GUI, DOCUMENT, "calValue", tostring(CAL_VALUE))
|
|
end
|
|
|
|
|
|
function calibrationUpdate()
|
|
local now = singeGetTicks()
|
|
local gap = CAL_LATENCY + CAL_VALUE -- flash this long after the click; negative means before
|
|
local flash = false
|
|
|
|
-- Start a new beat? Whichever of click and flash comes first goes on the beat.
|
|
if now - CAL_BEAT >= CAL_PERIOD then
|
|
CAL_BEAT = now
|
|
CAL_CLICK_AT = now + math.max(0, -gap)
|
|
CAL_FLASH_AT = now + math.max(0, gap)
|
|
CAL_CLICK_DONE = false
|
|
CAL_FLASH_DONE = false
|
|
end
|
|
if not CAL_CLICK_DONE and now >= CAL_CLICK_AT then
|
|
soundPlay(SND_CLICK)
|
|
CAL_CLICK_DONE = true
|
|
end
|
|
if not CAL_FLASH_DONE and now >= CAL_FLASH_AT then
|
|
flash = true
|
|
CAL_FLASH_DONE = true
|
|
end
|
|
|
|
-- The page is white for the one frame of the flash and black otherwise.
|
|
menuElement("calibration"):SetClass("flash", flash)
|
|
end
|
|
|
|
|
|
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 its assets.
|
|
function gameSelect(index)
|
|
if index < 1 then
|
|
index = GAME_COUNT
|
|
elseif index > GAME_COUNT then
|
|
index = 1
|
|
end
|
|
menuRow(GAME_SELECTED):SetClass("selected", false)
|
|
GAME_SELECTED = index
|
|
menuRow(GAME_SELECTED):SetClass("selected", true)
|
|
LIST_SCROLL_PENDING = true
|
|
unloadGameAssets()
|
|
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)
|
|
|
|
-- The document wraps the text itself.
|
|
menuArt("poster", game.CABINET)
|
|
menuArt("marquee", game.MARQUEE)
|
|
guiSetValue(GUI, DOCUMENT, "title", menuEscape(game.TITLE))
|
|
guiSetValue(GUI, DOCUMENT, "credits", menuLabel("Year", game.YEAR) .. menuLabel("Genre", game.GENRE) .. menuLabel("Platform", game.PLATFORM))
|
|
guiSetValue(GUI, DOCUMENT, "developer", menuLabel("Developer", game.DEVELOPER))
|
|
guiSetValue(GUI, DOCUMENT, "publisher", menuLabel("Publisher", game.PUBLISHER))
|
|
guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE))
|
|
guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION))
|
|
menuElement("description").scroll_top = 0
|
|
end
|
|
|
|
|
|
-- The game page takes input once the disc has reached the grid behind it.
|
|
function menuActive()
|
|
return (not CALIBRATING) and (discGetFrame() >= DISC_GRID_START)
|
|
end
|
|
|
|
|
|
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
|
|
-- own file is reached from there through "../"; the engine's files are named as they are.
|
|
function menuArt(id, file)
|
|
local path = tostring(file or "")
|
|
|
|
if path:sub(1, 6):lower() ~= "singe/" then
|
|
path = "../" .. path
|
|
end
|
|
menuElement(id).style.decorator = 'image("' .. path .. '" contain)'
|
|
end
|
|
|
|
|
|
-- Fills the game list with one row per game and routes their clicks here.
|
|
function menuBuildList()
|
|
local rows = {}
|
|
|
|
for index, game in ipairs(GAME_LIST) do
|
|
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. '</div>'
|
|
end
|
|
menuElement("list").inner_rml = table.concat(rows)
|
|
for index = 1, GAME_COUNT do
|
|
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
|
|
end
|
|
guiSetHandler(GUI, DOCUMENT, "start", "click", menuStartClick)
|
|
end
|
|
|
|
|
|
function menuElement(id)
|
|
return rmlui.contexts["gui" .. GUI].documents["menu"]:GetElementById(id)
|
|
end
|
|
|
|
|
|
-- Text on its way into the document, with RML's special characters made harmless.
|
|
function menuEscape(text)
|
|
local output = tostring(text or "")
|
|
|
|
output = string.gsub(output, "&", "&")
|
|
output = string.gsub(output, "<", "<")
|
|
output = string.gsub(output, ">", ">")
|
|
return output
|
|
end
|
|
|
|
|
|
-- One "Label: value" fragment of a details line.
|
|
function menuLabel(label, value)
|
|
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' '
|
|
end
|
|
|
|
|
|
-- Lays the document out from the overlay size. The list fills the left, the poster stands beside
|
|
-- it, the marquee sits over the attract video on the right, the details span both under them and
|
|
-- the key hints run along the bottom. The video rectangle is the same one videoDraw is given.
|
|
function menuLayout()
|
|
local width = overlayGetWidth()
|
|
local height = overlayGetHeight()
|
|
local sideX = MARGIN + LIST_W + GAP + POSTER_W + GAP
|
|
local sideW = width - MARGIN - sideX
|
|
local listH = height - MARGIN * 2 - FOOTER_H - GAP
|
|
local posterX = MARGIN + LIST_W + GAP
|
|
local footerY = height - MARGIN - FOOTER_H
|
|
local infoY = 0
|
|
|
|
MARQUEE_H = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
|
|
VIDEO_X = sideX
|
|
VIDEO_Y = MARGIN + MARQUEE_H + GAP
|
|
VIDEO_W = sideW
|
|
VIDEO_H = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
|
|
infoY = VIDEO_Y + VIDEO_H + GAP
|
|
|
|
menuPlace("list", MARGIN, MARGIN, LIST_W, listH)
|
|
menuPlace("poster", posterX, MARGIN, POSTER_W, VIDEO_Y + VIDEO_H - MARGIN)
|
|
menuPlace("marquee", sideX, MARGIN, sideW, MARQUEE_H)
|
|
menuPlace("video", VIDEO_X, VIDEO_Y, VIDEO_W, VIDEO_H)
|
|
menuPlace("info", posterX, infoY, width - MARGIN - posterX, MARGIN + listH - infoY)
|
|
menuPlace("footer", MARGIN, footerY, width - MARGIN * 2, FOOTER_H)
|
|
end
|
|
|
|
|
|
-- Rows that fit in the list at once: what left and right page by.
|
|
function menuPageSize()
|
|
local list = menuElement("list")
|
|
local row = menuRow(GAME_SELECTED)
|
|
|
|
if row.offset_height <= 0 then
|
|
return 1
|
|
end
|
|
return math.max(1, math.floor(list.client_height / row.offset_height))
|
|
end
|
|
|
|
|
|
function menuPlace(id, x, y, w, h)
|
|
local element = menuElement(id)
|
|
|
|
element:SetClass("placed", true)
|
|
element.style.left = x .. "px"
|
|
element.style.top = y .. "px"
|
|
element.style.width = w .. "px"
|
|
element.style.height = h .. "px"
|
|
end
|
|
|
|
|
|
function menuRow(index)
|
|
return menuElement("game" .. index)
|
|
end
|
|
|
|
|
|
-- A click on a row selects its game; a click on the selected row starts it.
|
|
function menuRowClick(gui, document, id, event, value)
|
|
local index = tonumber(id:sub(5))
|
|
|
|
if not menuActive() then
|
|
return
|
|
end
|
|
if index == GAME_SELECTED then
|
|
gameStart()
|
|
else
|
|
gameSelect(index)
|
|
end
|
|
end
|
|
|
|
|
|
-- Brings the selected row into view once the document has been laid out.
|
|
function menuScrollToSelection()
|
|
local list = menuElement("list")
|
|
local row = menuRow(GAME_SELECTED)
|
|
local top = row.offset_top - list.scroll_top
|
|
|
|
if list.client_height <= 0 then
|
|
return
|
|
end
|
|
if top < 0 then
|
|
row:ScrollIntoView(true)
|
|
elseif top + row.offset_height > list.client_height then
|
|
row:ScrollIntoView(false)
|
|
end
|
|
LIST_SCROLL_PENDING = false
|
|
end
|
|
|
|
|
|
-- The game page or the calibration page; never both.
|
|
function menuShowPage(calibrating)
|
|
menuElement("games"):SetClass("hidden", calibrating)
|
|
menuElement("calibration"):SetClass("hidden", not calibrating)
|
|
end
|
|
|
|
|
|
function menuStartClick(gui, document, id, event, value)
|
|
if menuActive() then
|
|
gameStart()
|
|
end
|
|
end
|
|
|
|
|
|
function onInputPressed(what)
|
|
|
|
if CALIBRATING then
|
|
calibrationInput(what)
|
|
return
|
|
end
|
|
|
|
-- Are we displaying the grid background?
|
|
if menuActive() then
|
|
|
|
if what == SWITCH_SERVICE then
|
|
calibrationBegin()
|
|
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 - menuPageSize()))
|
|
end
|
|
elseif what == SWITCH_RIGHT then
|
|
if GAME_SELECTED == GAME_COUNT then
|
|
gameSelect(1)
|
|
else
|
|
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + menuPageSize()))
|
|
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 CALIBRATING then
|
|
calibrationUpdate()
|
|
guiDraw(GUI)
|
|
return(OVERLAY_UPDATED)
|
|
end
|
|
|
|
-- Are we displaying the grid background?
|
|
if menuActive() then
|
|
|
|
-- Attract Mode Video, under the hole the document leaves for it
|
|
videoDraw(VIDEO_ATTRACT, VIDEO_X, VIDEO_Y, VIDEO_X + VIDEO_W, VIDEO_Y + VIDEO_H)
|
|
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
|
|
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
|
|
end
|
|
|
|
if LIST_SCROLL_PENDING then
|
|
menuScrollToSelection()
|
|
end
|
|
guiDraw(GUI)
|
|
end
|
|
|
|
return(OVERLAY_UPDATED)
|
|
|
|
end
|
|
|
|
|
|
function onShutdown()
|
|
unloadGameAssets()
|
|
saveConfig(not SHUTDOWN_FROM_PUSH)
|
|
if GUI then
|
|
guiDelete(GUI)
|
|
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")
|
|
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
|
|
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
|
|
|
|
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else
|
|
-- follows from the overlay size in menuLayout. The marquee and video keep the classic menu's
|
|
-- 320x75 and 320x200 proportions.
|
|
MARGIN = 10
|
|
GAP = 8
|
|
LIST_W = 200
|
|
POSTER_W = 180
|
|
FOOTER_H = 24
|
|
MARQUEE_ASPECT_W = 320
|
|
MARQUEE_ASPECT_H = 75
|
|
VIDEO_ASPECT_W = 320
|
|
VIDEO_ASPECT_H = 200
|
|
|
|
INTRO_DONE = false
|
|
LIST_SCROLL_PENDING = false
|
|
SHUTDOWN_FROM_PUSH = false
|
|
|
|
-- Audio delay calibration screen (SWITCH_SERVICE)
|
|
CALIBRATING = false
|
|
CAL_PERIOD = 1000
|
|
CAL_STEP_COARSE = 10
|
|
CAL_STEP_FINE = 1
|
|
CAL_LIMIT = 1000
|
|
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 document: the size of the overlay, composited over it every frame, and first in line
|
|
-- for the mouse and the switches so its rows and Start button can be clicked.
|
|
GUI = guiNew(overlayGetWidth(), overlayGetHeight())
|
|
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
|
|
guiSetInput(GUI, true)
|
|
menuLayout()
|
|
menuBuildList()
|
|
|
|
discPlay()
|
|
if (not SHOW_INTRO) then
|
|
discSkipToFrame(DISC_GRID_START)
|
|
end
|
|
|
|
-- Prime the pump
|
|
gameSelect(GAME_SELECTED)
|
|
end
|