--[[ * * 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. dofile("Singe/Backdrop.singe") -- Layout constants follow. 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 local detailScroll = 0 -- How far down the description has walked, in pixels, local detailDown = true -- and which way it is walking. local detailClock = 0 local SCROLL_HOLD = 3.0 -- Seconds it rests at each end before moving, as in the overlay local SCROLL_STEP = 1.1 -- renderer, and seconds a step while it is moving. local SCROLL_LINE = 16 -- How far a step goes: a line of the description. 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. -- -- Art that is not there shows as art that is not there, which menuArtPath decides -- a games.dat -- naming a file that was never packed, or a path with a typo in it, is otherwise a silent hole in -- the page, and it used to be worse than silent: an empty path took the whole engine down through -- the texture loader. MENU_MISSING_ART is an engine file, so the "singe/" test below leaves it -- alone. local function menuArt(id, file) local path = menuArtPath(file) 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 -- 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 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 backdropFinish() sceneEnable(false) end -- The backdrop is its own file, because util/renderMenuVideo.py draws the same thing to record it. MENU_RENDER.backdropBegin = function(showIntro) backdropBegin(showIntro) end MENU_RENDER.backdrop = function() -- Transparent, so the scene behind the overlay is what shows. colorBackground(0, 0, 0, 0) overlayClear() backdropFrame() end MENU_RENDER.backdropReady = function() -- The menu takes the screen once the logo has left, not when a video frame number passes. return backdropReady() end 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 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 detailScroll = 0 detailDown = true detailClock = 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 -- The description walks by itself when there is more of it than fits -- down to the end, a rest, -- and back up to the start -- the same way the overlay renderer's details do. The style sheet -- gives this box a scroll bar, but nothing drives one from a joystick and up and down belong to the -- game list, so a long description was simply unreadable past its first few lines. local function menuScrollDetails() local element = menuElement("description") local room = element.scroll_height - element.client_height if room <= 0 then return end -- The clock starts when there is something to scroll and the menu is on screen, not when the -- game was selected: the menu is behind the intro for the first seven seconds. if detailClock == 0 then detailClock = singeGetTicks() end local resting = (detailScroll <= 0) or (detailScroll >= room) if (singeGetTicks() - detailClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then if detailDown and (detailScroll >= room) then detailDown = false elseif not detailDown and (detailScroll <= 0) then detailDown = true end detailScroll = math.max(math.min(detailScroll + (detailDown and SCROLL_LINE or -SCROLL_LINE), room), 0) detailClock = singeGetTicks() element.scroll_top = detailScroll end end -- Page up and page down, by as much as is on show less a line to read across. The walk resumes -- from where this leaves it, in the direction it was sent, after its usual rest. MENU_RENDER.scrollDetails = function(direction) local element = menuElement("description") local room = element.scroll_height - element.client_height if room > 0 then local page = math.max(element.client_height - SCROLL_LINE, SCROLL_LINE) detailScroll = math.max(math.min(detailScroll + direction * page, room), 0) detailDown = (direction > 0) detailClock = singeGetTicks() element.scroll_top = detailScroll end end MENU_RENDER.frame = function() -- Attract Mode Video, under the hole the document leaves for it if VIDEO_ATTRACT then videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) end if scrollPending then menuScrollToSelection() end menuScrollDetails() 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