Tools and menu work.
This commit is contained in:
parent
421ef75bdc
commit
f9f67201e3
14 changed files with 1062 additions and 953 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,4 +22,3 @@ docs/.asciidoctor/
|
|||
/Menu.bat
|
||||
/data/
|
||||
screenshots/
|
||||
screenshots-hw/
|
||||
|
|
|
|||
36
CHANGELOG
36
CHANGELOG
|
|
@ -354,10 +354,10 @@ API Changes
|
|||
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
|
||||
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
|
||||
of the same document. The previous overlay-drawn menu is kept for one
|
||||
release as Singe/MenuClassic.singe for a machine without a GPU device,
|
||||
where guiNew fails; name it in Menu.sh or Menu.bat to use it.
|
||||
scroll the description). The service tools are pages of the same
|
||||
document. A machine without a GPU device, where guiNew fails, gets the
|
||||
same menu drawn into the overlay instead; see "One menu, two renderers"
|
||||
below.
|
||||
|
||||
- Physics debug drawing. physicsSetDebug(DEBUG_*) draws the bodies in
|
||||
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
|
||||
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
|
||||
opens a list of ten screens rather than the audio delay screen alone:
|
||||
Audio Delay, Input Test, System Information, Light Gun, Sound Test,
|
||||
Display, Disc Test, Saved Data, MIDI Ports and Frame Statistics.
|
||||
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
|
||||
each is a page of Singe/Menu.rml, so a cabinet builder can add one.
|
||||
MenuClassic keeps the audio delay screen and nothing else.
|
||||
Singe/Tools.singe, which the engine extracts beside Menu.singe. Nothing
|
||||
there draws: a tool builds a list of rows and the menu draws them, so
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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/Menu.singe ${GENERATED_DIR}/Menu_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.rcss ${GENERATED_DIR}/menu_rcss.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "")
|
||||
|
|
|
|||
|
|
@ -32,68 +32,11 @@
|
|||
<button id="start">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tools" class="tool hidden">
|
||||
<h1>Service Tools</h1>
|
||||
<div id="toolList"></div>
|
||||
<p class="value" id="toolHelp"></p>
|
||||
<p class="keys">Up / Down: choose Button 1: open Service: back to the games</p>
|
||||
</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 Up / Down: 1 ms Start: reset to 0</p>
|
||||
<p class="keys">Button 1: save 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> shots shown: <span id="gunCount"></span></p>
|
||||
<p class="keys">Button 1: fire Start: clear 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 Left / Right: disc track 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 Button 3 / 4: size Coin 1: rotate Start: reset</p>
|
||||
<p class="keys">Button 1: keep for this machine 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 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 Button 3: delete this game's save 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 Button 3: look again 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 Button 2: back</p>
|
||||
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
|
||||
rows and Singe/MenuDocument.singe turns them into the divs below it. -->
|
||||
<div id="tool" class="tool hidden">
|
||||
<h1 id="toolTitle"></h1>
|
||||
<div id="toolBody"></div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- 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.
|
||||
|
||||
-- The bundled game menu. Everything the menu *is* lives here: the game list read from every
|
||||
-- games.dat, the selection and how it moves, the attract video, launching a game and coming back,
|
||||
-- and the service tools. Nothing here draws.
|
||||
--
|
||||
-- Drawing is a renderer, chosen once at startup by whether this machine has a GPU device:
|
||||
--
|
||||
-- Singe/MenuDocument.singe an RmlUi document, which needs the GPU device the 3D scene uses
|
||||
-- Singe/MenuOverlay.singe sprites and fontPrint straight into the overlay, which needs nothing
|
||||
--
|
||||
-- The two draw the same menu and take the same keys. A renderer provides the MENU_RENDER table;
|
||||
-- see the top of either file for what is in it.
|
||||
|
||||
dofile("Singe/Framework.singe")
|
||||
dofile("Singe/Tools.singe")
|
||||
|
|
@ -50,18 +57,15 @@ function compareTitles(a, b)
|
|||
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)
|
||||
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()
|
||||
GAME_SELECTED = index
|
||||
loadGameAssets()
|
||||
end
|
||||
|
||||
|
|
@ -82,17 +86,7 @@ function loadGameAssets()
|
|||
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
|
||||
MENU_RENDER.showGame(game)
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -102,152 +96,6 @@ function menuActive()
|
|||
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
|
||||
|
||||
|
||||
function menuStartClick(gui, document, id, event, value)
|
||||
if menuActive() then
|
||||
gameStart()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function onInputPressed(what)
|
||||
|
||||
-- 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
|
||||
gameSelect(GAME_COUNT)
|
||||
else
|
||||
gameSelect(math.max(1, GAME_SELECTED - menuPageSize()))
|
||||
gameSelect(math.max(1, GAME_SELECTED - MENU_RENDER.pageSize()))
|
||||
end
|
||||
elseif what == SWITCH_RIGHT then
|
||||
if GAME_SELECTED == GAME_COUNT then
|
||||
gameSelect(1)
|
||||
else
|
||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + menuPageSize()))
|
||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + MENU_RENDER.pageSize()))
|
||||
end
|
||||
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
|
||||
gameStart()
|
||||
|
|
@ -303,23 +151,17 @@ function onOverlayUpdate()
|
|||
|
||||
if toolsActive() then
|
||||
toolsUpdate()
|
||||
guiDraw(GUI)
|
||||
MENU_RENDER.tools()
|
||||
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)
|
||||
-- The attract clip runs between the two frames the game's entry names.
|
||||
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
|
||||
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
|
||||
end
|
||||
|
||||
if LIST_SCROLL_PENDING then
|
||||
menuScrollToSelection()
|
||||
end
|
||||
guiDraw(GUI)
|
||||
MENU_RENDER.frame()
|
||||
end
|
||||
|
||||
return(OVERLAY_UPDATED)
|
||||
|
|
@ -330,9 +172,7 @@ end
|
|||
function onShutdown()
|
||||
unloadGameAssets()
|
||||
saveConfig(not SHUTDOWN_FROM_PUSH)
|
||||
if GUI then
|
||||
guiDelete(GUI)
|
||||
end
|
||||
MENU_RENDER.finish()
|
||||
if SND_CLICK then
|
||||
soundUnload(SND_CLICK)
|
||||
end
|
||||
|
|
@ -346,6 +186,11 @@ function saveConfig(showIntro)
|
|||
if cfg then
|
||||
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
|
||||
cfg:write("SHOW_INTRO = " .. tostring(showIntro) .. "\n")
|
||||
-- The file is written whole, so a renderer forced here has to be written back or it
|
||||
-- would last exactly one run.
|
||||
if MENU_OVERLAY then
|
||||
cfg:write("MENU_OVERLAY = true\n")
|
||||
end
|
||||
cfg:close()
|
||||
else
|
||||
debugPrint("Unable to write " .. CONFIG_FILE)
|
||||
|
|
@ -360,6 +205,7 @@ function unloadGameAssets()
|
|||
videoUnload(VIDEO_ATTRACT)
|
||||
VIDEO_ATTRACT = nil
|
||||
end
|
||||
MENU_RENDER.hideGame()
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -416,24 +262,10 @@ else
|
|||
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
|
||||
SHUTDOWN_FROM_PUSH = false
|
||||
|
||||
INTRO_DONE = false
|
||||
LIST_SCROLL_PENDING = 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")
|
||||
|
||||
-- Load configuration
|
||||
|
|
@ -451,13 +283,19 @@ 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()
|
||||
-- The renderer. A GUI document is drawn through the GPU device the 3D scene uses, so without
|
||||
-- one there is no document to build and guiNew would end the script rather than answer false;
|
||||
-- the overlay renderer needs nothing but the overlay every game already draws into. Set
|
||||
-- MENU_OVERLAY = true in menu.dat to use it on a machine that has a GPU, which is how the
|
||||
-- overlay path gets tested.
|
||||
if singeHasGpu() and not MENU_OVERLAY then
|
||||
dofile("Singe/MenuDocument.singe")
|
||||
else
|
||||
dofile("Singe/MenuOverlay.singe")
|
||||
end
|
||||
MENU_RENDER.begin()
|
||||
MENU_RENDER.layout()
|
||||
MENU_RENDER.buildList()
|
||||
|
||||
discPlay()
|
||||
if (not SHOW_INTRO) then
|
||||
|
|
|
|||
|
|
@ -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
301
assets/MenuDocument.singe
Normal 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, "&", "&")
|
||||
output = string.gsub(output, "<", "<")
|
||||
output = string.gsub(output, ">", ">")
|
||||
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) .. ' '
|
||||
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(" ", #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
349
assets/MenuOverlay.singe
Normal 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
|
||||
|
|
@ -23,36 +23,90 @@
|
|||
|
||||
|
||||
-- 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
|
||||
-- up to four functions: begin when it opens, input for a switch, update once a frame, and finish
|
||||
-- when it closes. Anything a tool leaves out simply does not happen.
|
||||
-- pressing SERVICE in the menu. Each tool is an entry in TOOLS with up to four functions: begin
|
||||
-- when it opens, input for a switch, update once a frame, and finish when it closes. Anything a
|
||||
-- tool leaves out simply does not happen.
|
||||
--
|
||||
-- Only the RmlUi menu has these. Singe/MenuClassic.singe exists for a machine with no GPU device,
|
||||
-- where there is no document to put a page in; it keeps its own audio delay screen and nothing
|
||||
-- else.
|
||||
-- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub,
|
||||
-- toolsKeys, toolsItem and toolsBlank constructors and hands it to toolsShow; the menu that loaded
|
||||
-- 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")
|
||||
|
||||
|
||||
TOOLS = {}
|
||||
TOOL_SELECTED = 1
|
||||
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
|
||||
-- ones every time, so they are named here rather than in twenty-two places.
|
||||
local function set(id, text)
|
||||
guiSetValue(GUI, DOCUMENT, id, text)
|
||||
-- ===== Rows =====
|
||||
|
||||
-- A row is a table with a kind the menu knows how to draw. Tools never build one by hand.
|
||||
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
|
||||
|
||||
|
||||
-- ===== The list =====
|
||||
|
||||
function toolsBegin()
|
||||
TOOL_OPEN = nil
|
||||
TOOL_OPEN = nil
|
||||
TOOL_SHOWING = true
|
||||
toolsShowList()
|
||||
toolsShowPage("tools")
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -60,38 +114,37 @@ function toolsEnd()
|
|||
if TOOL_OPEN and TOOL_OPEN.finish then
|
||||
TOOL_OPEN.finish()
|
||||
end
|
||||
TOOL_OPEN = nil
|
||||
toolsShowPage(nil)
|
||||
end
|
||||
|
||||
|
||||
-- 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
|
||||
TOOL_OPEN = nil
|
||||
TOOL_SHOWING = false
|
||||
TOOL_CLEAR = false
|
||||
TOOL_FLASH = false
|
||||
TOOL_DIRTY = true
|
||||
end
|
||||
|
||||
|
||||
function toolsShowList()
|
||||
local rows = {}
|
||||
|
||||
TOOL_TITLE = "Service Tools"
|
||||
TOOL_CLEAR = false
|
||||
for i, tool in ipairs(TOOLS) do
|
||||
local class = (i == TOOL_SELECTED) and ' class="selected"' or ""
|
||||
rows[#rows + 1] = string.format("<div%s>%s</div>", class, tool.name)
|
||||
rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED)
|
||||
end
|
||||
set("toolList", table.concat(rows))
|
||||
set("toolHelp", TOOLS[TOOL_SELECTED].help or "")
|
||||
rows[#rows + 1] = toolsBlank()
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
TOOL_OPEN.begin()
|
||||
end
|
||||
toolsShowPage(TOOL_OPEN.id)
|
||||
TOOL_DIRTY = true
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -99,9 +152,9 @@ function toolsClose()
|
|||
if TOOL_OPEN and TOOL_OPEN.finish then
|
||||
TOOL_OPEN.finish()
|
||||
end
|
||||
TOOL_OPEN = nil
|
||||
TOOL_OPEN = nil
|
||||
TOOL_FLASH = false
|
||||
toolsShowList()
|
||||
toolsShowPage("tools")
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -141,7 +194,7 @@ end
|
|||
|
||||
-- Whether the tools have the screen, which is what the menu asks before doing anything of its own.
|
||||
function toolsActive()
|
||||
return TOOL_OPEN ~= nil or not menuElement("tools"):IsClassSet("hidden")
|
||||
return TOOL_SHOWING
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -158,12 +211,18 @@ local calValue, calBeat, calClickDone, calFlashDone
|
|||
|
||||
|
||||
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
|
||||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolAudio",
|
||||
name = "Audio Delay",
|
||||
help = "Line the click up with the flash so sound and picture agree.",
|
||||
|
||||
|
|
@ -172,7 +231,6 @@ TOOLS[#TOOLS + 1] = {
|
|||
calBeat = singeGetTicks() - CAL_PERIOD
|
||||
calClickDone = true
|
||||
calFlashDone = true
|
||||
set("calLatency", tostring(singeGetAudioLatency()))
|
||||
audioShow()
|
||||
end,
|
||||
|
||||
|
|
@ -218,14 +276,14 @@ TOOLS[#TOOLS + 1] = {
|
|||
end
|
||||
if not calFlashDone and (now - calBeat >= singeGetAudioLatency() + calValue) then
|
||||
calFlashDone = true
|
||||
menuElement("toolAudio"):SetClass("flash", true)
|
||||
toolsFlash(true)
|
||||
elseif calFlashDone then
|
||||
menuElement("toolAudio"):SetClass("flash", false)
|
||||
toolsFlash(false)
|
||||
end
|
||||
end,
|
||||
|
||||
finish = function()
|
||||
menuElement("toolAudio"):SetClass("flash", false)
|
||||
toolsFlash(false)
|
||||
end,
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +309,6 @@ end
|
|||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolInput",
|
||||
name = "Input Test",
|
||||
help = "Controllers, keyboard, mice and light guns, live.",
|
||||
|
||||
|
|
@ -269,9 +326,9 @@ TOOLS[#TOOLS + 1] = {
|
|||
|
||||
update = function()
|
||||
local rows = {}
|
||||
rows[#rows + 1] = string.format("<div class='row'>Last switch: <b>%s</b> count: %d</div>", inputLast, inputCount)
|
||||
rows[#rows + 1] = string.format("<div class='row'>Gamepads: %d Mice: %d Stick drives mouse: %s</div>",
|
||||
controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled()))
|
||||
rows[#rows + 1] = toolsPair("Last switch", string.format("%s count: %d", inputLast, inputCount))
|
||||
rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s",
|
||||
controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled())))
|
||||
for slot = 0, SINGE_MAX_CONTROLLERS - 1 do
|
||||
if controllerIsValid(slot) then
|
||||
local axes = {}
|
||||
|
|
@ -286,16 +343,17 @@ TOOLS[#TOOLS + 1] = {
|
|||
buttons[#buttons + 1] = tostring(b)
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = string.format("<div class='row'>%d: <b>%s</b></div>", slot, controllerGetName(slot))
|
||||
rows[#rows + 1] = string.format("<div class='sub'>axes %s buttons %s</div>",
|
||||
table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-")
|
||||
rows[#rows + 1] = toolsText(string.format("%d: %s", slot, controllerGetName(slot)))
|
||||
rows[#rows + 1] = toolsSub(string.format("axes %s buttons %s",
|
||||
table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-"))
|
||||
end
|
||||
end
|
||||
for m = 0, mouseHowMany() - 1 do
|
||||
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
|
||||
set("inputRows", table.concat(rows))
|
||||
rows[#rows + 1] = toolsKeys("Press anything. Button 2: back")
|
||||
toolsShow(rows)
|
||||
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
|
||||
-- report without anyone having to find trace.txt, which is the point of it.
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolSystem",
|
||||
name = "System Information",
|
||||
help = "What this machine is and what the engine chose to run on.",
|
||||
|
||||
begin = function()
|
||||
local i = singeGetSystemInfo()
|
||||
local rows = {}
|
||||
local function line(label, value)
|
||||
rows[#rows + 1] = string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>", label, tostring(value))
|
||||
end
|
||||
line("Singe", i.version)
|
||||
line("System", i.os)
|
||||
line("Processor", i.cpu)
|
||||
line("Renderer", i.renderer)
|
||||
line("3D device", i.gpu)
|
||||
line("Video", i.decoder)
|
||||
line("Audio", i.audio)
|
||||
line("SoundFont", i.soundFont)
|
||||
line("MIDI", i.midi)
|
||||
line("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d",
|
||||
i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight()))
|
||||
line("Data", i.dataPath)
|
||||
set("systemRows", table.concat(rows))
|
||||
local i = singeGetSystemInfo()
|
||||
toolsShow({
|
||||
toolsPair("Singe", i.version),
|
||||
toolsPair("System", i.os),
|
||||
toolsPair("Processor", i.cpu),
|
||||
toolsPair("Renderer", i.renderer),
|
||||
toolsPair("3D device", i.gpu),
|
||||
toolsPair("Video", i.decoder),
|
||||
toolsPair("Audio", i.audio),
|
||||
toolsPair("SoundFont", i.soundFont),
|
||||
toolsPair("MIDI", i.midi),
|
||||
toolsPair("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d",
|
||||
i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight())),
|
||||
toolsPair("Data", i.dataPath),
|
||||
toolsKeys("Button 2: back"),
|
||||
})
|
||||
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
|
||||
-- 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.
|
||||
--
|
||||
-- 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.
|
||||
local GUN_SHOTS_KEPT = 12
|
||||
|
|
@ -348,14 +404,23 @@ local gunShots = {}
|
|||
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] = {
|
||||
id = "toolGun",
|
||||
name = "Light Gun",
|
||||
help = "Aim and fire: see where the shot actually lands.",
|
||||
name = "Light Gun",
|
||||
help = "Aim and fire: see where the shot actually lands.",
|
||||
clear = true,
|
||||
|
||||
begin = function()
|
||||
gunShots = {}
|
||||
gunLast = "none yet"
|
||||
gunShow()
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
|
|
@ -366,9 +431,11 @@ TOOLS[#TOOLS + 1] = {
|
|||
table.remove(gunShots, 1)
|
||||
end
|
||||
gunLast = string.format("%d, %d", x, y)
|
||||
gunShow()
|
||||
return true
|
||||
elseif what == SWITCH_START1 or what == SWITCH_START2 then
|
||||
gunShots = {}
|
||||
gunShow()
|
||||
return true
|
||||
end
|
||||
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, shot.y - GUN_CROSS, shot.x, shot.y + GUN_CROSS)
|
||||
end
|
||||
set("gunLast", gunLast)
|
||||
set("gunCount", tostring(#gunShots))
|
||||
end,
|
||||
|
||||
finish = function()
|
||||
|
|
@ -411,22 +476,34 @@ TOOLS[#TOOLS + 1] = {
|
|||
local soundStep = 0
|
||||
local soundNames = { "left speaker", "right speaker", "both speakers" }
|
||||
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] = {
|
||||
id = "toolSound",
|
||||
name = "Sound Test",
|
||||
help = "Left, right, both, and the disc's own audio tracks.",
|
||||
|
||||
begin = function()
|
||||
soundStep = 0
|
||||
set("soundWhich", "press Button 1")
|
||||
local tracks = {}
|
||||
|
||||
soundStep = 0
|
||||
soundWhich = "press Button 1"
|
||||
for t = 0, discGetAudioTracks() - 1 do
|
||||
local code = discGetLanguage(t)
|
||||
tracks[#tracks + 1] = string.format("%d:%s", t, (code ~= "" and code ~= "unk") and code or "unnamed")
|
||||
end
|
||||
set("soundTracks", (#tracks > 0) and table.concat(tracks, " ") or "the disc has none")
|
||||
soundList = (#tracks > 0) and table.concat(tracks, " ") or "the disc has none"
|
||||
soundShow()
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
|
|
@ -434,15 +511,16 @@ TOOLS[#TOOLS + 1] = {
|
|||
soundStep = (soundStep % #soundNames) + 1
|
||||
soundSetPan(SND_CLICK, soundPans[soundStep])
|
||||
soundPlay(SND_CLICK)
|
||||
set("soundWhich", soundNames[soundStep])
|
||||
soundWhich = soundNames[soundStep]
|
||||
soundShow()
|
||||
return true
|
||||
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()
|
||||
if count > 1 then
|
||||
local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count
|
||||
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
|
||||
return true
|
||||
end
|
||||
|
|
@ -470,23 +548,29 @@ local DISP_ROWS = 12
|
|||
local DISP_STEPS = 8
|
||||
local DISP_STEP_WIDE = 32
|
||||
local DISP_WEDGE = 16
|
||||
local dispSaved = ""
|
||||
|
||||
|
||||
local function displayShow()
|
||||
local x, y = vldpGetShift()
|
||||
set("dispValues", string.format("scale %d%% shift %d, %d rotation %d",
|
||||
vldpGetScale(), x, y, vldpGetRotate()))
|
||||
toolsShow({
|
||||
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
|
||||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolDisplay",
|
||||
name = "Display",
|
||||
help = "Size, position and rotation of the picture, and a test pattern.",
|
||||
name = "Display",
|
||||
help = "Size, position and rotation of the picture, and a test pattern.",
|
||||
clear = true,
|
||||
|
||||
begin = function()
|
||||
dispSaved = "not yet"
|
||||
displayShow()
|
||||
set("dispSaved", "")
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
|
|
@ -511,13 +595,14 @@ TOOLS[#TOOLS + 1] = {
|
|||
vldpSetRotate(0)
|
||||
elseif what == SWITCH_BUTTON1 then
|
||||
singeSaveGeometry()
|
||||
set("dispSaved", "kept for this machine")
|
||||
dispSaved = "kept for this machine"
|
||||
displayShow()
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
dispSaved = "not yet"
|
||||
displayShow()
|
||||
set("dispSaved", "")
|
||||
return true
|
||||
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
|
||||
-- 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
|
||||
-- rather than been slow.
|
||||
local DISC_SEEKS = 12
|
||||
|
|
@ -572,8 +658,21 @@ local discPending = nil
|
|||
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] = {
|
||||
id = "toolDisc",
|
||||
name = "Disc Test",
|
||||
help = "Seek timing: how quickly the disc finds a frame.",
|
||||
|
||||
|
|
@ -581,7 +680,7 @@ TOOLS[#TOOLS + 1] = {
|
|||
discResults = {}
|
||||
discPending = nil
|
||||
discWorst = 0
|
||||
set("discRows", "<div class='row'>Button 1 runs the test.</div>")
|
||||
discShow("Button 1 runs the test.")
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
|
|
@ -589,6 +688,7 @@ TOOLS[#TOOLS + 1] = {
|
|||
discResults = {}
|
||||
discWorst = 0
|
||||
discPending = { step = 0, started = 0 }
|
||||
discShow("Running.")
|
||||
return true
|
||||
end
|
||||
return false
|
||||
|
|
@ -610,14 +710,16 @@ TOOLS[#TOOLS + 1] = {
|
|||
end
|
||||
if discGetFrame() == discPending.target or singeGetTicks() - discPending.started > DISC_SEEK_LIMIT then
|
||||
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)
|
||||
discPending.started = 0
|
||||
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
|
||||
discShow(nil)
|
||||
else
|
||||
discShow("Running.")
|
||||
end
|
||||
set("discRows", table.concat(discResults))
|
||||
end
|
||||
end,
|
||||
|
||||
|
|
@ -638,26 +740,25 @@ local saveSelected = 1
|
|||
|
||||
local function savedShow()
|
||||
local rows = {}
|
||||
|
||||
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
|
||||
for i, entry in ipairs(saveDirs) do
|
||||
local class = (i == saveSelected) and " class='selected'" or ""
|
||||
rows[#rows + 1] = string.format("<div%s>%s <span class='sub'>%d bytes</span></div>", class, entry.name, entry.size)
|
||||
rows[#rows + 1] = toolsItem(string.format("%s %d bytes", entry.name, entry.size), i == saveSelected)
|
||||
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
|
||||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolSaved",
|
||||
name = "Saved Data",
|
||||
help = "What each game has kept, and clearing it.",
|
||||
|
||||
begin = function()
|
||||
saveDirs = {}
|
||||
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
|
||||
-- without it, so both are kept. pcall because the root need not exist yet.
|
||||
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
|
||||
-- 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
|
||||
-- the channel a synthesiser is most likely to have something assigned to.
|
||||
local MIDI_TEST_CHANNEL = 1
|
||||
|
|
@ -720,17 +822,19 @@ local midiDirty = false
|
|||
|
||||
local function midiShow()
|
||||
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
|
||||
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
|
||||
for i = 0, midiInputCount() - 1 do
|
||||
rows[#rows + 1] = string.format("<div class='sub'>in %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
|
||||
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
|
||||
set("midiRows", table.concat(rows))
|
||||
rows[#rows + 1] = toolsKeys("Button 1: send a note Button 3: look again Button 2: back")
|
||||
toolsShow(rows)
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -744,7 +848,6 @@ end
|
|||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
id = "toolMidi",
|
||||
name = "MIDI Ports",
|
||||
help = "List the ports, send a note, watch what arrives.",
|
||||
|
||||
|
|
@ -790,19 +893,25 @@ TOOLS[#TOOLS + 1] = {
|
|||
|
||||
-- ===== 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] = {
|
||||
id = "toolStats",
|
||||
name = "Frame Statistics",
|
||||
help = "The developer's overlay: frame time, what is alive, what the disc is doing.",
|
||||
|
||||
begin = function()
|
||||
set("statsState", statsIsEnabled() and "on" or "off")
|
||||
end,
|
||||
begin = statsShow,
|
||||
|
||||
input = function(what)
|
||||
if what == SWITCH_BUTTON1 then
|
||||
statsEnable(not statsIsEnabled())
|
||||
set("statsState", statsIsEnabled() and "on" or "off")
|
||||
statsShow()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -215,11 +215,21 @@ body {
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* The chosen line of a list the tool draws, in the game list's gold. */
|
||||
.tool .selected {
|
||||
/* A line a tool offers to choose between, and the chosen one in the game list's gold -- the same
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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
|
||||
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
|
||||
|
|
@ -238,6 +248,10 @@ body {
|
|||
}
|
||||
|
||||
.tool.clear h1,
|
||||
.tool.clear .row,
|
||||
.tool.clear .pair,
|
||||
.tool.clear .sub,
|
||||
.tool.clear .item,
|
||||
.tool.clear p {
|
||||
background-color: #000000c0;
|
||||
padding: 1dp 8dp;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
`guiNew` fails and the menu cannot start. `Singe/MenuClassic.singe` is the
|
||||
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`)
|
||||
to name it in place of `Singe/Menu.singe`. It has the audio delay screen and
|
||||
nothing else; the rest of the service tools need the document.
|
||||
[#menurenderers]
|
||||
==== The two renderers
|
||||
|
||||
A GUI document is drawn through the GPU device the 3D scene uses, so on a
|
||||
machine without one there is no document to build. The menu handles that
|
||||
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]
|
||||
==== 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.
|
||||
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
|
||||
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
|
||||
running, and nothing changes a game's own files.
|
||||
lives in `Singe/Tools.singe`, which the engine extracts beside `Menu.singe`.
|
||||
Nothing there draws: a tool builds a list of rows and hands it to the menu,
|
||||
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::
|
||||
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
|
||||
Framework.singe Loaded by every game (dofile it)
|
||||
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)
|
||||
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
|
||||
controls.cfg.example Template for input mappings
|
||||
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
|
||||
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
|
||||
and Requirements>>); `guiNew` raises an error there. The bundled menu is such
|
||||
a document, which is why `Singe/MenuClassic.singe` exists (see
|
||||
<<bundledmenu,The Bundled Menu>>). Every call named here has an entry in the
|
||||
and Requirements>>); `guiNew` raises an error rather than answering false, so
|
||||
ask <<singehasgpu,singeHasGpu>> first if your game can do without. The bundled
|
||||
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.
|
||||
|
||||
The language reference for RML and RCSS is RmlUi's own documentation at
|
||||
|
|
@ -14227,6 +14255,40 @@ else
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@
|
|||
#include "generated/settings_cfg.h"
|
||||
#include "generated/Menu_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_rcss.h"
|
||||
#include "generated/gui_rcss.h"
|
||||
|
|
|
|||
|
|
@ -1791,7 +1791,8 @@ static void _unpackData(const char *exePath, bool absolute) {
|
|||
{ "settings.cfg.example", settings_cfg, settings_cfg_len },
|
||||
{ "Menu.singe", Menu_singe, Menu_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.rcss", menu_rcss, menu_rcss_len },
|
||||
{ "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len },
|
||||
|
|
|
|||
16
src/singe.c
16
src/singe.c
|
|
@ -1275,6 +1275,7 @@ static int32_t apiSingeGetPauseFlag(lua_State *L);
|
|||
static int32_t apiSingeGetScriptPath(lua_State *L);
|
||||
static int32_t apiSingeGetTicks(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 apiSingeReload(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, "singeGetTicks", apiSingeGetTicks); // 3.00
|
||||
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, "singeReload", apiSingeReload); // 3.00
|
||||
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
||||
|
|
@ -12724,6 +12726,20 @@ static int32_t apiSingeGetWidth(lua_State *L) {
|
|||
|
||||
|
||||
// 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) {
|
||||
_luaTrace(L, "singeQuit", "Quit requested.");
|
||||
_global.running = false;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue