--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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, "&", "&") 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 '' .. label .. ': ' .. 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("
%s%s
", menuEscape(row.label), menuSpaces(row.value)) elseif row.kind == "sub" then return "
" .. menuSpaces(row.text) .. "
" elseif row.kind == "keys" then return "

" .. menuSpaces(row.text) .. "

" elseif row.kind == "item" then return string.format("
%s
", row.selected and "item selected" or "item", menuSpaces(row.text)) elseif row.kind == "blank" then return "
" end return "
" .. menuSpaces(row.text) .. "
" 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] = '
' .. menuEscape(game.TITLE) .. '
' 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] = "
...
" 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] = "
...
" below = false end if row.kind == "item" then seenItem = true end rows[#rows + 1] = toolRow(row) end if below then rows[#rows + 1] = "
...
" 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