320 lines
10 KiB
Text
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, "&", "&")
|
|
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 '<span class="label">' .. label .. ':</span> ' .. 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("<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
|