singe/assets/MenuDocument.singe
2026-09-12 22:36:00 -05:00

577 lines
19 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The 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
-- backdropBegin(showIntro) start the background
-- backdrop() advance it, and clear the overlay ready to be drawn into
-- backdropReady() the intro is over and the menu may take input
-- 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
-- The backdrop: a neon grid running to a horizon under a sliced sun, drawn by the 3D scene. The
-- numbers are world units; the fog colour is also the background, so the grid fades into it rather
-- than stopping at an edge.
local GRID_CELL = 6.0 -- Spacing between grid lines.
local GRID_LINE_W = 0.12 -- Width of a line running away from the camera, at the near end.
local GRID_CROSS_W = 0.18 -- Width of a cross line, at the near end.
local GRID_SPREAD = 16.0 -- Distance over which a line doubles in width. See backdropQuad().
local GRID_HALF_X = 12 -- Lines each side of the centre.
local GRID_DEPTH = 14 -- Cross lines ahead of the camera, past where the fog ends.
local GRID_SPEED = 9.0 -- Units a second the grid moves toward the camera.
local FOG_R = 8
local FOG_G = 3
local FOG_B = 20
local FOG_NEAR = 12
local FOG_FAR = 72
local SUN_R = 10.5
local SUN_Y = 7.6
local SUN_Z = -34.0
local SUN_SLICES = 6 -- Horizontal bands cut out of the sun, widest at the bottom.
local SUN_SEGMENTS = 72 -- Segments around the sun's arc.
local HORIZ_W = 70.0 -- Half the width of the horizon bar; it has to reach both edges.
local HORIZ_H = 0.5 -- Its height. Bloom makes it read thicker than this.
local HORIZ_Z = -38.0 -- Just beyond the sun, so the sun stands in front of it.
local HORIZ_D = 2.2 -- Degrees below eye level the horizon is drawn at. The true one is
-- at eye level, infinitely far away, and the grid fades into the fog
-- well before it; dropping the line to where the grid actually ends
-- closes the gap that would otherwise sit under the bar.
local CAM_Y = 2.7 -- Eye height.
local CAM_Z = 6.0
local BLOOM_LEVEL = 0.25
local BLOOM_AMOUNT = 0.9
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false
local backdropRoot = nil
local backdropGrid = nil
local introPlaying = false
-- A vertex on the ground plane, appended to the positions of a mesh being built.
local function backdropVertex(p, x, z)
p[#p + 1] = x
p[#p + 1] = 0
p[#p + 1] = z
end
-- One flat quad in the XZ plane, four corners in order, appended to a mesh being built.
local function backdropQuad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
backdropVertex(p, x0, z0)
backdropVertex(p, x1, z1)
backdropVertex(p, x2, z2)
backdropVertex(p, x3, z3)
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
i[#i + 1] = base + n
end
end
-- How much wider than its near end a line is at z, so that it holds its width on screen. A line of
-- constant width in the world thins as it recedes, and once it covers less than a pixel it breaks
-- into a dotted mess that crawls as the grid moves; widening it with distance is what stops that.
local function backdropSpread(z)
return 1 + (-z) / GRID_SPREAD
end
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row
-- of the screen even though they are at different depths.
local function backdropHorizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
-- Half the width of the sun at height y above its middle.
local function backdropChord(y)
return math.sqrt(math.max(SUN_R * SUN_R - y * y, 0))
end
-- The grid: lines running away from the camera, and cross lines one cell beyond each end so the
-- whole thing can be slid by one cell and wrapped without a seam.
local function backdropGridMesh()
local p = {}
local i = {}
local far = -GRID_DEPTH * GRID_CELL
local wide = GRID_HALF_X * GRID_CELL
local hN = GRID_LINE_W / 2
local hF = hN * backdropSpread(far)
for n = -GRID_HALF_X, GRID_HALF_X do
local x = n * GRID_CELL
backdropQuad(p, i, x - hN, GRID_CELL, x + hN, GRID_CELL, x + hF, far, x - hF, far)
end
for n = -1, GRID_DEPTH do
local z = -n * GRID_CELL
local h = GRID_CROSS_W / 2 * backdropSpread(z)
backdropQuad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h)
end
return meshNew(p, nil, nil, i)
end
-- The sun: the part of a disc above yClip, as a fan from the middle of the chord. Cutting the
-- geometry is what puts the sun behind the horizon; letting a ground plane hide its lower half
-- instead only works while the ground reaches far enough, and the grid's gaps show through it.
local function backdropSunMesh(yClip)
local p = { 0, yClip, 0 }
local i = {}
local a0 = math.asin(math.max(math.min(yClip / SUN_R, 1), -1))
for n = 0, SUN_SEGMENTS do
local a = a0 + (n / SUN_SEGMENTS) * (math.pi - a0 * 2)
p[#p + 1] = math.cos(a) * SUN_R
p[#p + 1] = math.sin(a) * SUN_R
p[#p + 1] = 0
end
for n = 1, SUN_SEGMENTS do
i[#i + 1] = 1
i[#i + 1] = n + 1
i[#i + 1] = n + 2
end
return meshNew(p, nil, nil, i)
end
-- Builds the backdrop. Everything but the camera hangs off one node, so the whole thing can be
-- hidden with a single call while the disc plays the intro in front of it.
local function backdropBuild()
local sunCut = backdropHorizonY(SUN_Z) - SUN_Y
local neon
local sunMat
local sliceMat
local horizMat
local camera
local sun
local horizon
sceneEnable(true)
sceneSetAmbient(0, 0, 0)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
sceneSetBloom(BLOOM_LEVEL, BLOOM_AMOUNT)
sceneSetTonemap(TONEMAP_NEUTRAL)
-- Transparent until the intro is over, so the disc is what shows through the 3D layer.
sceneSetBackground(FOG_R, FOG_G, FOG_B, 0)
neon = materialNew()
materialSetColor(neon, 0, 0, 0)
materialSetEmissive(neon, 255, 45, 190)
materialSetDoubleSided(neon, true)
sunMat = materialNew()
materialSetColor(sunMat, 0, 0, 0)
materialSetEmissive(sunMat, 255, 130, 55)
materialSetDoubleSided(sunMat, true)
-- The slices are holes cut in the sun, so they are painted in the colour behind it.
sliceMat = materialNew()
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(sliceMat, true)
materialSetDoubleSided(sliceMat, true)
-- Hotter than the grid's own magenta because the fog it sits in halves it: at the grid's
-- colour the bar sinks into the far rows instead of capping them.
horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetEmissive(horizMat, 255, 150, 235)
materialSetDoubleSided(horizMat, true)
backdropRoot = nodeNew()
nodeSetVisible(backdropRoot, false)
backdropGrid = nodeNew()
nodeSetMesh(backdropGrid, backdropGridMesh(), neon)
nodeSetParent(backdropGrid, backdropRoot)
horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, backdropHorizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdropRoot)
sun = nodeNew()
nodeSetMesh(sun, backdropSunMesh(sunCut), sunMat)
nodeSetPosition(sun, 0, SUN_Y, SUN_Z)
nodeSetParent(sun, backdropRoot)
-- Each slice is cut to the chord of the disc at its own height: one wider than the sun shows
-- its corners, and being opaque it also punches a rectangular hole in the horizon bar behind.
for n = 1, SUN_SLICES do
local slice = nodeNew()
local height = 0.62 - (n - 1) * 0.07
local y = sunCut + 0.5 + (n - 1) * 1.3
nodeSetMesh(slice, meshPlane(backdropChord(math.abs(y) + height / 2) * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
nodeSetParent(slice, backdropRoot)
end
camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
nodeSetRotation(camera, -2.0, 0, 0)
cameraSet(camera)
end
-- The intro is over: stop the disc and let the grid through.
local function backdropShow()
introPlaying = false
discPause()
sceneSetBackground(FOG_R, FOG_G, FOG_B, 255)
nodeSetVisible(backdropRoot, true)
end
function menuElement(id)
return rmlui.contexts["gui" .. GUI].documents["menu"]:GetElementById(id)
end
-- Text on its way into the document, with RML's special characters made harmless.
function menuEscape(text)
local output = tostring(text or "")
output = string.gsub(output, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
end
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
-- own file is reached from there through "../"; the engine's files are named as they are.
local function menuArt(id, file)
local path = tostring(file or "")
if path:sub(1, 6):lower() ~= "singe/" then
path = "../" .. path
end
menuElement(id).style.decorator = 'image("' .. path .. '" contain)'
end
-- One "Label: value" fragment of a details line.
local function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
end
local function menuPlace(id, x, y, w, h)
local element = menuElement(id)
element:SetClass("placed", true)
element.style.left = x .. "px"
element.style.top = y .. "px"
element.style.width = w .. "px"
element.style.height = h .. "px"
end
local function menuRow(index)
return menuElement("game" .. index)
end
-- A click on a row selects its game; a click on the selected row starts it.
local function menuRowClick(gui, document, id, event, value)
local index = tonumber(id:sub(5))
if not menuActive() then
return
end
if index == GAME_SELECTED then
gameStart()
else
gameSelect(index)
end
end
local function menuStartClick(gui, document, id, event, value)
if menuActive() then
gameStart()
end
end
-- Brings the selected row into view once the document has been laid out.
local function menuScrollToSelection()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
local top = row.offset_top - list.scroll_top
if list.client_height <= 0 then
return
end
if top < 0 then
row:ScrollIntoView(true)
elseif top + row.offset_height > list.client_height then
row:ScrollIntoView(false)
end
scrollPending = false
end
-- A tool lines its columns up with runs of spaces, which RML would collapse to one, so they are
-- made non-breaking on the way in. Only the tools do this: a game's description is meant to wrap.
local function menuSpaces(text)
return (string.gsub(menuEscape(text), " +", function(run) return string.rep("&nbsp;", #run) end))
end
-- One row of a service tool, as RML. The kinds are the ones Singe/Tools.singe builds.
local function toolRow(row)
if row.kind == "pair" then
return string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>",
menuEscape(row.label), menuSpaces(row.value))
elseif row.kind == "sub" then
return "<div class='sub'>" .. menuSpaces(row.text) .. "</div>"
elseif row.kind == "keys" then
return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>"
elseif row.kind == "item" then
return string.format("<div class='%s'>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text))
elseif row.kind == "blank" then
return "<div class='blank'></div>"
end
return "<div class='row'>" .. menuSpaces(row.text) .. "</div>"
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
-- The document: the size of the overlay, composited over it every frame, and first in line
-- for the mouse and the switches so its rows and Start button can be clicked.
GUI = guiNew(overlayGetWidth(), overlayGetHeight())
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
guiSetInput(GUI, true)
end
MENU_RENDER.finish = function()
if GUI then
guiDelete(GUI)
GUI = nil
end
sceneEnable(false)
end
-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of
-- Singe/menuBackground.mkv is never reached: this renderer has the GPU device the document needs,
-- which is the same one the scene draws with, so the grid is drawn rather than played.
MENU_RENDER.backdropBegin = function(showIntro)
backdropBuild()
introPlaying = showIntro
if showIntro then
discPlay()
else
backdropShow()
end
end
MENU_RENDER.backdrop = function()
if introPlaying and discGetFrame() >= DISC_GRID_START then
backdropShow()
end
if not introPlaying then
nodeSetPosition(backdropGrid, 0, 0, (singeGetTicks() / 1000.0 * GRID_SPEED) % GRID_CELL)
end
-- Transparent, so the scene behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
end
MENU_RENDER.backdropReady = function()
return not introPlaying
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
-- The same window the overlay renderer draws, so the two show the same page rather than one
-- of them quietly showing more. The style sheet can scroll this box, but nothing drives a
-- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen.
local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
if more then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
local seenItem = false
for _, row in ipairs(visible) do
-- The marker belongs under the last item rather than under the key hints below it.
if below and seenItem and row.kind ~= "item" then
rows[#rows + 1] = "<div class='sub'>...</div>"
below = false
end
if row.kind == "item" then
seenItem = true
end
rows[#rows + 1] = toolRow(row)
end
if below then
rows[#rows + 1] = "<div class='sub'>...</div>"
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