--[[ * * 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 -- 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 '' .. 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 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 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