577 lines
19 KiB
Text
577 lines
19 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
|
|
-- 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 '<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
|
|
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] = '<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
|