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

320 lines
10 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
-- 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, "&", "&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
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