--[[ * * 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 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 a page, and the line above the details -- says where in the library the selection is. An entry with more to say than fits walks down by -- itself a line at a time, rests, and walks back up again: up and down belong to the library, and -- a cabinet has no key to spare for scrolling a paragraph. Page up and page down override it for -- whoever does have a keyboard. local MARGIN_X = 25 local MARGIN_Y = 25 local MARGIN = 10 -- Around the page, and between the regions on it. local GAP = 8 local HINT_H = 24 -- The line of key hints along the bottom. local SIDE_FRAC = 0.40 -- How much of the width the marquee and the attract video take. local MARQUEE_ASPECT_W = 320 -- What the artwork and the clips are authored at; both regions keep local MARQUEE_ASPECT_H = 75 -- these proportions whatever the overlay's size turns out to be. local VIDEO_ASPECT_W = 320 local VIDEO_ASPECT_H = 200 local LINE_HEIGHT = 24 local LABEL_W = 150 local SUB_INDENT = 20 local PAGE_ROWS = 10 local WRAP_BREAK = " [!wb!] " local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools" local font = nil local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line -- of them ran off the end of a 720 pixel overlay. local introDone = false local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0 local textX, textY, textW, textH = 0, 0, 0, 0 local hintsX, hintsY = 0, 0 local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0 local spriteCabinet = nil local spriteMarquee = nil local textSprites = {} local textShadows = {} local textFirst = 0 -- The first line of the details on show, which walks by itself. local textDown = true -- and which way it is walking. local textClock = 0 local SHADOW = 2 -- How far the shadow sits down and across from the text. local SCROLL_HOLD = 3.0 -- Seconds the details rest at each end before moving. local SCROLL_STEP = 1.1 -- and seconds a line while they are moving. 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 -- Shrinks a sprite to fit inside a region, keeping its proportions. spriteDraw blits at the -- sprite's own size and a games.dat's artwork is whatever size the person who drew it chose, so -- without this a big cabinet picture is drawn straight over whatever the page put beside it. Only -- ever smaller: blowing a small picture up to fill the space makes it soft for nothing. local function fitSprite(handle, boxW, boxH) local width = spriteGetWidth(handle) local height = spriteGetHeight(handle) if (width > 0) and (height > 0) then local scale = math.min(boxW / width, boxH / height) if scale < 1.0 then spriteScale(handle, scale) end end 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) if text == nil or text == "" then -- Still a line: a tool that says nothing this frame should not have its page jump about. toolLine({}) return end 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 -- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the -- service is big and would otherwise run off the bottom of the screen. local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) toolLines = {} if more then toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) end local seenItem = false for _, row in ipairs(rows) do -- The "more below" marker belongs immediately under the last item, not at the very bottom -- of the page under the key hints, which are not part of the list. if below and seenItem and row.kind ~= "item" then toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) below = false end if row.kind == "item" then seenItem = true end -- 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 -- A list that runs to the very end of the page has no row after it to hang the marker on. if below then toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) end end MENU_RENDER = {} MENU_RENDER.begin = function() font = fontLoad("Singe/FreeSansBold.ttf", 18) hintFont = fontLoad("Singe/FreeSansBold.ttf", 13) fontQuality(FONT_QUALITY_BLENDED) fontSelect(font) end MENU_RENDER.finish = function() if font then fontUnload(font) font = nil end if hintFont then fontUnload(hintFont) hintFont = nil end end -- The backdrop is the disc: the engine intro, then the grid section played over and over. A -- machine with no GPU device has nothing to draw a grid with, so this renderer plays the one in -- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead. MENU_RENDER.backdropBegin = function(showIntro) introDone = not showIntro discPlay() if not showIntro then discSkipToFrame(DISC_GRID_START) end end MENU_RENDER.backdrop = function() -- Loop the grid. A machine too slow to show every disc frame can step over the last one, after -- which the engine starts the disc again from the top; once the intro has played, that is a loop. if discGetFrame() >= DISC_LAST_FRAME or (introDone and discGetFrame() < DISC_GRID_START) then discSkipToFrame(DISC_GRID_START) end if discGetFrame() >= DISC_GRID_START then introDone = true end -- Transparent, so the disc behind the overlay is what shows. colorBackground(0, 0, 0, 0) overlayClear() end MENU_RENDER.backdropReady = function() -- The menu comes up where the drawn backdrop's logo leaves, which is before the picture starts -- repeating: the frames between the two are the grid running on while the music fades out. return discGetFrame() >= DISC_MENU_FRAME end -- The cabinet fills the left; the marquee, the attract video and the details stack down the right. -- The page: the cabinet on the left, the marquee and the attract video down the right, the game's -- details across the bottom and the key hints under them. The details used to have the video's -- column and whatever height was left over, which at 720x480 was four lines -- the description and -- about one field -- so most of what a games.dat says never reached the screen at all. They get -- the full width now, and the fields are grouped the way the document renderer groups them, so the -- whole entry fits. Everything is worked out from the overlay's size rather than written down, -- because a game may set any size it likes. MENU_RENDER.layout = function() local width = overlayGetWidth() local height = overlayGetHeight() local sideW = math.floor(width * SIDE_FRAC) local sideX = width - MARGIN - sideW marqueeW = sideW marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W) marqueeX = sideX marqueeY = MARGIN videoW = sideW videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W) videoX = sideX videoY = marqueeY + marqueeH + GAP hintsX = MARGIN hintsY = height - MARGIN - HINT_H textX = MARGIN textY = videoY + videoH + GAP textW = width - MARGIN - textX textH = hintsY - GAP - textY cabinetX = MARGIN cabinetY = MARGIN cabinetW = sideX - GAP - cabinetX cabinetH = textY - GAP - 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(menuArtPath(game.CABINET)) spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE)) fitSprite(spriteCabinet, cabinetW, cabinetH) fitSprite(spriteMarquee, marqueeW, marqueeH) -- The details, grouped the way the document renderer groups them: the short fields share a -- line, and the description comes last because it is the one that can run on. It used to come -- first, which meant a long description pushed every fact about the game off the bottom. local textBox = "Year: " .. field(game.YEAR) .. " Genre: " .. field(game.GENRE) .. " Platform: " .. field(game.PLATFORM) .. WRAP_BREAK .. "Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK .. "Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK .. "Singe port: " .. field(game.CREATOR) .. " Source: " .. field(game.SOURCE) .. WRAP_BREAK .. WRAP_BREAK .. field(game.DESCRIPTION) -- fontToSprite bakes the colour in, so it is set before the sprites are made rather than -- before they are drawn. -- Twice over: black first, drawn a couple of pixels down and across behind the white, because -- this page sits on a moving grid and white on magenta is hard to read. The document renderer -- puts a translucent panel behind its text; nothing here can fill a rectangle, and a shadow -- costs one more blit a line rather than one more surface a frame. local lines = fitLines(textBox, textW) colorForeground(0, 0, 0, 255) for _, line in ipairs(lines) do textShadows[#textShadows + 1] = (string.len(line) > 0) and fontToSprite(line) or -1 end colorForeground(255, 255, 255, 255) for _, line in ipairs(lines) do textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1 end -- How tall a line is, and how many of them fit under the title. Measured here rather than -- part way through drawing, because the scroll has to know before the first line is drawn. textHeight = 0 for _, handle in ipairs(textSprites) do if (textHeight == 0) and (handle >= 0) then textHeight = spriteGetHeight(handle) + 1 end end textLimit = (textHeight > 0) and math.max(math.floor((textH - LINE_HEIGHT) / textHeight), 1) or 1 textFirst = 0 textDown = true textClock = 0 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 for _, handle in ipairs(textShadows) do if handle >= 0 then spriteUnload(handle) end end textSprites = {} textShadows = {} 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 room = #textSprites - textLimit if room > 0 then textFirst = math.max(math.min(textFirst + direction * math.max(textLimit - 1, 1), room), 0) textDown = (direction > 0) textClock = singeGetTicks() end end MENU_RENDER.pageSize = function() return PAGE_ROWS end MENU_RENDER.frame = function() local x = 0 local y = 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 + (marqueeH - spriteGetHeight(spriteMarquee)) * 0.5 spriteDraw(spriteMarquee, x, y) -- Attract mode video if VIDEO_ATTRACT then videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) end -- Which game this is, since there is no list to look at. It heads the details rather than -- sitting over the cabinet artwork, where it was unreadable against whatever the picture was. local title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT) colorForeground(0, 0, 0, 255) fontPrint(textX + SHADOW, textY + SHADOW, title) colorForeground(255, 211, 90, 255) fontPrint(textX, textY, title) -- What the game is. More of it than fits walks a line at a time, down to the end and back up -- to the start, so everything an entry says is seen without a key to scroll it -- and without -- the jump back to the top that reading the last line used to end in. local room = #textSprites - textLimit -- The clock starts when the details are first drawn, not when they were built: the menu is -- behind the intro for the first seven seconds, and a page that had already scrolled itself by -- the time anybody saw it would appear to start half way down. if textClock == 0 then textClock = singeGetTicks() end if room > 0 then local resting = (textFirst <= 0) or (textFirst >= room) if (singeGetTicks() - textClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then if textDown and (textFirst >= room) then textDown = false elseif not textDown and (textFirst <= 0) then textDown = true end textFirst = math.max(math.min(textFirst + (textDown and 1 or -1), room), 0) textClock = singeGetTicks() end end y = textY + LINE_HEIGHT for index = textFirst + 1, math.min(textFirst + textLimit, #textSprites) do if textSprites[index] >= 0 then spriteDraw(textShadows[index], textX + SHADOW, y + SHADOW) spriteDraw(textSprites[index], textX, y) end y = y + textHeight end -- Which key does what. The document renderer says the same thing along its footer; here -- there was nothing at all, so the only way to learn that up and down move one game and left -- and right move a page was to be told. fontSelect(hintFont) colorForeground(0, 0, 0, 255) fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS) colorForeground(178, 196, 224, 255) fontPrint(hintsX, hintsY, HINTS) fontSelect(font) 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 refuses an empty string, and a tool legitimately produces one: a status -- line with nothing to say yet is still a line, and it still takes up its height. if piece.text ~= "" then fontPrint(piece.x, y, piece.text) end end y = y + LINE_HEIGHT end end