349 lines
10 KiB
Text
349 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 with sprites and fontPrint straight into the overlay: the look Singe 2 had, and
|
|
-- the one that works on a machine whose GPU device never came up, where there is no GUI document to
|
|
-- build. Singe/MenuDocument.singe is the other renderer; Singe/Menu.singe loads exactly one of the
|
|
-- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it.
|
|
--
|
|
-- One game is on screen at a time, as it always was here, so there is no list to page through
|
|
-- visually: up and down move one game, left and right move ten, and the line under the marquee
|
|
-- says where in the library the selection is. A description too long for its column is clipped
|
|
-- rather than scrolled, because up and down now belong to the list.
|
|
|
|
|
|
local MARGIN_X = 25
|
|
local MARGIN_Y = 25
|
|
local VIDEO_W = 320
|
|
local VIDEO_H = 200
|
|
local MARQUEE_H = 75
|
|
local LINE_HEIGHT = 24
|
|
local LABEL_W = 150
|
|
local SUB_INDENT = 20
|
|
local PAGE_ROWS = 10
|
|
local WRAP_BREAK = " [!wb!] "
|
|
|
|
local font = nil
|
|
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
|
|
local marqueeX, marqueeY, marqueeW = 0, 0, 0
|
|
local textX, textY, textW, textH = 0, 0, 0, 0
|
|
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
|
|
local spriteCabinet = nil
|
|
local spriteMarquee = nil
|
|
local textSprites = {}
|
|
local textHeight = 0
|
|
local textLimit = 0
|
|
|
|
|
|
-- A games.dat entry need not carry every field, and one that is missing must not take the menu
|
|
-- down; the document renderer is tolerant the same way, through menuEscape.
|
|
local function field(value)
|
|
return tostring(value or "")
|
|
end
|
|
|
|
|
|
-- Splits text into the lines that fit the width given, measuring with the selected font. The only
|
|
-- way to measure a string is to render it, so this is slow enough to be worth doing once when
|
|
-- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a
|
|
-- line ending.
|
|
--
|
|
-- The space between words is kept as it was written rather than squeezed to one, because a tool
|
|
-- lines its columns up with runs of spaces and there is no other way here to say "a wide gap".
|
|
local function fitLines(text, maxWidth)
|
|
local lines = {}
|
|
local line = ""
|
|
local pending = ""
|
|
local trimBreak = utilTrim(WRAP_BREAK)
|
|
|
|
for word, space in (text .. WRAP_BREAK):gmatch("(%S+)(%s*)") do
|
|
if word == trimBreak then
|
|
lines[#lines + 1] = line
|
|
line = ""
|
|
pending = ""
|
|
else
|
|
local candidate = line .. pending .. word
|
|
local measured = fontToSprite(candidate)
|
|
local tooWide = spriteGetWidth(measured) > maxWidth
|
|
spriteUnload(measured)
|
|
if tooWide and line ~= "" then
|
|
lines[#lines + 1] = line
|
|
line = word
|
|
else
|
|
line = candidate
|
|
end
|
|
pending = space
|
|
end
|
|
end
|
|
if line ~= "" then
|
|
lines[#lines + 1] = line
|
|
end
|
|
|
|
return lines
|
|
end
|
|
|
|
|
|
-- The service tools, laid out. Each entry is one line of the page and each line is a list of
|
|
-- pieces: where the piece starts, what it says and which colour it wants. The colours are roles
|
|
-- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint
|
|
-- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in.
|
|
local toolLines = {}
|
|
|
|
|
|
local function toolLine(pieces)
|
|
toolLines[#toolLines + 1] = pieces
|
|
end
|
|
|
|
|
|
-- Text too long for the page wraps under itself rather than running off the edge.
|
|
local function toolWrapped(text, x, role)
|
|
for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do
|
|
toolLine({ { x = x, text = line, role = role } })
|
|
end
|
|
end
|
|
|
|
|
|
local function toolsLayout()
|
|
local valueX = MARGIN_X + LABEL_W
|
|
local wasKeys = false
|
|
|
|
toolLines = {}
|
|
for _, row in ipairs(TOOL_ROWS) do
|
|
-- The key hints stand away from whatever the tool put above them, as they do in the
|
|
-- document, but sit together when a tool needs more than one line of them.
|
|
if row.kind == "keys" and not wasKeys then
|
|
toolLine({})
|
|
end
|
|
wasKeys = (row.kind == "keys")
|
|
|
|
if row.kind == "blank" then
|
|
toolLine({})
|
|
elseif row.kind == "pair" then
|
|
-- The label and the first line of the value share a line; the rest of the value wraps
|
|
-- under itself, in its own column.
|
|
local lines = fitLines(row.value, overlayGetWidth() - MARGIN_X - valueX)
|
|
toolLine({ { x = MARGIN_X, text = row.label, role = "quiet" },
|
|
{ x = valueX, text = lines[1] or "", role = "ink" } })
|
|
for index = 2, #lines do
|
|
toolLine({ { x = valueX, text = lines[index], role = "ink" } })
|
|
end
|
|
elseif row.kind == "sub" then
|
|
toolWrapped(row.text, MARGIN_X + SUB_INDENT, "quiet")
|
|
elseif row.kind == "keys" then
|
|
toolWrapped(row.text, MARGIN_X, "quiet")
|
|
elseif row.kind == "item" then
|
|
toolLine({ { x = MARGIN_X, text = (row.selected and "> " or " ") .. row.text,
|
|
role = row.selected and "pick" or "ink" } })
|
|
else
|
|
toolWrapped(row.text, MARGIN_X, "ink")
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
MENU_RENDER = {}
|
|
|
|
|
|
MENU_RENDER.begin = function()
|
|
font = fontLoad("Singe/FreeSansBold.ttf", 18)
|
|
fontQuality(FONT_QUALITY_BLENDED)
|
|
fontSelect(font)
|
|
end
|
|
|
|
|
|
MENU_RENDER.finish = function()
|
|
if font then
|
|
fontUnload(font)
|
|
font = nil
|
|
end
|
|
end
|
|
|
|
|
|
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
|
|
MENU_RENDER.layout = function()
|
|
local width = overlayGetWidth()
|
|
local height = overlayGetHeight()
|
|
|
|
marqueeW = VIDEO_W
|
|
marqueeX = width - MARGIN_X - marqueeW
|
|
marqueeY = MARGIN_Y
|
|
|
|
videoW = VIDEO_W
|
|
videoH = VIDEO_H
|
|
videoX = width - MARGIN_X - videoW
|
|
videoY = MARGIN_Y + MARQUEE_H + MARGIN_Y
|
|
|
|
textW = VIDEO_W
|
|
textX = width - MARGIN_X - textW
|
|
textY = videoY + videoH + MARGIN_Y
|
|
textH = height - MARGIN_Y - textY
|
|
|
|
cabinetX = MARGIN_X
|
|
cabinetY = MARGIN_Y
|
|
cabinetW = videoX - MARGIN_X - cabinetX
|
|
cabinetH = height - MARGIN_Y - cabinetY
|
|
end
|
|
|
|
|
|
MENU_RENDER.buildList = function()
|
|
-- One game is on screen at a time; there is no list to build.
|
|
end
|
|
|
|
|
|
MENU_RENDER.showGame = function(game)
|
|
spriteCabinet = spriteLoad(game.CABINET)
|
|
spriteMarquee = spriteLoad(game.MARQUEE)
|
|
|
|
-- The details follow the description in one wrapped block, the way this menu has always shown
|
|
-- them. The document menu gives them their own panel instead.
|
|
local textBox = field(game.DESCRIPTION) .. WRAP_BREAK .. WRAP_BREAK ..
|
|
"Year: " .. field(game.YEAR) .. WRAP_BREAK ..
|
|
"Genre: " .. field(game.GENRE) .. WRAP_BREAK ..
|
|
"Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
|
|
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
|
|
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK .. WRAP_BREAK ..
|
|
"Singe Port: " .. field(game.CREATOR) .. WRAP_BREAK ..
|
|
"Source: " .. field(game.SOURCE)
|
|
-- fontToSprite bakes the colour in, so it is set before the sprites are made rather than
|
|
-- before they are drawn.
|
|
colorForeground(255, 255, 255, 255)
|
|
for _, line in ipairs(fitLines(textBox, textW)) do
|
|
textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
|
|
end
|
|
end
|
|
|
|
|
|
MENU_RENDER.hideGame = function()
|
|
if spriteCabinet then
|
|
spriteUnload(spriteCabinet)
|
|
spriteCabinet = nil
|
|
end
|
|
if spriteMarquee then
|
|
spriteUnload(spriteMarquee)
|
|
spriteMarquee = nil
|
|
end
|
|
for _, handle in ipairs(textSprites) do
|
|
if handle >= 0 then
|
|
spriteUnload(handle)
|
|
end
|
|
end
|
|
textSprites = {}
|
|
end
|
|
|
|
|
|
MENU_RENDER.pageSize = function()
|
|
return PAGE_ROWS
|
|
end
|
|
|
|
|
|
MENU_RENDER.frame = function()
|
|
local x = 0
|
|
local y = 0
|
|
local shown = 0
|
|
|
|
-- Cabinet image
|
|
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
|
|
y = cabinetY + (cabinetH - spriteGetHeight(spriteCabinet)) * 0.5
|
|
spriteDraw(spriteCabinet, x, y)
|
|
|
|
-- Marquee image
|
|
x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5
|
|
y = marqueeY + (MARQUEE_H - spriteGetHeight(spriteMarquee)) * 0.5
|
|
spriteDraw(spriteMarquee, x, y)
|
|
|
|
-- Attract mode video
|
|
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
|
|
|
|
-- Which game this is, since there is no list to look at
|
|
colorForeground(255, 211, 90, 255)
|
|
fontPrint(cabinetX, cabinetY, string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT))
|
|
|
|
-- Game description, clipped to the lines that fit
|
|
y = textY
|
|
for _, handle in ipairs(textSprites) do
|
|
-- Find height of font and number of lines that fit
|
|
if textHeight == 0 and handle >= 0 then
|
|
textHeight = spriteGetHeight(handle)
|
|
textLimit = math.floor(textH / textHeight)
|
|
end
|
|
if shown < textLimit then
|
|
if handle >= 0 then
|
|
spriteDraw(handle, textX, y)
|
|
end
|
|
y = y + textHeight + 1
|
|
shown = shown + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
MENU_RENDER.tools = function()
|
|
local y = MARGIN_Y
|
|
|
|
-- A tool that draws its own picture into the overlay has already done so, and the page has to
|
|
-- let it through; every other tool gets a page painted over everything.
|
|
if not TOOL_CLEAR then
|
|
if TOOL_FLASH then
|
|
colorBackground(255, 255, 255, 255)
|
|
else
|
|
colorBackground(0, 0, 0, 255)
|
|
end
|
|
overlayClear()
|
|
end
|
|
|
|
-- Measuring text means rendering it, so the page is laid out only when a tool says it changed.
|
|
if TOOL_DIRTY then
|
|
TOOL_DIRTY = false
|
|
toolsLayout()
|
|
end
|
|
|
|
local ink = TOOL_FLASH and 0 or 255
|
|
local quiet = TOOL_FLASH and 90 or 160
|
|
|
|
-- A page that lets the overlay through gathers its text along the bottom, out of the way of
|
|
-- whatever the tool drew: the light gun's corner targets in particular.
|
|
if TOOL_CLEAR then
|
|
y = overlayGetHeight() - MARGIN_Y - (#toolLines + 1) * LINE_HEIGHT
|
|
end
|
|
colorForeground(ink, ink, ink, 255)
|
|
fontPrint(MARGIN_X, y, TOOL_TITLE)
|
|
y = y + LINE_HEIGHT
|
|
if not TOOL_CLEAR then
|
|
y = y + LINE_HEIGHT
|
|
end
|
|
|
|
for _, line in ipairs(toolLines) do
|
|
for _, piece in ipairs(line) do
|
|
if piece.role == "quiet" then
|
|
colorForeground(quiet, quiet, quiet, 255)
|
|
elseif piece.role == "pick" then
|
|
colorForeground(255, 211, 90, 255)
|
|
else
|
|
colorForeground(ink, ink, ink, 255)
|
|
end
|
|
fontPrint(piece.x, y, piece.text)
|
|
end
|
|
y = y + LINE_HEIGHT
|
|
end
|
|
end
|