Tools and menu work.

This commit is contained in:
Scott Duensing 2026-09-11 19:21:07 -05:00
parent 421ef75bdc
commit f9f67201e3
14 changed files with 1062 additions and 953 deletions

1
.gitignore vendored
View file

@ -22,4 +22,3 @@ docs/.asciidoctor/
/Menu.bat /Menu.bat
/data/ /data/
screenshots/ screenshots/
screenshots-hw/

View file

@ -354,10 +354,10 @@ API Changes
a scrolling list on the left, up and down to select, left and right to a scrolling list on the left, up and down to select, left and right to
page, Start or any button to play, and a mouse works too (click a row page, Start or any button to play, and a mouse works too (click a row
to select it, click it again or the Start button to play, wheel to to select it, click it again or the Start button to play, wheel to
scroll the description). The audio delay calibration screen is a page scroll the description). The service tools are pages of the same
of the same document. The previous overlay-drawn menu is kept for one document. A machine without a GPU device, where guiNew fails, gets the
release as Singe/MenuClassic.singe for a machine without a GPU device, same menu drawn into the overlay instead; see "One menu, two renderers"
where guiNew fails; name it in Menu.sh or Menu.bat to use it. below.
- Physics debug drawing. physicsSetDebug(DEBUG_*) draws the bodies in - Physics debug drawing. physicsSetDebug(DEBUG_*) draws the bodies in
wireframe (coloured by motion state), joints and their limits, contact wireframe (coloured by motion state), joints and their limits, contact
@ -862,14 +862,36 @@ API Changes
things that must not pass through each other -- which is what the things that must not pass through each other -- which is what the
collide calls deliberately do not do. collide calls deliberately do not do.
- One menu, two renderers. Singe/MenuClassic.singe is gone: it was the
whole menu written a second time, and the two copies duplicated their
game list, their selection, their menu.dat handling and their games.dat
walk almost line for line. Singe/Menu.singe is now everything the menu
is and draws nothing, and it picks one of two renderers at startup from
the new singeHasGpu(): Singe/MenuDocument.singe for a machine with a
GPU device, Singe/MenuOverlay.singe for one without. A machine with no
Vulkan, Direct3D 12 or Metal driver therefore gets the menu -- and all
ten service tools -- without anyone editing a launcher, which is what
editing Menu.sh used to be for.
Both renderers take the same keys, so the overlay one now moves the
selection with up and down and pages with left and right, as the
document one does; it shows one game at a time rather than a list, and
clips a description too long for its column rather than scrolling it.
MENU_OVERLAY = true in menu.dat forces it on a machine that has a GPU.
singeHasGpu() answers whether the GPU device came up. guiNew and the
scene calls end the script rather than returning false, so a game that
can do without one had no way to ask.
- The service tools. Pressing the SERVICE key on the bundled menu now - The service tools. Pressing the SERVICE key on the bundled menu now
opens a list of ten screens rather than the audio delay screen alone: opens a list of ten screens rather than the audio delay screen alone:
Audio Delay, Input Test, System Information, Light Gun, Sound Test, Audio Delay, Input Test, System Information, Light Gun, Sound Test,
Display, Disc Test, Saved Data, MIDI Ports and Frame Statistics. Display, Disc Test, Saved Data, MIDI Ports and Frame Statistics.
Button 2 backs out of a tool, the service key leaves. They live in Button 2 backs out of a tool, the service key leaves. They live in
Singe/Tools.singe, which the engine extracts beside Menu.singe, and Singe/Tools.singe, which the engine extracts beside Menu.singe. Nothing
each is a page of Singe/Menu.rml, so a cabinet builder can add one. there draws: a tool builds a list of rows and the menu draws them, so
MenuClassic keeps the audio delay screen and nothing else. the same ten run on both renderers and a cabinet builder adding one
writes no drawing code at all.
Four calls were added for them, and are worth having on their own: Four calls were added for them, and are worth having on their own:

View file

@ -190,7 +190,8 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg
singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuClassic.singe ${GENERATED_DIR}/MenuClassic_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "")

View file

@ -32,68 +32,11 @@
<button id="start">Start</button> <button id="start">Start</button>
</div> </div>
</div> </div>
<div id="tools" class="tool hidden"> <!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
<h1>Service Tools</h1> rows and Singe/MenuDocument.singe turns them into the divs below it. -->
<div id="toolList"></div> <div id="tool" class="tool hidden">
<p class="value" id="toolHelp"></p> <h1 id="toolTitle"></h1>
<p class="keys">Up / Down: choose &nbsp; Button 1: open &nbsp; Service: back to the games</p> <div id="toolBody"></div>
</div>
<div id="toolAudio" class="tool hidden">
<h1>Audio Delay</h1>
<p>Adjust until the flash and the click happen together.</p>
<p class="value">Delay: <span id="calValue"></span> ms</p>
<p class="value">Measured device queue: <span id="calLatency"></span> ms</p>
<p class="keys">Left / Right: 10 ms &nbsp; Up / Down: 1 ms &nbsp; Start: reset to 0</p>
<p class="keys">Button 1: save &nbsp; Button 2: back</p>
</div>
<div id="toolInput" class="tool hidden">
<h1>Input Test</h1>
<div id="inputRows"></div>
<p class="keys">Press anything. Button 2: back</p>
</div>
<div id="toolSystem" class="tool hidden">
<h1>System Information</h1>
<div id="systemRows"></div>
<p class="keys">Button 2: back</p>
</div>
<div id="toolGun" class="tool clear hidden">
<h1>Light Gun</h1>
<p class="value">Last shot: <span id="gunLast"></span> &nbsp; shots shown: <span id="gunCount"></span></p>
<p class="keys">Button 1: fire &nbsp; Start: clear &nbsp; Button 2: back</p>
</div>
<div id="toolSound" class="tool hidden">
<h1>Sound Test</h1>
<p class="value">Playing: <span id="soundWhich"></span></p>
<p class="value">Disc audio tracks: <span id="soundTracks"></span></p>
<p class="keys">Button 1: left, right, both &nbsp; Left / Right: disc track &nbsp; Button 2: back</p>
</div>
<div id="toolDisplay" class="tool clear hidden">
<h1>Display</h1>
<p class="value" id="dispValues"></p>
<p class="value" id="dispSaved"></p>
<p class="keys">Arrows: move &nbsp; Button 3 / 4: size &nbsp; Coin 1: rotate &nbsp; Start: reset</p>
<p class="keys">Button 1: keep for this machine &nbsp; Button 2: back</p>
</div>
<div id="toolDisc" class="tool hidden">
<h1>Disc Test</h1>
<div id="discRows"></div>
<p class="keys">Button 1: run &nbsp; Button 2: back</p>
</div>
<div id="toolSaved" class="tool hidden">
<h1>Saved Data</h1>
<div id="savedRows"></div>
<p class="keys">Up / Down: choose &nbsp; Button 3: delete this game's save &nbsp; Button 2: back</p>
</div>
<div id="toolMidi" class="tool hidden">
<h1>MIDI Ports</h1>
<div id="midiRows"></div>
<p class="keys">Button 1: send a note &nbsp; Button 3: look again &nbsp; Button 2: back</p>
</div>
<div id="toolStats" class="tool hidden">
<h1>Frame Statistics</h1>
<p class="value">The overlay is <span id="statsState"></span>.</p>
<p>It stays on while you play, in the top left corner.</p>
<p class="keys">Button 1: turn it on or off &nbsp; Button 2: back</p>
</div> </div>
</body> </body>
</rml> </rml>

View file

@ -22,11 +22,18 @@
--]] --]]
-- 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 -- The bundled game menu. Everything the menu *is* lives here: the game list read from every
-- region of the document from the overlay size (so the attract video it draws into the overlay -- games.dat, the selection and how it moves, the attract video, launching a game and coming back,
-- lands in the hole the document leaves for it) and launches games with scriptPush. -- and the service tools. Nothing here draws.
-- Singe/MenuClassic.singe is the previous, sprite drawn menu, kept for one release. --
-- 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/Framework.singe")
dofile("Singe/Tools.singe") dofile("Singe/Tools.singe")
@ -50,18 +57,15 @@ function compareTitles(a, b)
end end
-- Makes a game the selection, wrapping at either end of the list, and shows its assets. -- Makes a game the selection, wrapping at either end of the list, and shows it.
function gameSelect(index) function gameSelect(index)
if index < 1 then if index < 1 then
index = GAME_COUNT index = GAME_COUNT
elseif index > GAME_COUNT then elseif index > GAME_COUNT then
index = 1 index = 1
end end
menuRow(GAME_SELECTED):SetClass("selected", false)
GAME_SELECTED = index
menuRow(GAME_SELECTED):SetClass("selected", true)
LIST_SCROLL_PENDING = true
unloadGameAssets() unloadGameAssets()
GAME_SELECTED = index
loadGameAssets() loadGameAssets()
end end
@ -82,17 +86,7 @@ function loadGameAssets()
end end
videoPlay(VIDEO_ATTRACT) videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START) videoSeek(VIDEO_ATTRACT, game.ATTRACT_START)
MENU_RENDER.showGame(game)
-- 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 end
@ -102,152 +96,6 @@ function menuActive()
end 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, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
end
-- One "Label: value" fragment of a details line.
function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
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
function menuStartClick(gui, document, id, event, value)
if menuActive() then
gameStart()
end
end
function onInputPressed(what) function onInputPressed(what)
-- The service tools take every switch while they are up, including the one that closes them. -- The service tools take every switch while they are up, including the one that closes them.
@ -270,13 +118,13 @@ function onInputPressed(what)
if GAME_SELECTED == 1 then if GAME_SELECTED == 1 then
gameSelect(GAME_COUNT) gameSelect(GAME_COUNT)
else else
gameSelect(math.max(1, GAME_SELECTED - menuPageSize())) gameSelect(math.max(1, GAME_SELECTED - MENU_RENDER.pageSize()))
end end
elseif what == SWITCH_RIGHT then elseif what == SWITCH_RIGHT then
if GAME_SELECTED == GAME_COUNT then if GAME_SELECTED == GAME_COUNT then
gameSelect(1) gameSelect(1)
else else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + menuPageSize())) gameSelect(math.min(GAME_COUNT, GAME_SELECTED + MENU_RENDER.pageSize()))
end 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 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() gameStart()
@ -303,23 +151,17 @@ function onOverlayUpdate()
if toolsActive() then if toolsActive() then
toolsUpdate() toolsUpdate()
guiDraw(GUI) MENU_RENDER.tools()
return(OVERLAY_UPDATED) return(OVERLAY_UPDATED)
end end
-- Are we displaying the grid background? -- Are we displaying the grid background?
if menuActive() then if menuActive() then
-- The attract clip runs between the two frames the game's entry names.
-- 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 if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START) videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
end end
MENU_RENDER.frame()
if LIST_SCROLL_PENDING then
menuScrollToSelection()
end
guiDraw(GUI)
end end
return(OVERLAY_UPDATED) return(OVERLAY_UPDATED)
@ -330,9 +172,7 @@ end
function onShutdown() function onShutdown()
unloadGameAssets() unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH) saveConfig(not SHUTDOWN_FROM_PUSH)
if GUI then MENU_RENDER.finish()
guiDelete(GUI)
end
if SND_CLICK then if SND_CLICK then
soundUnload(SND_CLICK) soundUnload(SND_CLICK)
end end
@ -346,6 +186,11 @@ function saveConfig(showIntro)
if cfg then if cfg then
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n") cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
cfg:write("SHOW_INTRO = " .. tostring(showIntro) .. "\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() cfg:close()
else else
debugPrint("Unable to write " .. CONFIG_FILE) debugPrint("Unable to write " .. CONFIG_FILE)
@ -360,6 +205,7 @@ function unloadGameAssets()
videoUnload(VIDEO_ATTRACT) videoUnload(VIDEO_ATTRACT)
VIDEO_ATTRACT = nil VIDEO_ATTRACT = nil
end end
MENU_RENDER.hideGame()
end end
@ -416,24 +262,10 @@ else
DISC_GRID_START = 180 DISC_GRID_START = 180
DISC_LAST_FRAME = 359 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 INTRO_DONE = false
LIST_SCROLL_PENDING = false
SHUTDOWN_FROM_PUSH = false SHUTDOWN_FROM_PUSH = false
-- The service tools (SWITCH_SERVICE) share this click with the audio delay screen. -- The service tools share this click with the audio delay tool.
SND_CLICK = soundLoad("Singe/click.wav") SND_CLICK = soundLoad("Singe/click.wav")
-- Load configuration -- Load configuration
@ -451,13 +283,19 @@ else
GAME_SELECTED = 1 GAME_SELECTED = 1
end end
-- The document: the size of the overlay, composited over it every frame, and first in line -- The renderer. A GUI document is drawn through the GPU device the 3D scene uses, so without
-- for the mouse and the switches so its rows and Start button can be clicked. -- one there is no document to build and guiNew would end the script rather than answer false;
GUI = guiNew(overlayGetWidth(), overlayGetHeight()) -- the overlay renderer needs nothing but the overlay every game already draws into. Set
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml") -- MENU_OVERLAY = true in menu.dat to use it on a machine that has a GPU, which is how the
guiSetInput(GUI, true) -- overlay path gets tested.
menuLayout() if singeHasGpu() and not MENU_OVERLAY then
menuBuildList() dofile("Singe/MenuDocument.singe")
else
dofile("Singe/MenuOverlay.singe")
end
MENU_RENDER.begin()
MENU_RENDER.layout()
MENU_RENDER.buildList()
discPlay() discPlay()
if (not SHOW_INTRO) then if (not SHOW_INTRO) then

View file

@ -1,547 +0,0 @@
--[[
*
* 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.
*
*
--]]
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
end
function calibrationEnd(save)
if save then
singeSetAudioCalibration(CAL_VALUE)
end
CALIBRATING = 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
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))
end
end
function calibrationUpdate()
local now = singeGetTicks()
local gap = CAL_LATENCY + CAL_VALUE -- flash this long after the click; negative means before
local flash = false
local y = MARGIN_Y
-- 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
if flash then
colorBackground(255, 255, 255, 255)
colorForeground(0, 0, 0, 255)
else
colorBackground(0, 0, 0, 255)
colorForeground(255, 255, 255, 255)
end
overlayClear()
fontPrint(MARGIN_X, y, "AUDIO DELAY CALIBRATION")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Adjust until the flash and the click happen together.")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Delay: " .. CAL_VALUE .. " ms")
y = y + CAL_LINE_HEIGHT
fontPrint(MARGIN_X, y, "Measured device queue: " .. CAL_LATENCY .. " ms")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Left / Right: 10 ms Up / Down: 1 ms Start: reset to 0")
y = y + CAL_LINE_HEIGHT
fontPrint(MARGIN_X, y, "Button 1: save Button 2: cancel")
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
function loadGameAssets()
SPRITE_CABINET = spriteLoad(GAME_LIST[GAME_SELECTED].CABINET)
SPRITE_MARQUEE = spriteLoad(GAME_LIST[GAME_SELECTED].MARQUEE)
VIDEO_ATTRACT = videoLoad(GAME_LIST[GAME_SELECTED].ATTRACT)
if GAME_LIST[GAME_SELECTED].AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].AUDIO_TRACK)
end
videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
-- videoSetVolume(VIDEO_ATTRACT, 0, 0)
-- Build text sprites
local textBox = GAME_LIST[GAME_SELECTED].DESCRIPTION .. WRAP_BREAK .. WRAP_BREAK ..
"Year: " .. GAME_LIST[GAME_SELECTED].YEAR .. WRAP_BREAK ..
"Genre: " .. GAME_LIST[GAME_SELECTED].GENRE .. WRAP_BREAK ..
"Platform: " .. GAME_LIST[GAME_SELECTED].PLATFORM .. WRAP_BREAK ..
"Developer: " .. GAME_LIST[GAME_SELECTED].DEVELOPER .. WRAP_BREAK ..
"Publisher: " .. GAME_LIST[GAME_SELECTED].PUBLISHER .. WRAP_BREAK .. WRAP_BREAK ..
"Singe Port: " .. GAME_LIST[GAME_SELECTED].CREATOR .. WRAP_BREAK ..
"Source: " .. GAME_LIST[GAME_SELECTED].SOURCE
wrapText(textBox, TEXT_W)
TEXT_LINE_COUNT = utilGetTableSize(TEXT_SPRITE_LIST)
TEXT_LINE_TOP = 1
end
function onInputPressed(what)
if CALIBRATING then
calibrationInput(what)
return
end
-- Are we displaying the grid background?
if (discGetFrame() >= DISC_GRID_START) then
if what == SWITCH_SERVICE then
calibrationBegin()
return
end
if what == SWITCH_UP then
if TEXT_LINE_TOP > 1 then
TEXT_LINE_TOP = TEXT_LINE_TOP - 1
end
end
if what == SWITCH_DOWN then
if TEXT_LINE_TOP < TEXT_LINE_COUNT - TEXT_LINE_LIMIT + 1 then
TEXT_LINE_TOP = TEXT_LINE_TOP + 1
end
end
if what == SWITCH_LEFT then
GAME_SELECTED = GAME_SELECTED - 1
if GAME_SELECTED < 1 then
GAME_SELECTED = GAME_COUNT
end
unloadGameAssets()
loadGameAssets()
end
if what == SWITCH_RIGHT then
GAME_SELECTED = GAME_SELECTED + 1
if GAME_SELECTED > GAME_COUNT then
GAME_SELECTED = 1
end
unloadGameAssets()
loadGameAssets()
end
if what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
-- Start next game
SHUTDOWN_FROM_PUSH = true
scriptPush(GAME_LIST[GAME_SELECTED])
end
end
end
function onOverlayUpdate()
local x = 0
local y = 0
local c = 0
local t = 0
-- Loop video?
if (discGetFrame() >= DISC_LAST_FRAME) then
discSkipToFrame(DISC_GRID_START)
end
if CALIBRATING then
calibrationUpdate()
return(OVERLAY_UPDATED)
end
colorBackground(0, 0, 0, 0)
overlayClear()
-- Are we displaying the grid background?
if (discGetFrame() >= DISC_GRID_START) then
-- Cabinet image
x = CABINET_X + (CABINET_W - spriteGetWidth(SPRITE_CABINET)) * 0.5
y = CABINET_Y + (CABINET_H - spriteGetHeight(SPRITE_CABINET)) * 0.5
spriteDraw(SPRITE_CABINET, x, y)
-- Marquee Image
x = MARQUEE_X + (MARQUEE_W - spriteGetWidth(SPRITE_MARQUEE)) * 0.5
y = MARQUEE_Y + (MARQUEE_H - spriteGetHeight(SPRITE_MARQUEE)) * 0.5
spriteDraw(SPRITE_MARQUEE, x, y)
-- Attract Mode Video
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
-- Game Description
colorForeground(255, 255, 255, 255)
y = TEXT_Y
c = 0
t = 1
for _, handle in ipairs(TEXT_SPRITE_LIST) do
-- Find height of font and number of lines that fit
if TEXT_LINE_HEIGHT == 0 then
if (handle >= 0) then
TEXT_LINE_HEIGHT = spriteGetHeight(handle)
TEXT_LINE_LIMIT = math.floor(TEXT_H / TEXT_LINE_HEIGHT)
end
end
-- Only display what is visible in the window
if (t >= TEXT_LINE_TOP) then
if (c < TEXT_LINE_LIMIT) then
if (handle >= 0) then
spriteDraw(handle, TEXT_X, y)
end
y = y + TEXT_LINE_HEIGHT + 1
c = c + 1
end
end
t = t + 1
end
end
return(OVERLAY_UPDATED)
end
function onShutdown()
unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH)
if freeSans18 then
fontUnload(freeSans18)
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 SPRITE_CABINET then
spriteUnload(SPRITE_CABINET)
SPRITE_CABINET = nil
end
if SPRITE_MARQUEE then
spriteUnload(SPRITE_MARQUEE)
SPRITE_MARQUEE = nil
end
if VIDEO_ATTRACT then
videoUnload(VIDEO_ATTRACT)
VIDEO_ATTRACT = nil
end
for _, handle in ipairs(TEXT_SPRITE_LIST or {}) do
if handle >= 0 then
spriteUnload(handle)
end
end
TEXT_SPRITE_LIST = {}
end
function wrapText(text, maxWidth)
local words = {}
local toWrap = ""
local line = ""
local lastLine = ""
local fragment = ""
local doBreak = false
local spriteTemp = -1
local trimBreak = utilTrim(WRAP_BREAK)
-- Break input into words
toWrap = text .. WRAP_BREAK
for w in toWrap:gmatch("%S+") do
words[#words+1] = w
end
-- Iterate over words and try to fit them in the space given
for _, word in ipairs(words) do
-- Is this an intentional break?
if word == trimBreak then
doBreak = true
else
-- Add this word to the line
line = utilTrim(line .. " " .. word)
-- Do we even have text?
if string.len(line) > 0 then
-- Is the line too long to fit now?
spriteTemp = fontToSprite(line)
if spriteGetWidth(spriteTemp) > maxWidth then
doBreak = true
fragment = word
line = lastLine
else
fragment = ""
end
spriteUnload(spriteTemp)
end
end
lastLine = line
-- Did we find a break?
if doBreak then
-- Did we get anything to print?
if string.len(line) > 0 then
table.insert(TEXT_SPRITE_LIST, fontToSprite(line))
else
-- Blank line
table.insert(TEXT_SPRITE_LIST, -1)
end
-- Reset for next line
line = fragment
fragment = ""
doBreak = false
end
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())
freeSans18 = fontLoad("Singe/FreeSansBold.ttf", 18)
fontQuality(FONT_QUALITY_BLENDED)
fontSelect(freeSans18)
DISC_GRID_START = 180
DISC_LAST_FRAME = 359
MARGIN_X = 25
MARGIN_Y = 25
VIDEO_W = 320
VIDEO_H = 200
MARQUEE_H = 75
MARQUEE_W = VIDEO_W
MARQUEE_X = overlayGetWidth() - MARGIN_X - MARQUEE_W
MARQUEE_Y = MARGIN_Y
VIDEO_X = overlayGetWidth() - MARGIN_X - VIDEO_W
VIDEO_Y = MARGIN_Y + MARQUEE_H + MARGIN_Y
TEXT_W = VIDEO_W
TEXT_X = overlayGetWidth() - MARGIN_X - TEXT_W
TEXT_Y = VIDEO_Y + VIDEO_H + MARGIN_Y
TEXT_H = overlayGetHeight() - MARGIN_Y - TEXT_Y
CABINET_X = MARGIN_X
CABINET_Y = MARGIN_Y
CABINET_W = VIDEO_X - MARGIN_X - CABINET_X
CABINET_H = overlayGetHeight() - MARGIN_Y - CABINET_Y
--[[
Cabinet is 325x430
Marquee is 320x75
Video is 320x200
Text is 320x105
--]]
--[[
debugPrint("Cabinet is " .. CABINET_W .. "x" .. CABINET_H)
debugPrint("Marquee is " .. MARQUEE_W .. "x" .. MARQUEE_H)
debugPrint(" Video is " .. VIDEO_W .. "x" .. VIDEO_H)
debugPrint(" Text is " .. TEXT_W .. "x" .. TEXT_H)
--]]
WRAP_BREAK = " [!wb!] "
TEXT_LINE_TOP = 1
TEXT_LINE_LIMIT = 0
TEXT_LINE_COUNT = 0
TEXT_LINE_HEIGHT = 0
TEXT_SPRITE_LIST = {}
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
CAL_LINE_HEIGHT = 24
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
discPlay()
if (not SHOW_INTRO) then
discSkipToFrame(DISC_GRID_START)
end
-- Prime the pump
loadGameAssets()
end

301
assets/MenuDocument.singe Normal file
View file

@ -0,0 +1,301 @@
--[[
*
* 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 menu drawn as an RmlUi document: Singe/Menu.rml styled by Singe/menu.rcss, composited over
-- the overlay, with the attract video drawn into the overlay underneath the hole the document
-- leaves for it. This needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the one
-- for a machine without one. Singe/Menu.singe loads exactly one of the two and calls it through
-- MENU_RENDER:
--
-- begin() build what drawing needs
-- finish() give it back
-- layout() place the regions, from the overlay size
-- buildList() the whole game list is known
-- showGame(game) this is the selection now
-- hideGame() let go of the last selection
-- pageSize() how many rows left and right should move by
-- frame() draw the game page
-- tools() draw the service tools page
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else
-- follows from the overlay size in layout(). The marquee and video keep the overlay menu's
-- 320x75 and 320x200 proportions.
local MARGIN = 10
local GAP = 8
local LIST_W = 200
local POSTER_W = 180
local FOOTER_H = 24
local MARQUEE_ASPECT_W = 320
local MARQUEE_ASPECT_H = 75
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false
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, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
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.
local 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
-- One "Label: value" fragment of a details line.
local function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
end
local 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
local function menuRow(index)
return menuElement("game" .. index)
end
-- A click on a row selects its game; a click on the selected row starts it.
local 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
local function menuStartClick(gui, document, id, event, value)
if menuActive() then
gameStart()
end
end
-- Brings the selected row into view once the document has been laid out.
local 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
scrollPending = false
end
-- A tool lines its columns up with runs of spaces, which RML would collapse to one, so they are
-- made non-breaking on the way in. Only the tools do this: a game's description is meant to wrap.
local function menuSpaces(text)
return (string.gsub(menuEscape(text), " +", function(run) return string.rep("&nbsp;", #run) end))
end
-- One row of a service tool, as RML. The kinds are the ones Singe/Tools.singe builds.
local function toolRow(row)
if row.kind == "pair" then
return string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>",
menuEscape(row.label), menuSpaces(row.value))
elseif row.kind == "sub" then
return "<div class='sub'>" .. menuSpaces(row.text) .. "</div>"
elseif row.kind == "keys" then
return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>"
elseif row.kind == "item" then
return string.format("<div class='%s'>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text))
elseif row.kind == "blank" then
return "<div class='blank'></div>"
end
return "<div class='row'>" .. menuSpaces(row.text) .. "</div>"
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
-- 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)
end
MENU_RENDER.finish = function()
if GUI then
guiDelete(GUI)
GUI = nil
end
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.
MENU_RENDER.layout = function()
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 marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
local infoY = 0
videoX = sideX
videoY = MARGIN + marqueeH + GAP
videoW = sideW
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
infoY = videoY + videoH + GAP
menuPlace("list", MARGIN, MARGIN, LIST_W, listH)
menuPlace("poster", posterX, MARGIN, POSTER_W, videoY + videoH - MARGIN)
menuPlace("marquee", sideX, MARGIN, sideW, marqueeH)
menuPlace("video", videoX, videoY, videoW, videoH)
menuPlace("info", posterX, infoY, width - MARGIN - posterX, MARGIN + listH - infoY)
menuPlace("footer", MARGIN, footerY, width - MARGIN * 2, FOOTER_H)
end
-- Fills the game list with one row per game and routes their clicks here.
MENU_RENDER.buildList = function()
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
MENU_RENDER.showGame = function(game)
menuRow(GAME_SELECTED):SetClass("selected", true)
scrollPending = true
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
MENU_RENDER.hideGame = function()
-- The document keeps nothing of its own per game; only the highlight has to come off.
if GAME_SELECTED then
menuRow(GAME_SELECTED):SetClass("selected", false)
end
end
-- Rows that fit in the list at once: what left and right page by.
MENU_RENDER.pageSize = function()
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
MENU_RENDER.frame = function()
-- Attract Mode Video, under the hole the document leaves for it
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
if scrollPending then
menuScrollToSelection()
end
guiDraw(GUI)
end
MENU_RENDER.tools = function()
local rows = {}
-- The tools redraw only when they say something changed; the document keeps what it was given.
if TOOL_DIRTY then
TOOL_DIRTY = false
for _, row in ipairs(TOOL_ROWS) do
rows[#rows + 1] = toolRow(row)
end
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
menuElement("games"):SetClass("hidden", toolsActive())
menuElement("tool"):SetClass("hidden", not toolsActive())
menuElement("tool"):SetClass("clear", TOOL_CLEAR)
menuElement("tool"):SetClass("flash", TOOL_FLASH)
end
guiDraw(GUI)
end

349
assets/MenuOverlay.singe Normal file
View file

@ -0,0 +1,349 @@
--[[
*
* 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 menu drawn with sprites and fontPrint straight into the overlay: the look Singe 2 had, and
-- the one that works on a machine whose GPU device never came up, where there is no GUI document to
-- build. Singe/MenuDocument.singe is the other renderer; Singe/Menu.singe loads exactly one of the
-- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it.
--
-- One game is on screen at a time, as it always was here, so there is no list to page through
-- visually: up and down move one game, left and right move ten, and the line under the marquee
-- says where in the library the selection is. A description too long for its column is clipped
-- rather than scrolled, because up and down now belong to the list.
local MARGIN_X = 25
local MARGIN_Y = 25
local VIDEO_W = 320
local VIDEO_H = 200
local MARQUEE_H = 75
local LINE_HEIGHT = 24
local LABEL_W = 150
local SUB_INDENT = 20
local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] "
local font = nil
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW = 0, 0, 0
local textX, textY, textW, textH = 0, 0, 0, 0
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
local spriteCabinet = nil
local spriteMarquee = nil
local textSprites = {}
local textHeight = 0
local textLimit = 0
-- A games.dat entry need not carry every field, and one that is missing must not take the menu
-- down; the document renderer is tolerant the same way, through menuEscape.
local function field(value)
return tostring(value or "")
end
-- Splits text into the lines that fit the width given, measuring with the selected font. The only
-- way to measure a string is to render it, so this is slow enough to be worth doing once when
-- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a
-- line ending.
--
-- The space between words is kept as it was written rather than squeezed to one, because a tool
-- lines its columns up with runs of spaces and there is no other way here to say "a wide gap".
local function fitLines(text, maxWidth)
local lines = {}
local line = ""
local pending = ""
local trimBreak = utilTrim(WRAP_BREAK)
for word, space in (text .. WRAP_BREAK):gmatch("(%S+)(%s*)") do
if word == trimBreak then
lines[#lines + 1] = line
line = ""
pending = ""
else
local candidate = line .. pending .. word
local measured = fontToSprite(candidate)
local tooWide = spriteGetWidth(measured) > maxWidth
spriteUnload(measured)
if tooWide and line ~= "" then
lines[#lines + 1] = line
line = word
else
line = candidate
end
pending = space
end
end
if line ~= "" then
lines[#lines + 1] = line
end
return lines
end
-- The service tools, laid out. Each entry is one line of the page and each line is a list of
-- pieces: where the piece starts, what it says and which colour it wants. The colours are roles
-- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint
-- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in.
local toolLines = {}
local function toolLine(pieces)
toolLines[#toolLines + 1] = pieces
end
-- Text too long for the page wraps under itself rather than running off the edge.
local function toolWrapped(text, x, role)
for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do
toolLine({ { x = x, text = line, role = role } })
end
end
local function toolsLayout()
local valueX = MARGIN_X + LABEL_W
local wasKeys = false
toolLines = {}
for _, row in ipairs(TOOL_ROWS) do
-- The key hints stand away from whatever the tool put above them, as they do in the
-- document, but sit together when a tool needs more than one line of them.
if row.kind == "keys" and not wasKeys then
toolLine({})
end
wasKeys = (row.kind == "keys")
if row.kind == "blank" then
toolLine({})
elseif row.kind == "pair" then
-- The label and the first line of the value share a line; the rest of the value wraps
-- under itself, in its own column.
local lines = fitLines(row.value, overlayGetWidth() - MARGIN_X - valueX)
toolLine({ { x = MARGIN_X, text = row.label, role = "quiet" },
{ x = valueX, text = lines[1] or "", role = "ink" } })
for index = 2, #lines do
toolLine({ { x = valueX, text = lines[index], role = "ink" } })
end
elseif row.kind == "sub" then
toolWrapped(row.text, MARGIN_X + SUB_INDENT, "quiet")
elseif row.kind == "keys" then
toolWrapped(row.text, MARGIN_X, "quiet")
elseif row.kind == "item" then
toolLine({ { x = MARGIN_X, text = (row.selected and "> " or " ") .. row.text,
role = row.selected and "pick" or "ink" } })
else
toolWrapped(row.text, MARGIN_X, "ink")
end
end
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontQuality(FONT_QUALITY_BLENDED)
fontSelect(font)
end
MENU_RENDER.finish = function()
if font then
fontUnload(font)
font = nil
end
end
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
MENU_RENDER.layout = function()
local width = overlayGetWidth()
local height = overlayGetHeight()
marqueeW = VIDEO_W
marqueeX = width - MARGIN_X - marqueeW
marqueeY = MARGIN_Y
videoW = VIDEO_W
videoH = VIDEO_H
videoX = width - MARGIN_X - videoW
videoY = MARGIN_Y + MARQUEE_H + MARGIN_Y
textW = VIDEO_W
textX = width - MARGIN_X - textW
textY = videoY + videoH + MARGIN_Y
textH = height - MARGIN_Y - textY
cabinetX = MARGIN_X
cabinetY = MARGIN_Y
cabinetW = videoX - MARGIN_X - cabinetX
cabinetH = height - MARGIN_Y - cabinetY
end
MENU_RENDER.buildList = function()
-- One game is on screen at a time; there is no list to build.
end
MENU_RENDER.showGame = function(game)
spriteCabinet = spriteLoad(game.CABINET)
spriteMarquee = spriteLoad(game.MARQUEE)
-- The details follow the description in one wrapped block, the way this menu has always shown
-- them. The document menu gives them their own panel instead.
local textBox = field(game.DESCRIPTION) .. WRAP_BREAK .. WRAP_BREAK ..
"Year: " .. field(game.YEAR) .. WRAP_BREAK ..
"Genre: " .. field(game.GENRE) .. WRAP_BREAK ..
"Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK .. WRAP_BREAK ..
"Singe Port: " .. field(game.CREATOR) .. WRAP_BREAK ..
"Source: " .. field(game.SOURCE)
-- fontToSprite bakes the colour in, so it is set before the sprites are made rather than
-- before they are drawn.
colorForeground(255, 255, 255, 255)
for _, line in ipairs(fitLines(textBox, textW)) do
textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
end
MENU_RENDER.hideGame = function()
if spriteCabinet then
spriteUnload(spriteCabinet)
spriteCabinet = nil
end
if spriteMarquee then
spriteUnload(spriteMarquee)
spriteMarquee = nil
end
for _, handle in ipairs(textSprites) do
if handle >= 0 then
spriteUnload(handle)
end
end
textSprites = {}
end
MENU_RENDER.pageSize = function()
return PAGE_ROWS
end
MENU_RENDER.frame = function()
local x = 0
local y = 0
local shown = 0
-- Cabinet image
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
y = cabinetY + (cabinetH - spriteGetHeight(spriteCabinet)) * 0.5
spriteDraw(spriteCabinet, x, y)
-- Marquee image
x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5
y = marqueeY + (MARQUEE_H - spriteGetHeight(spriteMarquee)) * 0.5
spriteDraw(spriteMarquee, x, y)
-- Attract mode video
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
-- Which game this is, since there is no list to look at
colorForeground(255, 211, 90, 255)
fontPrint(cabinetX, cabinetY, string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT))
-- Game description, clipped to the lines that fit
y = textY
for _, handle in ipairs(textSprites) do
-- Find height of font and number of lines that fit
if textHeight == 0 and handle >= 0 then
textHeight = spriteGetHeight(handle)
textLimit = math.floor(textH / textHeight)
end
if shown < textLimit then
if handle >= 0 then
spriteDraw(handle, textX, y)
end
y = y + textHeight + 1
shown = shown + 1
end
end
end
MENU_RENDER.tools = function()
local y = MARGIN_Y
-- A tool that draws its own picture into the overlay has already done so, and the page has to
-- let it through; every other tool gets a page painted over everything.
if not TOOL_CLEAR then
if TOOL_FLASH then
colorBackground(255, 255, 255, 255)
else
colorBackground(0, 0, 0, 255)
end
overlayClear()
end
-- Measuring text means rendering it, so the page is laid out only when a tool says it changed.
if TOOL_DIRTY then
TOOL_DIRTY = false
toolsLayout()
end
local ink = TOOL_FLASH and 0 or 255
local quiet = TOOL_FLASH and 90 or 160
-- A page that lets the overlay through gathers its text along the bottom, out of the way of
-- whatever the tool drew: the light gun's corner targets in particular.
if TOOL_CLEAR then
y = overlayGetHeight() - MARGIN_Y - (#toolLines + 1) * LINE_HEIGHT
end
colorForeground(ink, ink, ink, 255)
fontPrint(MARGIN_X, y, TOOL_TITLE)
y = y + LINE_HEIGHT
if not TOOL_CLEAR then
y = y + LINE_HEIGHT
end
for _, line in ipairs(toolLines) do
for _, piece in ipairs(line) do
if piece.role == "quiet" then
colorForeground(quiet, quiet, quiet, 255)
elseif piece.role == "pick" then
colorForeground(255, 211, 90, 255)
else
colorForeground(ink, ink, ink, 255)
end
fontPrint(piece.x, y, piece.text)
end
y = y + LINE_HEIGHT
end
end

View file

@ -23,27 +23,81 @@
-- The service tools: everything an operator or a developer needs to check a cabinet, reached by -- The service tools: everything an operator or a developer needs to check a cabinet, reached by
-- pressing SERVICE in the menu. Each tool is a page of Singe/Menu.rml and an entry in TOOLS with -- pressing SERVICE in the menu. Each tool is an entry in TOOLS with up to four functions: begin
-- up to four functions: begin when it opens, input for a switch, update once a frame, and finish -- when it opens, input for a switch, update once a frame, and finish when it closes. Anything a
-- when it closes. Anything a tool leaves out simply does not happen. -- tool leaves out simply does not happen.
-- --
-- Only the RmlUi menu has these. Singe/MenuClassic.singe exists for a machine with no GPU device, -- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub,
-- where there is no document to put a page in; it keeps its own audio delay screen and nothing -- toolsKeys, toolsItem and toolsBlank constructors and hands it to toolsShow; the menu that loaded
-- else. -- this file draws it whichever way it draws everything else. That is what lets the same ten tools
-- run over the RmlUi document and over the plain overlay on a machine with no GPU.
local lfs = require("lfs") local lfs = require("lfs")
TOOLS = {}
TOOL_SELECTED = 1 TOOL_SELECTED = 1
TOOL_OPEN = nil -- The tool being used, or nil while the list itself is showing TOOL_OPEN = nil -- The tool being used, or nil while the list itself is showing
TOOLS = {} TOOL_SHOWING = false -- Whether the tools have the screen at all
-- What the menu draws. It redraws when TOOL_DIRTY is set and clears it; a tool that changes
-- nothing costs nothing.
TOOL_TITLE = ""
TOOL_ROWS = {}
TOOL_FLASH = false -- One frame of white, for the audio delay tool
TOOL_CLEAR = false -- This page lets the overlay through: the tool draws its own picture
TOOL_DIRTY = false
-- Writing into a page is the one thing every tool does, and the document and the GUI are the same -- ===== Rows =====
-- ones every time, so they are named here rather than in twenty-two places.
local function set(id, text) -- A row is a table with a kind the menu knows how to draw. Tools never build one by hand.
guiSetValue(GUI, DOCUMENT, id, text) function toolsText(text)
return { kind = "text", text = text }
end
function toolsPair(label, value)
return { kind = "pair", label = label, value = tostring(value) }
end
function toolsSub(text)
return { kind = "sub", text = text }
end
function toolsKeys(text)
return { kind = "keys", text = text }
end
function toolsItem(text, selected)
return { kind = "item", text = text, selected = selected }
end
function toolsBlank()
return { kind = "blank" }
end
-- What the page says now. Called as often as a tool likes; only the last one before the menu
-- draws is seen.
function toolsShow(rows)
TOOL_ROWS = rows
TOOL_DIRTY = true
end
-- The one frame of white the audio delay tool flashes. A menu that cannot invert its page can
-- ignore it, and the tool still works by ear.
function toolsFlash(on)
if TOOL_FLASH ~= on then
TOOL_FLASH = on
TOOL_DIRTY = true
end
end end
@ -51,8 +105,8 @@ end
function toolsBegin() function toolsBegin()
TOOL_OPEN = nil TOOL_OPEN = nil
TOOL_SHOWING = true
toolsShowList() toolsShowList()
toolsShowPage("tools")
end end
@ -61,37 +115,36 @@ function toolsEnd()
TOOL_OPEN.finish() TOOL_OPEN.finish()
end end
TOOL_OPEN = nil TOOL_OPEN = nil
toolsShowPage(nil) TOOL_SHOWING = false
end TOOL_CLEAR = false
TOOL_FLASH = false
TOOL_DIRTY = true
-- One page of the document at a time, and the game list when nothing is named.
function toolsShowPage(id)
menuElement("games"):SetClass("hidden", id ~= nil)
menuElement("tools"):SetClass("hidden", id ~= "tools")
for _, tool in ipairs(TOOLS) do
menuElement(tool.id):SetClass("hidden", id ~= tool.id)
end
end end
function toolsShowList() function toolsShowList()
local rows = {} local rows = {}
TOOL_TITLE = "Service Tools"
TOOL_CLEAR = false
for i, tool in ipairs(TOOLS) do for i, tool in ipairs(TOOLS) do
local class = (i == TOOL_SELECTED) and ' class="selected"' or "" rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED)
rows[#rows + 1] = string.format("<div%s>%s</div>", class, tool.name)
end end
set("toolList", table.concat(rows)) rows[#rows + 1] = toolsBlank()
set("toolHelp", TOOLS[TOOL_SELECTED].help or "") rows[#rows + 1] = toolsText(TOOLS[TOOL_SELECTED].help or "")
rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: open Service: back to the games")
toolsShow(rows)
end end
function toolsOpen(index) function toolsOpen(index)
TOOL_OPEN = TOOLS[index] TOOL_OPEN = TOOLS[index]
TOOL_TITLE = TOOL_OPEN.name
TOOL_CLEAR = TOOL_OPEN.clear or false
if TOOL_OPEN.begin then if TOOL_OPEN.begin then
TOOL_OPEN.begin() TOOL_OPEN.begin()
end end
toolsShowPage(TOOL_OPEN.id) TOOL_DIRTY = true
end end
@ -100,8 +153,8 @@ function toolsClose()
TOOL_OPEN.finish() TOOL_OPEN.finish()
end end
TOOL_OPEN = nil TOOL_OPEN = nil
TOOL_FLASH = false
toolsShowList() toolsShowList()
toolsShowPage("tools")
end end
@ -141,7 +194,7 @@ end
-- Whether the tools have the screen, which is what the menu asks before doing anything of its own. -- Whether the tools have the screen, which is what the menu asks before doing anything of its own.
function toolsActive() function toolsActive()
return TOOL_OPEN ~= nil or not menuElement("tools"):IsClassSet("hidden") return TOOL_SHOWING
end end
@ -158,12 +211,18 @@ local calValue, calBeat, calClickDone, calFlashDone
local function audioShow() local function audioShow()
set("calValue", tostring(calValue)) toolsShow({
toolsText("Adjust until the flash and the click happen together."),
toolsBlank(),
toolsPair("Delay", calValue .. " ms"),
toolsPair("Device queue", singeGetAudioLatency() .. " ms"),
toolsKeys("Left / Right: 10 ms Up / Down: 1 ms Start: reset to 0"),
toolsKeys("Button 1: save Button 2: back"),
})
end end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolAudio",
name = "Audio Delay", name = "Audio Delay",
help = "Line the click up with the flash so sound and picture agree.", help = "Line the click up with the flash so sound and picture agree.",
@ -172,7 +231,6 @@ TOOLS[#TOOLS + 1] = {
calBeat = singeGetTicks() - CAL_PERIOD calBeat = singeGetTicks() - CAL_PERIOD
calClickDone = true calClickDone = true
calFlashDone = true calFlashDone = true
set("calLatency", tostring(singeGetAudioLatency()))
audioShow() audioShow()
end, end,
@ -218,14 +276,14 @@ TOOLS[#TOOLS + 1] = {
end end
if not calFlashDone and (now - calBeat >= singeGetAudioLatency() + calValue) then if not calFlashDone and (now - calBeat >= singeGetAudioLatency() + calValue) then
calFlashDone = true calFlashDone = true
menuElement("toolAudio"):SetClass("flash", true) toolsFlash(true)
elseif calFlashDone then elseif calFlashDone then
menuElement("toolAudio"):SetClass("flash", false) toolsFlash(false)
end end
end, end,
finish = function() finish = function()
menuElement("toolAudio"):SetClass("flash", false) toolsFlash(false)
end, end,
} }
@ -251,7 +309,6 @@ end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolInput",
name = "Input Test", name = "Input Test",
help = "Controllers, keyboard, mice and light guns, live.", help = "Controllers, keyboard, mice and light guns, live.",
@ -269,9 +326,9 @@ TOOLS[#TOOLS + 1] = {
update = function() update = function()
local rows = {} local rows = {}
rows[#rows + 1] = string.format("<div class='row'>Last switch: <b>%s</b> &nbsp; count: %d</div>", inputLast, inputCount) rows[#rows + 1] = toolsPair("Last switch", string.format("%s count: %d", inputLast, inputCount))
rows[#rows + 1] = string.format("<div class='row'>Gamepads: %d &nbsp; Mice: %d &nbsp; Stick drives mouse: %s</div>", rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s",
controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled())) controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled())))
for slot = 0, SINGE_MAX_CONTROLLERS - 1 do for slot = 0, SINGE_MAX_CONTROLLERS - 1 do
if controllerIsValid(slot) then if controllerIsValid(slot) then
local axes = {} local axes = {}
@ -286,16 +343,17 @@ TOOLS[#TOOLS + 1] = {
buttons[#buttons + 1] = tostring(b) buttons[#buttons + 1] = tostring(b)
end end
end end
rows[#rows + 1] = string.format("<div class='row'>%d: <b>%s</b></div>", slot, controllerGetName(slot)) rows[#rows + 1] = toolsText(string.format("%d: %s", slot, controllerGetName(slot)))
rows[#rows + 1] = string.format("<div class='sub'>axes %s &nbsp; buttons %s</div>", rows[#rows + 1] = toolsSub(string.format("axes %s buttons %s",
table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-") table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-"))
end end
end end
for m = 0, mouseHowMany() - 1 do for m = 0, mouseHowMany() - 1 do
local x, y = mouseGetPosition(m) local x, y = mouseGetPosition(m)
rows[#rows + 1] = string.format("<div class='sub'>mouse %d: %s at %d, %d</div>", m, mouseGetName(m), x, y) rows[#rows + 1] = toolsSub(string.format("mouse %d: %s at %d, %d", m, mouseGetName(m), x, y))
end end
set("inputRows", table.concat(rows)) rows[#rows + 1] = toolsKeys("Press anything. Button 2: back")
toolsShow(rows)
end, end,
} }
@ -305,29 +363,26 @@ TOOLS[#TOOLS + 1] = {
-- Everything the trace header knows, on one screen. A photograph of this answers most of a bug -- Everything the trace header knows, on one screen. A photograph of this answers most of a bug
-- report without anyone having to find trace.txt, which is the point of it. -- report without anyone having to find trace.txt, which is the point of it.
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolSystem",
name = "System Information", name = "System Information",
help = "What this machine is and what the engine chose to run on.", help = "What this machine is and what the engine chose to run on.",
begin = function() begin = function()
local i = singeGetSystemInfo() local i = singeGetSystemInfo()
local rows = {} toolsShow({
local function line(label, value) toolsPair("Singe", i.version),
rows[#rows + 1] = string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>", label, tostring(value)) toolsPair("System", i.os),
end toolsPair("Processor", i.cpu),
line("Singe", i.version) toolsPair("Renderer", i.renderer),
line("System", i.os) toolsPair("3D device", i.gpu),
line("Processor", i.cpu) toolsPair("Video", i.decoder),
line("Renderer", i.renderer) toolsPair("Audio", i.audio),
line("3D device", i.gpu) toolsPair("SoundFont", i.soundFont),
line("Video", i.decoder) toolsPair("MIDI", i.midi),
line("Audio", i.audio) toolsPair("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d",
line("SoundFont", i.soundFont) i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight())),
line("MIDI", i.midi) toolsPair("Data", i.dataPath),
line("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d", toolsKeys("Button 2: back"),
i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight())) })
line("Data", i.dataPath)
set("systemRows", table.concat(rows))
end, end,
} }
@ -337,6 +392,7 @@ TOOLS[#TOOLS + 1] = {
-- Where the gun is pointing, against the picture it is pointing at. A cabinet needs this every -- Where the gun is pointing, against the picture it is pointing at. A cabinet needs this every
-- time the gun or the monitor moves, and nothing else in the engine shows the mapping from the -- time the gun or the monitor moves, and nothing else in the engine shows the mapping from the
-- gun's own coordinates to the canvas. -- gun's own coordinates to the canvas.
--
-- The targets: one in the middle and one in each corner, set in far enough that a gun that is -- The targets: one in the middle and one in each corner, set in far enough that a gun that is
-- slightly out still puts its shot on the screen beside them. -- slightly out still puts its shot on the screen beside them.
local GUN_SHOTS_KEPT = 12 local GUN_SHOTS_KEPT = 12
@ -348,14 +404,23 @@ local gunShots = {}
local gunLast = "none yet" local gunLast = "none yet"
local function gunShow()
toolsShow({
toolsPair("Last shot", string.format("%s shots shown: %d", gunLast, #gunShots)),
toolsKeys("Button 1: fire Start: clear Button 2: back"),
})
end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolGun",
name = "Light Gun", name = "Light Gun",
help = "Aim and fire: see where the shot actually lands.", help = "Aim and fire: see where the shot actually lands.",
clear = true,
begin = function() begin = function()
gunShots = {} gunShots = {}
gunLast = "none yet" gunLast = "none yet"
gunShow()
end, end,
input = function(what) input = function(what)
@ -366,9 +431,11 @@ TOOLS[#TOOLS + 1] = {
table.remove(gunShots, 1) table.remove(gunShots, 1)
end end
gunLast = string.format("%d, %d", x, y) gunLast = string.format("%d, %d", x, y)
gunShow()
return true return true
elseif what == SWITCH_START1 or what == SWITCH_START2 then elseif what == SWITCH_START1 or what == SWITCH_START2 then
gunShots = {} gunShots = {}
gunShow()
return true return true
end end
return false return false
@ -394,8 +461,6 @@ TOOLS[#TOOLS + 1] = {
overlayLine(shot.x - GUN_CROSS, shot.y, shot.x + GUN_CROSS, shot.y) overlayLine(shot.x - GUN_CROSS, shot.y, shot.x + GUN_CROSS, shot.y)
overlayLine(shot.x, shot.y - GUN_CROSS, shot.x, shot.y + GUN_CROSS) overlayLine(shot.x, shot.y - GUN_CROSS, shot.x, shot.y + GUN_CROSS)
end end
set("gunLast", gunLast)
set("gunCount", tostring(#gunShots))
end, end,
finish = function() finish = function()
@ -411,22 +476,34 @@ TOOLS[#TOOLS + 1] = {
local soundStep = 0 local soundStep = 0
local soundNames = { "left speaker", "right speaker", "both speakers" } local soundNames = { "left speaker", "right speaker", "both speakers" }
local soundPans = { -1.0, 1.0, 0.0 } local soundPans = { -1.0, 1.0, 0.0 }
local soundWhich = ""
local soundList = ""
local function soundShow()
toolsShow({
toolsPair("Playing", soundWhich),
toolsPair("Disc tracks", soundList),
toolsKeys("Button 1: left, right, both Left / Right: disc track Button 2: back"),
})
end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolSound",
name = "Sound Test", name = "Sound Test",
help = "Left, right, both, and the disc's own audio tracks.", help = "Left, right, both, and the disc's own audio tracks.",
begin = function() begin = function()
soundStep = 0
set("soundWhich", "press Button 1")
local tracks = {} local tracks = {}
soundStep = 0
soundWhich = "press Button 1"
for t = 0, discGetAudioTracks() - 1 do for t = 0, discGetAudioTracks() - 1 do
local code = discGetLanguage(t) local code = discGetLanguage(t)
tracks[#tracks + 1] = string.format("%d:%s", t, (code ~= "" and code ~= "unk") and code or "unnamed") tracks[#tracks + 1] = string.format("%d:%s", t, (code ~= "" and code ~= "unk") and code or "unnamed")
end end
set("soundTracks", (#tracks > 0) and table.concat(tracks, " &nbsp; ") or "the disc has none") soundList = (#tracks > 0) and table.concat(tracks, " ") or "the disc has none"
soundShow()
end, end,
input = function(what) input = function(what)
@ -434,15 +511,16 @@ TOOLS[#TOOLS + 1] = {
soundStep = (soundStep % #soundNames) + 1 soundStep = (soundStep % #soundNames) + 1
soundSetPan(SND_CLICK, soundPans[soundStep]) soundSetPan(SND_CLICK, soundPans[soundStep])
soundPlay(SND_CLICK) soundPlay(SND_CLICK)
set("soundWhich", soundNames[soundStep]) soundWhich = soundNames[soundStep]
soundShow()
return true return true
elseif what == SWITCH_LEFT or what == SWITCH_RIGHT then elseif what == SWITCH_LEFT or what == SWITCH_RIGHT then
-- Walk the disc's audio tracks, which is how a multi language release is checked.
local count = discGetAudioTracks() local count = discGetAudioTracks()
if count > 1 then if count > 1 then
local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count
discSetAudioTrack(next) discSetAudioTrack(next)
set("soundWhich", string.format("disc track %d (%s)", next, discGetLanguage(next))) soundWhich = string.format("disc track %d (%s)", next, discGetLanguage(next))
soundShow()
end end
return true return true
end end
@ -470,23 +548,29 @@ local DISP_ROWS = 12
local DISP_STEPS = 8 local DISP_STEPS = 8
local DISP_STEP_WIDE = 32 local DISP_STEP_WIDE = 32
local DISP_WEDGE = 16 local DISP_WEDGE = 16
local dispSaved = ""
local function displayShow() local function displayShow()
local x, y = vldpGetShift() local x, y = vldpGetShift()
set("dispValues", string.format("scale %d%% &nbsp; shift %d, %d &nbsp; rotation %d", toolsShow({
vldpGetScale(), x, y, vldpGetRotate())) toolsPair("Picture", string.format("scale %d%% shift %d, %d rotation %d",
vldpGetScale(), x, y, vldpGetRotate())),
toolsPair("Kept", dispSaved),
toolsKeys("Arrows: move Button 3 / 4: size Coin 1: rotate Start: reset"),
toolsKeys("Button 1: keep for this machine Button 2: back"),
})
end end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolDisplay",
name = "Display", name = "Display",
help = "Size, position and rotation of the picture, and a test pattern.", help = "Size, position and rotation of the picture, and a test pattern.",
clear = true,
begin = function() begin = function()
dispSaved = "not yet"
displayShow() displayShow()
set("dispSaved", "")
end, end,
input = function(what) input = function(what)
@ -511,13 +595,14 @@ TOOLS[#TOOLS + 1] = {
vldpSetRotate(0) vldpSetRotate(0)
elseif what == SWITCH_BUTTON1 then elseif what == SWITCH_BUTTON1 then
singeSaveGeometry() singeSaveGeometry()
set("dispSaved", "kept for this machine") dispSaved = "kept for this machine"
displayShow()
return true return true
else else
return false return false
end end
dispSaved = "not yet"
displayShow() displayShow()
set("dispSaved", "")
return true return true
end, end,
@ -563,6 +648,7 @@ TOOLS[#TOOLS + 1] = {
-- How long the disc takes to find a frame. A rip with its keyframes far apart seeks slowly, and -- How long the disc takes to find a frame. A rip with its keyframes far apart seeks slowly, and
-- the only symptom in a game is that everything feels heavy; this measures it directly. -- the only symptom in a game is that everything feels heavy; this measures it directly.
--
-- Twelve seeks spread over the disc, and a seek that has not landed in three seconds has failed -- Twelve seeks spread over the disc, and a seek that has not landed in three seconds has failed
-- rather than been slow. -- rather than been slow.
local DISC_SEEKS = 12 local DISC_SEEKS = 12
@ -572,8 +658,21 @@ local discPending = nil
local discWorst = 0 local discWorst = 0
local function discShow(note)
local rows = {}
if note ~= nil then
rows[#rows + 1] = toolsText(note)
end
for _, line in ipairs(discResults) do
rows[#rows + 1] = line
end
rows[#rows + 1] = toolsKeys("Button 1: run Button 2: back")
toolsShow(rows)
end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolDisc",
name = "Disc Test", name = "Disc Test",
help = "Seek timing: how quickly the disc finds a frame.", help = "Seek timing: how quickly the disc finds a frame.",
@ -581,7 +680,7 @@ TOOLS[#TOOLS + 1] = {
discResults = {} discResults = {}
discPending = nil discPending = nil
discWorst = 0 discWorst = 0
set("discRows", "<div class='row'>Button 1 runs the test.</div>") discShow("Button 1 runs the test.")
end, end,
input = function(what) input = function(what)
@ -589,6 +688,7 @@ TOOLS[#TOOLS + 1] = {
discResults = {} discResults = {}
discWorst = 0 discWorst = 0
discPending = { step = 0, started = 0 } discPending = { step = 0, started = 0 }
discShow("Running.")
return true return true
end end
return false return false
@ -610,14 +710,16 @@ TOOLS[#TOOLS + 1] = {
end end
if discGetFrame() == discPending.target or singeGetTicks() - discPending.started > DISC_SEEK_LIMIT then if discGetFrame() == discPending.target or singeGetTicks() - discPending.started > DISC_SEEK_LIMIT then
local took = singeGetTicks() - discPending.started local took = singeGetTicks() - discPending.started
discResults[#discResults + 1] = string.format("<div class='sub'>frame %d took %d ms</div>", discPending.target, took) discResults[#discResults + 1] = toolsSub(string.format("frame %d took %d ms", discPending.target, took))
discWorst = math.max(discWorst, took) discWorst = math.max(discWorst, took)
discPending.started = 0 discPending.started = 0
if discPending.step >= DISC_SEEKS then if discPending.step >= DISC_SEEKS then
discResults[#discResults + 1] = string.format("<div class='row'>worst <b>%d ms</b> over %d seeks</div>", discWorst, discPending.step) discResults[#discResults + 1] = toolsText(string.format("worst %d ms over %d seeks", discWorst, discPending.step))
discPending = nil discPending = nil
discShow(nil)
else
discShow("Running.")
end end
set("discRows", table.concat(discResults))
end end
end, end,
@ -638,26 +740,25 @@ local saveSelected = 1
local function savedShow() local function savedShow()
local rows = {} local rows = {}
if #saveDirs == 0 then if #saveDirs == 0 then
rows[#rows + 1] = "<div class='row'>No game has saved anything yet.</div>" rows[#rows + 1] = toolsText("No game has saved anything yet.")
end end
for i, entry in ipairs(saveDirs) do for i, entry in ipairs(saveDirs) do
local class = (i == saveSelected) and " class='selected'" or "" rows[#rows + 1] = toolsItem(string.format("%s %d bytes", entry.name, entry.size), i == saveSelected)
rows[#rows + 1] = string.format("<div%s>%s &nbsp; <span class='sub'>%d bytes</span></div>", class, entry.name, entry.size)
end end
set("savedRows", table.concat(rows)) rows[#rows + 1] = toolsKeys("Up / Down: choose Button 3: delete this game's save Button 2: back")
toolsShow(rows)
end end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolSaved",
name = "Saved Data", name = "Saved Data",
help = "What each game has kept, and clearing it.", help = "What each game has kept, and clearing it.",
begin = function() begin = function()
saveDirs = {} saveDirs = {}
saveSelected = 1 saveSelected = 1
-- Every game's data directory is one below the root the engine was given.
-- lfs.dir hands back its iterator and the directory it walks; the iterator is useless -- lfs.dir hands back its iterator and the directory it walks; the iterator is useless
-- without it, so both are kept. pcall because the root need not exist yet. -- without it, so both are kept. pcall because the root need not exist yet.
local root = singeGetSystemInfo().dataRoot local root = singeGetSystemInfo().dataRoot
@ -707,6 +808,7 @@ TOOLS[#TOOLS + 1] = {
-- The ports this machine has, and whether anything is coming in or going out. Nothing is opened -- The ports this machine has, and whether anything is coming in or going out. Nothing is opened
-- until this tool asks, so a cabinet that never touches MIDI never pays for it. -- until this tool asks, so a cabinet that never touches MIDI never pays for it.
--
-- Middle C on channel 1 at a plain mezzo-forte: loud enough to hear on any instrument, and on -- Middle C on channel 1 at a plain mezzo-forte: loud enough to hear on any instrument, and on
-- the channel a synthesiser is most likely to have something assigned to. -- the channel a synthesiser is most likely to have something assigned to.
local MIDI_TEST_CHANNEL = 1 local MIDI_TEST_CHANNEL = 1
@ -720,17 +822,19 @@ local midiDirty = false
local function midiShow() local function midiShow()
local rows = {} local rows = {}
rows[#rows + 1] = string.format("<div class='row'>%d in, %d out</div>", midiInputCount(), midiOutputCount())
rows[#rows + 1] = toolsPair("Ports", string.format("%d in, %d out", midiInputCount(), midiOutputCount()))
for i = 0, midiOutputCount() - 1 do for i = 0, midiOutputCount() - 1 do
rows[#rows + 1] = string.format("<div class='sub'>out %d%s %s</div>", i, (i == midiOut) and " *" or "", midiOutputName(i)) rows[#rows + 1] = toolsSub(string.format("out %d%s %s", i, (i == midiOut) and " *" or "", midiOutputName(i)))
end end
for i = 0, midiInputCount() - 1 do for i = 0, midiInputCount() - 1 do
rows[#rows + 1] = string.format("<div class='sub'>in &nbsp;%d%s %s</div>", i, (i == midiIn) and " *" or "", midiInputName(i)) rows[#rows + 1] = toolsSub(string.format("in %d%s %s", i, (i == midiIn) and " *" or "", midiInputName(i)))
end end
for _, m in ipairs(midiSeen) do for _, m in ipairs(midiSeen) do
rows[#rows + 1] = string.format("<div class='sub'>received %s</div>", m) rows[#rows + 1] = toolsSub("received " .. m)
end end
set("midiRows", table.concat(rows)) rows[#rows + 1] = toolsKeys("Button 1: send a note Button 3: look again Button 2: back")
toolsShow(rows)
end end
@ -744,7 +848,6 @@ end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolMidi",
name = "MIDI Ports", name = "MIDI Ports",
help = "List the ports, send a note, watch what arrives.", help = "List the ports, send a note, watch what arrives.",
@ -790,19 +893,25 @@ TOOLS[#TOOLS + 1] = {
-- ===== 10. Frame statistics ===== -- ===== 10. Frame statistics =====
local function statsShow()
toolsShow({
toolsPair("Overlay", statsIsEnabled() and "on" or "off"),
toolsText("It stays on while you play, in the top left corner."),
toolsKeys("Button 1: turn it on or off Button 2: back"),
})
end
TOOLS[#TOOLS + 1] = { TOOLS[#TOOLS + 1] = {
id = "toolStats",
name = "Frame Statistics", name = "Frame Statistics",
help = "The developer's overlay: frame time, what is alive, what the disc is doing.", help = "The developer's overlay: frame time, what is alive, what the disc is doing.",
begin = function() begin = statsShow,
set("statsState", statsIsEnabled() and "on" or "off")
end,
input = function(what) input = function(what)
if what == SWITCH_BUTTON1 then if what == SWITCH_BUTTON1 then
statsEnable(not statsIsEnabled()) statsEnable(not statsIsEnabled())
set("statsState", statsIsEnabled() and "on" or "off") statsShow()
return true return true
end end
return false return false

View file

@ -215,11 +215,21 @@ body {
word-break: break-word; word-break: break-word;
} }
/* The chosen line of a list the tool draws, in the game list's gold. */ /* A line a tool offers to choose between, and the chosen one in the game list's gold -- the same
.tool .selected { way the game list itself shows a selection, since RCSS has no generated content to mark it with. */
.tool .item {
margin-bottom: 2dp;
}
.tool .item.selected {
color: #ffd35a; color: #ffd35a;
} }
/* A gap a tool asked for. */
.tool .blank {
height: 12dp;
}
/* Two tools draw their own picture into the overlay, which is underneath the document: the light /* Two tools draw their own picture into the overlay, which is underneath the document: the light
gun's targets and the display's test pattern. Those pages let it through and gather their text gun's targets and the display's test pattern. Those pages let it through and gather their text
into a strip along the bottom, between the two lower targets, so nothing worth aiming at is into a strip along the bottom, between the two lower targets, so nothing worth aiming at is
@ -238,6 +248,10 @@ body {
} }
.tool.clear h1, .tool.clear h1,
.tool.clear .row,
.tool.clear .pair,
.tool.clear .sub,
.tool.clear .item,
.tool.clear p { .tool.clear p {
background-color: #000000c0; background-color: #000000c0;
padding: 1dp 8dp; padding: 1dp 8dp;

View file

@ -94,14 +94,38 @@ click on a row selects that game and a second click on the same row, or a
click on the Start button in the footer, launches it; the mouse wheel click on the Start button in the footer, launches it; the mouse wheel
scrolls a description too long for its panel. The menu keeps its selection scrolls a description too long for its panel. The menu keeps its selection
in `menu.dat` in its data directory, so it comes back to the same game when in `menu.dat` in its data directory, so it comes back to the same game when
one quits or when Singe is next started. one quits or when Singe is next started. That file is a Lua fragment the menu
reads at startup and rewrites at shutdown: `GAME_SELECTED` is the game it was
showing, `SHOW_INTRO` is false when it is returning from a game so the intro
is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see
<<menurenderers,The two renderers>>).
A GUI needs the GPU device the 3D scene uses, so on a machine without one [#menurenderers]
`guiNew` fails and the menu cannot start. `Singe/MenuClassic.singe` is the ==== The two renderers
previous menu, drawn with sprites and `fontPrint` straight into the overlay,
kept for one release for exactly that machine: edit `Menu.sh` (or `Menu.bat`) A GUI document is drawn through the GPU device the 3D scene uses, so on a
to name it in place of `Singe/Menu.singe`. It has the audio delay screen and machine without one there is no document to build. The menu handles that
nothing else; the rest of the service tools need the document. itself: `Singe/Menu.singe` asks <<singehasgpu,singeHasGpu>> at startup and
loads one of two renderers.
`Singe/MenuDocument.singe`::
The RmlUi document described above, for a machine with a GPU device.
`Singe/MenuOverlay.singe`::
Sprites and `fontPrint` straight into the overlay, the look Singe 2 had. It
needs nothing beyond the overlay every game already draws into.
Both draw the same menu, take the same keys and run the same service tools;
`Singe/Menu.singe` holds everything that is not drawing -- the game list, the
selection, launching a game -- so there is one menu rather than two. The
overlay renderer shows one game at a time instead of a list, since it has no
panel to scroll, and it clips a description too long for its column rather
than scrolling it.
To see the overlay renderer on a machine that does have a GPU, put
`MENU_OVERLAY = true` in `menu.dat` in the menu's data directory; the menu
writes the line back out, so it survives. That is how the overlay path is
tested, and how a cabinet builder who prefers the old look gets it.
[#servicetools] [#servicetools]
==== The Service Tools ==== The Service Tools
@ -109,9 +133,11 @@ nothing else; the rest of the service tools need the document.
The key mapped to `INPUT_SERVICE` opens a list of tools over the game list. The key mapped to `INPUT_SERVICE` opens a list of tools over the game list.
Up and down choose one and button 1 opens it; button 2 backs out of a tool to Up and down choose one and button 1 opens it; button 2 backs out of a tool to
the list, and the service key again leaves the tools altogether. Everything the list, and the service key again leaves the tools altogether. Everything
lives in `Singe/Tools.singe`, which the engine extracts beside `Menu.singe`, lives in `Singe/Tools.singe`, which the engine extracts beside `Menu.singe`.
and each tool is a page of `Singe/Menu.rml`. Nothing here needs a game to be Nothing there draws: a tool builds a list of rows and hands it to the menu,
running, and nothing changes a game's own files. which is why the same ten tools run on both renderers (see
<<menurenderers,The two renderers>>). Nothing here needs a game to be running,
and nothing changes a game's own files.
Audio Delay:: Audio Delay::
The downstream audio delay, measured against a click and a flash. See The downstream audio delay, measured against a click and a flash. See
@ -947,10 +973,11 @@ longer there.
Singe/ Support files extracted by the engine Singe/ Support files extracted by the engine
Framework.singe Loaded by every game (dofile it) Framework.singe Loaded by every game (dofile it)
Menu.singe The bundled game menu Menu.singe The bundled game menu
Menu.rml The menu's RmlUi document
menu.rcss The menu's style sheet, on top of gui.rcss
Tools.singe The menu's service tools (see The Service Tools) Tools.singe The menu's service tools (see The Service Tools)
MenuClassic.singe The previous, overlay-drawn menu (see The Bundled Menu) MenuDocument.singe The menu drawn as a GUI document
MenuOverlay.singe The menu drawn into the overlay, for a machine with no GPU
Menu.rml The document renderer's RmlUi document
menu.rcss Its style sheet, on top of gui.rcss
gui.rcss The shipped GUI theme gui.rcss The shipped GUI theme
controls.cfg.example Template for input mappings controls.cfg.example Template for input mappings
click.wav Used by the menu's audio delay and sound tests click.wav Used by the menu's audio delay and sound tests
@ -1419,9 +1446,10 @@ texture goes wherever a texture can: composited over the overlay with
the same options page works flat on the screen and on the monitor of a the same options page works flat on the screen and on the monitor of a
modeled arcade cabinet. A GUI needs the GPU device the 3D scene uses, so it modeled arcade cabinet. A GUI needs the GPU device the 3D scene uses, so it
is unavailable on a machine without one (see <<sceneperformance,Performance is unavailable on a machine without one (see <<sceneperformance,Performance
and Requirements>>); `guiNew` raises an error there. The bundled menu is such and Requirements>>); `guiNew` raises an error rather than answering false, so
a document, which is why `Singe/MenuClassic.singe` exists (see ask <<singehasgpu,singeHasGpu>> first if your game can do without. The bundled
<<bundledmenu,The Bundled Menu>>). Every call named here has an entry in the menu does exactly that, and draws itself into the overlay instead when the
answer is no (see <<menurenderers,The two renderers>>). Every call named here has an entry in the
<<guiref,Gui>> section of the reference. <<guiref,Gui>> section of the reference.
The language reference for RML and RCSS is RmlUi's own documentation at The language reference for RML and RCSS is RmlUi's own documentation at
@ -14227,6 +14255,40 @@ else
end end
---- ----
[#singehasgpu]
==== singeHasGpu
[source,text]
----
present = singeHasGpu()
----
Whether the GPU device came up. Everything drawn through it is unavailable
without one: the 3D scene, the particles, and every GUI document. Ask this
before asking for any of them, because <<guinew,guiNew>> and the scene calls
end the script rather than answering `false` -- there is no way to try and
recover.
A machine can lack one because it has no Vulkan, Direct3D 12 or Metal driver
at all, or because `--nogpu` was given. The bundled menu calls this at startup
and picks a renderer from the answer (see <<menurenderers,The two renderers>>).
*Returns:* boolean, true when a GPU device is present.
*Since:* 3.00.
*See also:* <<guinew,guiNew>>, <<singegetsysteminfo,singeGetSystemInfo>>, <<sceneperformance,Performance and Requirements>>
.Example
[source,lua]
----
-- A game with a 3D title screen and a flat one to fall back on.
if singeHasGpu() then
buildSceneTitle()
else
buildSpriteTitle()
end
----
[#singequit] [#singequit]
==== singeQuit ==== singeQuit

View file

@ -34,7 +34,8 @@
#include "generated/settings_cfg.h" #include "generated/settings_cfg.h"
#include "generated/Menu_singe.h" #include "generated/Menu_singe.h"
#include "generated/Tools_singe.h" #include "generated/Tools_singe.h"
#include "generated/MenuClassic_singe.h" #include "generated/MenuDocument_singe.h"
#include "generated/MenuOverlay_singe.h"
#include "generated/Menu_rml.h" #include "generated/Menu_rml.h"
#include "generated/menu_rcss.h" #include "generated/menu_rcss.h"
#include "generated/gui_rcss.h" #include "generated/gui_rcss.h"

View file

@ -1791,7 +1791,8 @@ static void _unpackData(const char *exePath, bool absolute) {
{ "settings.cfg.example", settings_cfg, settings_cfg_len }, { "settings.cfg.example", settings_cfg, settings_cfg_len },
{ "Menu.singe", Menu_singe, Menu_singe_len }, { "Menu.singe", Menu_singe, Menu_singe_len },
{ "Tools.singe", Tools_singe, Tools_singe_len }, { "Tools.singe", Tools_singe, Tools_singe_len },
{ "MenuClassic.singe", MenuClassic_singe, MenuClassic_singe_len }, { "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len },
{ "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len },
{ "Menu.rml", Menu_rml, Menu_rml_len }, { "Menu.rml", Menu_rml, Menu_rml_len },
{ "menu.rcss", menu_rcss, menu_rcss_len }, { "menu.rcss", menu_rcss, menu_rcss_len },
{ "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len }, { "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len },

View file

@ -1275,6 +1275,7 @@ static int32_t apiSingeGetPauseFlag(lua_State *L);
static int32_t apiSingeGetScriptPath(lua_State *L); static int32_t apiSingeGetScriptPath(lua_State *L);
static int32_t apiSingeGetTicks(lua_State *L); static int32_t apiSingeGetTicks(lua_State *L);
static int32_t apiSingeGetWidth(lua_State *L); static int32_t apiSingeGetWidth(lua_State *L);
static int32_t apiSingeHasGpu(lua_State *L);
static int32_t apiSingeQuit(lua_State *L); static int32_t apiSingeQuit(lua_State *L);
static int32_t apiSingeReload(lua_State *L); static int32_t apiSingeReload(lua_State *L);
static int32_t apiSingeScreenshot(lua_State *L); static int32_t apiSingeScreenshot(lua_State *L);
@ -5478,6 +5479,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "singeGetScriptPath", apiSingeGetScriptPath); // 1.15 RDG lua_register(L, "singeGetScriptPath", apiSingeGetScriptPath); // 1.15 RDG
lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00 lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00
lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx
lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00
lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG
lua_register(L, "singeReload", apiSingeReload); // 3.00 lua_register(L, "singeReload", apiSingeReload); // 3.00
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00 lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
@ -12724,6 +12726,20 @@ static int32_t apiSingeGetWidth(lua_State *L) {
// singeQuit() // singeQuit()
// Whether the GPU device came up. Everything drawn through it -- the 3D scene, the particles and
// every GUI document -- is unavailable without one, and guiNew ends the script rather than answering
// false, so a script that can do without has to ask before it asks for a GUI.
static int32_t apiSingeHasGpu(lua_State *L) {
bool has = (_global.device != NULL);
_argCheck(L, "singeHasGpu", 0, 0);
_luaTrace(L, "singeHasGpu", "%s", has ? "true" : "false");
lua_pushboolean(L, has);
return 1;
}
static int32_t apiSingeQuit(lua_State *L) { static int32_t apiSingeQuit(lua_State *L) {
_luaTrace(L, "singeQuit", "Quit requested."); _luaTrace(L, "singeQuit", "Quit requested.");
_global.running = false; _global.running = false;