From 5128b840b1068532b87194dc6f7c6d9ce4ff4905 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 23 Jul 2026 09:44:09 -0400 Subject: [PATCH] launcher spruce p2 --- src/import/RomImporter.lua | 663 ++++++++++++++++++++++++++----------- 1 file changed, 469 insertions(+), 194 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 3afd2ab0..d639ad4f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -23,21 +23,39 @@ local REQUIRED_FILES = { "assets/generated/audio/programs.bin", } --- Split-screen ROM-selector palette (the "Split-screen ROM selector" design). --- One column per game: Red is live, Blue and Yellow are placeholders until --- those games are supported. Values are 0-255 RGB. +-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from +-- the Claude Design project): a dark neon arcade panel, one column per game. +-- Red is live; Blue and Yellow are lit placeholders until those games are +-- supported. Values are 0-255 RGB; alpha is applied per draw. local PAL = { - bg = { 241, 243, 232 }, - detail = { 74, 88, 72 }, - heading = { 25, 31, 28 }, - box = { 215, 220, 202 }, + -- radial background gradient (bright navy at top-centre -> near black) + bgTop = { 22, 34, 74 }, -- #16224a + bgBot = { 7, 11, 29 }, -- #070b1d + -- neon accents, one per cartridge + red = { 255, 60, 72 }, -- rgb(255,60,72) + blue = { 70, 150, 255 }, -- rgb(70,150,255) + gold = { 255, 203, 5 }, -- rgb(255,203,5) + -- card interiors (the dark colour the accent tint fades into) + cardRed = { 20, 12, 26 }, -- #140c1a + cardBlue = { 12, 18, 40 }, -- #0c1228 + cardGold = { 30, 22, 8 }, -- #1e1608 + -- text + heading = { 255, 255, 255 }, + detail = { 198, 208, 230 }, -- #c6d0e6 + warning = { 159, 176, 208 }, -- #9fb0d0 + link = { 127, 208, 255 }, -- #7fd0ff, the bois.icu link + linkHover = { 191, 234, 255 }, -- #bfeaff, brighter on hover white = { 255, 255, 255 }, - red = { 181, 35, 42 }, - blue = { 30, 86, 168 }, - gold = { 214, 164, 0 }, - goldInk = { 160, 120, 0 }, - disabled = { 146, 158, 178 }, - disabledInk = { 238, 242, 247 }, + -- "Play" button (green gradient) + its ink + playTop = { 62, 224, 138 }, -- #3ee08a + playBot = { 22, 163, 90 }, -- #16a35a + playInk = { 6, 32, 18 }, -- #062012 + -- "Choose ROM" button (red gradient) + chooseTop = { 255, 83, 97 }, -- #ff5361 + chooseBot = { 214, 31, 44 }, -- #d61f2c + -- disabled "Coming soon" button + disabled = { 120, 132, 158 }, + disabledInk = { 149, 161, 189 }, -- #95a1bd } local function allRequiredFilesExist() @@ -446,205 +464,264 @@ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end --- set the current draw color from a PAL triple (0-255), with optional alpha 0-1 +-- set the current draw colour from a PAL triple (0-255), with optional alpha 0-1 local function col(c, a) love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) end --- LOVE has no dashed-stroke primitive; step short segments along each edge so --- the "coming soon" Blue/Yellow boxes read as placeholders -local function dashedRect(x, y, w, h, dash, gap) - local step = dash + gap - local function edge(x1, y1, x2, y2) - local dx, dy = x2 - x1, y2 - y1 - local len = math.sqrt(dx * dx + dy * dy) - if len == 0 then return end - local ux, uy = dx / len, dy / len - local d = 0 - while d < len do - local e = math.min(d + dash, len) - love.graphics.line(x1 + ux * d, y1 + uy * d, x1 + ux * e, y1 + uy * e) - d = d + step - end +-- Faux-bold: the launcher's UI font ships no bold face, so 800-weight text +-- (headings, buttons) is thickened with a second sub-pixel pass. +local function printfB(text, x, y, w, align) + love.graphics.printf(text, x, y, w, align) + love.graphics.printf(text, x + 0.6, y, w, align) +end +local function printB(text, x, y) + love.graphics.print(text, x, y) + love.graphics.print(text, x + 0.6, y) +end + +-- One reusable unit quad, recoloured per call, for every vertical gradient +-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn +-- the GPU). Callers set the blend mode; this only touches colour + geometry. +local gradMesh +local function setGrad(cTop, cBot, aTop, aBot) + if not gradMesh then gradMesh = love.graphics.newMesh(4, "fan", "dynamic") end + gradMesh:setVertices({ + { 0, 0, 0, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop }, + { 1, 0, 1, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop }, + { 1, 1, 1, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot }, + { 0, 1, 0, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot }, + }) +end +local function fillGrad(x, y, w, h, cTop, cBot, aTop, aBot) + setGrad(cTop, cBot, aTop, aBot) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(gradMesh, x, y, 0, w, h) +end +-- vertical gradient clipped to a rounded rectangle (via the stencil buffer) +local function fillGradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot) + love.graphics.stencil(function() + love.graphics.rectangle("fill", x, y, w, h, r, r) + end, "replace", 1) + love.graphics.setStencilTest("greater", 0) + fillGrad(x, y, w, h, cTop, cBot, aTop, aBot) + love.graphics.setStencilTest() +end + +-- Soft additive neon halo around a rounded rect. LOVE has no blur, so stack +-- progressively larger, fainter translucent rounded rects. +local function neonGlow(x, y, w, h, r, c, strength) + strength = math.max(0, strength) + if strength == 0 then return end + love.graphics.setBlendMode("add") + local layers = 7 + for i = 1, layers do + local g = i * 2.4 + love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, + strength * 0.05 * (1 - (i - 1) / layers)) + love.graphics.rectangle("fill", x - g, y - g, w + 2 * g, h + 2 * g, r + g, r + g) end - edge(x, y, x + w, y) - edge(x + w, y, x + w, y + h) - edge(x + w, y + h, x, y + h) - edge(x, y + h, x, y) + love.graphics.setBlendMode("alpha") +end + +-- A white shine band that sweeps across an active button, clipped to its +-- rounded shape. phase is 0..1 (left of the button -> right of it). +local shineMesh +local function buttonShine(x, y, w, h, r, phase) + if not shineMesh then + -- triangle strip: three columns (transparent, white, transparent) + shineMesh = love.graphics.newMesh({ + { 0, 0, 0, 0, 1, 1, 1, 0 }, + { 0, 1, 0, 1, 1, 1, 1, 0 }, + { 0.5, 0, 0.5, 0, 1, 1, 1, 0.5 }, + { 0.5, 1, 0.5, 1, 1, 1, 1, 0.5 }, + { 1, 0, 1, 0, 1, 1, 1, 0 }, + { 1, 1, 1, 1, 1, 1, 1, 0 }, + }, "strip", "static") + end + local bandW = w * 0.6 + local bx = x - bandW + phase * (w + bandW) + love.graphics.stencil(function() + love.graphics.rectangle("fill", x, y, w, h, r, r) + end, "replace", 1) + love.graphics.setStencilTest("greater", 0) + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(shineMesh, bx, y, 0, bandW, h) + love.graphics.setBlendMode("alpha") + love.graphics.setStencilTest() end function RomImporter:draw() local width, height = love.graphics.getDimensions() local third = width / 3 - col(PAL.bg) - love.graphics.rectangle("fill", 0, 0, width, height) + local s = clamp(height / 768, 0.7, 1.6) + local pulse = self.pulse - -- top tri-colour stripe (Red | Blue | Yellow) - local stripeH = math.max(8, height * 0.02) - col(PAL.red); love.graphics.rectangle("fill", 0, 0, third, stripeH) - col(PAL.blue); love.graphics.rectangle("fill", third, 0, third, stripeH) - col(PAL.gold); love.graphics.rectangle("fill", 2 * third, 0, width - 2 * third, stripeH) + -- Hover state (desktop only -- touch has no cursor). anyHover and ptIn are + -- captured by drawCard and the footer below; the cursor is set at the end. + local mx, my = love.mouse.getPosition() + local hoverEnabled = not self.android + local anyHover = false + local function ptIn(r) + return r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height + end - -- fonts, rebuilt only when the window size changes + -- Fonts + size-dependent scenery, rebuilt only when the window size changes. local fontKey = ("%dx%d"):format(width, height) if self.fontKey ~= fontKey then self.fontKey = fontKey - local function f(frac, lo, hi) return love.graphics.newFont(clamp(height * frac, lo, hi)) end - self.pillFont = f(0.021, 12, 15) - self.headFont = f(0.030, 16, 22) - self.detailFont = f(0.023, 13, 16) - self.buttonFont = f(0.029, 16, 22) - self.hintFont = f(0.021, 12, 15) - self.warningFont = f(0.017, 10, 12) + local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end + self.headFont = f(19 * s) + self.detailFont = f(14 * s) + self.buttonFont = f(19 * s) + self.hintFont = f(13 * s) + self.warningFont = f(11 * s) + + -- Background: a radial gradient (bright navy at top-centre -> near black). + -- A triangle fan from the top-centre gives the radial falloff; the screen + -- is cleared to the outer colour first so the corners it does not reach + -- match seamlessly. + do + local cx, cy = width / 2, 0 + local rx, ry = width * 1.3, height * 1.08 + local n = 72 + local verts = { { cx, cy, 0, 0, + PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } + for i = 0, n do + local a = (i / n) * math.pi * 2 + verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, + PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 } + end + self.bgMesh = love.graphics.newMesh(verts, "fan", "static") + end + + -- CRT vignette: a gentle edge darkening, centred slightly above the middle. + do + local cx, cy = width / 2, height * 0.45 + local rx, ry = width * 0.78, height * 0.78 + local n = 72 + local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } } + for i = 0, n do + local a = (i / n) * math.pi * 2 + verts[#verts + 1] = + { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, 0, 0, 0, 0.32 } + end + self.vignetteMesh = love.graphics.newMesh(verts, "fan", "static") + end + + -- CRT scanlines: a 1px dark line every 3px, baked into a tiny tile and + -- drawn once with a repeat-wrapped quad (one draw call, correct alpha). + if not self.scanlineImage then + local id = love.image.newImageData(1, 3) + id:setPixel(0, 0, 0, 0, 0, 0.08) + id:setPixel(0, 1, 0, 0, 0, 0) + id:setPixel(0, 2, 0, 0, 0, 0) + self.scanlineImage = love.graphics.newImage(id) + self.scanlineImage:setWrap("repeat", "repeat") + self.scanlineImage:setFilter("nearest", "nearest") + end + self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3) end - -- footer (Boi's Club Games logo + trust warning), pinned to the bottom and - -- measured first so the columns know where they must stop + -- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it + -- is rendered white (the design's filter:invert(1)). Built lazily so a + -- headless require never needs a GL context. + self.invertShader = self.invertShader or love.graphics.newShader([[ + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a); + } + ]]) + + -- Shine shader: the same white sweep the active buttons get, but clipped to + -- the logo's own shape (a soft band brightens the pixels it crosses; fully + -- transparent pixels stay transparent). + self.shineShader = self.shineShader or love.graphics.newShader([[ + extern number shinePos; + extern number shineW; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos)); + return vec4(p.rgb + band * 0.55, p.a) * color; + } + ]]) + + -- background + col(PAL.bgBot) + love.graphics.rectangle("fill", 0, 0, width, height) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.bgMesh) + + -- neon tri-segment top bar (Red | Blue | Yellow) with a soft downward bloom + local barH = math.max(10, 16 * s) + local segs = { + { PAL.red, 0, third }, + { PAL.blue, third, third }, + { PAL.gold, 2 * third, width - 2 * third }, + } + love.graphics.setBlendMode("add") + for _, seg in ipairs(segs) do + fillGrad(seg[2], 0, seg[3], barH * 3.2, seg[1], seg[1], 0.30, 0.0) + end + love.graphics.setBlendMode("alpha") + for _, seg in ipairs(segs) do + col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], barH) + end + + -- Footer (Boi's Club Games logo + trust warning), measured first so the + -- columns know where they must stop. Drawn near the end. local warningWidth = math.min(width - 32, 620) - love.graphics.setFont(self.warningFont) local _, warningLines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) local warningH = #warningLines * self.warningFont:getHeight() - local warningY = height - warningH - 8 + local warningY = height - warningH - 10 local bcgW, bcgH = self.bcg:getDimensions() - local bcgScale = math.min(math.min(width - 48, 200) / bcgW, height * 0.075 / bcgH) + local bcgScale = math.min(math.min(width - 48, 200) / bcgW, height * 0.07 / bcgH) local bcgDW, bcgDH = bcgW * bcgScale, bcgH * bcgScale local bcgX, bcgY = (width - bcgDW) / 2, warningY - bcgDH - 8 self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH } - local footerTop = bcgY - 10 + local footerTop = bcgY - 12 - -- faint per-column tints under the split - col(PAL.red, 0.07); love.graphics.rectangle("fill", 0, stripeH, third, footerTop - stripeH) - col(PAL.blue, 0.07); love.graphics.rectangle("fill", third, stripeH, third, footerTop - stripeH) - col(PAL.gold, 0.08); love.graphics.rectangle("fill", 2 * third, stripeH, width - 2 * third, footerTop - stripeH) - - -- logo (larger, no subtitle), centred over the split + -- Logo metrics (drawn later with a bob so the cards never shift; layout uses + -- the resting position). local logoW, logoH = self.logo:getDimensions() - local logoScale = math.min(math.min(width - 40, 560) / logoW, height * 0.20 / logoH) + local logoScale = math.min(math.min(width - 40, 440 * s) / logoW, height * 0.20 / logoH) local logoDW, logoDH = logoW * logoScale, logoH * logoScale - local logoY = stripeH + height * 0.02 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.logo, (width - logoDW) / 2, logoY, 0, logoScale, logoScale) + local logoY = barH + 20 * s - -- shared column metrics so the three columns line up exactly - local m = { - boxW = math.min(third - 32, 300), - boxH = clamp(height * 0.22, 120, 190), - pillH = self.pillFont:getHeight() + 10, - buttonH = clamp(height * 0.072, 44, 56), - gap1 = clamp(height * 0.028, 12, 22), - gap2 = clamp(height * 0.030, 14, 24), - gap3 = 10, - hintH = self.hintFont:getHeight(), - } - local stackH = m.pillH + m.gap1 + m.boxH + m.gap2 + m.buttonH + m.gap3 + m.hintH - local regionTop = logoY + logoDH + height * 0.03 - local top = regionTop + math.max(0, (footerTop - regionTop - stackH) / 2) + -- shared card metrics so the three columns line up exactly + local padX = 20 * s + local padTop = 22 * s + local padBot = 24 * s + local cardW = math.min(third - 24 * s, 300 * s) + local buttonH = 50 * s + local gapHead = 8 * s + local gapDetail = 18 * s + local gapButton = 10 * s + local hintH = self.hintFont:getHeight() - -- draw one column and return the button/link hit rects for the caller - local function column(colX, spec) - local cx = colX + third / 2 - local x = cx - m.boxW / 2 - local a = spec.alpha or 1 - local y = top - - -- pill badge - love.graphics.setFont(self.pillFont) - local pillW = self.pillFont:getWidth(spec.label) + 28 - col(spec.color, a) - love.graphics.setLineWidth(2) - love.graphics.rectangle("line", cx - pillW / 2, y, pillW, m.pillH, m.pillH / 2, m.pillH / 2) - love.graphics.printf(spec.label, cx - pillW / 2, - y + (m.pillH - self.pillFont:getHeight()) / 2, pillW, "center") - y = y + m.pillH + m.gap1 - - -- box - col(PAL.box, a) - love.graphics.rectangle("fill", x, y, m.boxW, m.boxH) - love.graphics.setLineWidth(2) - col(spec.color, a) - if spec.dashed then - dashedRect(x, y, m.boxW, m.boxH, 7, 5) - else - love.graphics.rectangle("line", x, y, m.boxW, m.boxH) - end - local pad = 14 - love.graphics.setFont(self.headFont) - col(PAL.heading, a) - love.graphics.printf(spec.heading, x + pad, y + pad, m.boxW - 2 * pad, "center") - local _, headLines = self.headFont:getWrap(spec.heading, m.boxW - 2 * pad) - local headBlock = math.max(1, #headLines) * self.headFont:getHeight() - love.graphics.setFont(self.detailFont) - col(PAL.detail, a) - love.graphics.printf(spec.detail, x + pad, y + pad + headBlock + 8, m.boxW - 2 * pad, "center") - if spec.progress then - local barY = y + m.boxH - 20 - col(PAL.disabled, a) - love.graphics.rectangle("fill", x + pad, barY, m.boxW - 2 * pad, 8) - col(spec.color, a) - love.graphics.rectangle("fill", x + pad, barY, (m.boxW - 2 * pad) * spec.progress, 8) - end - y = y + m.boxH + m.gap2 - - -- button - local rect - if spec.button then - rect = { x = x, y = y, width = m.boxW, height = m.buttonH } - col(spec.button.bg, a) - love.graphics.rectangle("fill", x, y, m.boxW, m.buttonH) - love.graphics.setFont(self.buttonFont) - local label = spec.button.text - if spec.button.play then - -- filled play triangle + label, centred as a group - local tw = self.buttonFont:getWidth(label) - local tri = self.buttonFont:getHeight() * 0.55 - local groupW = tri + 12 + tw - local gx = x + (m.boxW - groupW) / 2 - local gy = y + m.buttonH / 2 - col(spec.button.fg, a) - love.graphics.polygon("fill", gx, gy - tri / 2, gx, gy + tri / 2, gx + tri * 0.9, gy) - love.graphics.print(label, gx + tri + 12, - y + (m.buttonH - self.buttonFont:getHeight()) / 2) - else - col(spec.button.fg, a) - love.graphics.printf(label, x, - y + (m.buttonH - self.buttonFont:getHeight()) / 2, m.boxW, "center") - end - end - y = y + m.buttonH + m.gap3 - - -- hint line, or the " imported · re-import" link - love.graphics.setFont(self.hintFont) - local reimportRect - if spec.reimport then - local prefix = spec.romName .. " imported · " - local link = "re-import" - local pw = self.hintFont:getWidth(prefix) - local lw = self.hintFont:getWidth(link) - local startX = cx - (pw + lw) / 2 - col(PAL.detail, a) - love.graphics.print(prefix, startX, y) - love.graphics.print(link, startX + pw, y) - love.graphics.setLineWidth(1) - love.graphics.line(startX + pw, y + self.hintFont:getHeight(), - startX + pw + lw, y + self.hintFont:getHeight()) - reimportRect = { x = startX + pw, y = y, width = lw, height = m.hintH + 2 } - elseif spec.hint then - col(PAL.detail, a) - love.graphics.printf(spec.hint, x, y, m.boxW, "center") - end - - return rect, reimportRect + -- natural height a card needs for its (wrapped) heading + detail + local function contentH(spec) + local innerW = cardW - 2 * padX + local _, hl = self.headFont:getWrap(spec.heading, innerW) + local _, dl = self.detailFont:getWrap(spec.detail, innerW) + local headH = math.max(1, #hl) * self.headFont:getHeight() + local detH = math.max(1, #dl) * self.detailFont:getHeight() + return padTop + headH + gapHead + detH + gapDetail + + buttonH + gapButton + hintH + padBot end - -- Red column: live, driven by the import state machine + -- Red column: live, driven by the import state machine. local redHint = self.android and "or copy the .gb via USB" or "or drop the .gb file here" - local redSpec = { color = PAL.red, label = "RED", dashed = false, alpha = 1 } + local redSpec = { + accent = PAL.red, interior = PAL.cardRed, + alpha = 1, glowScale = 1, period = 2.6, + } if self.state == "ready" then redSpec.heading = "Red ROM ready" redSpec.detail = "Your ROM is verified. Press Play to start." - redSpec.button = { text = "Play Red", bg = PAL.red, fg = PAL.white, play = true } - redSpec.reimport = true - redSpec.romName = self.romName + redSpec.button = { kind = "play", text = "Play Red" } + redSpec.link = { name = self.romName } elseif self.state == "working" or self.state == "complete" then redSpec.heading = self.status redSpec.detail = self.detail @@ -652,7 +729,7 @@ function RomImporter:draw() elseif self.state == "error" then redSpec.heading = "That ROM could not be imported" redSpec.detail = self.detail - redSpec.button = { text = "Choose ROM", bg = PAL.red, fg = PAL.white } + redSpec.button = { kind = "choose", text = "Choose ROM" } redSpec.hint = redHint else -- waiting if self.android then @@ -665,32 +742,230 @@ function RomImporter:draw() redSpec.heading = "Choose or drop a Red ROM" redSpec.detail = "The ROM is verified before any files are created." end - redSpec.button = { text = "Choose ROM", bg = PAL.red, fg = PAL.white } + redSpec.button = { kind = "choose", text = "Choose ROM" } redSpec.hint = redHint end - self.redButton, self.reimportRect = column(0, redSpec) - -- Blue and Yellow columns: placeholders until those games are supported - column(third, { - color = PAL.blue, label = "BLUE", dashed = true, alpha = 0.72, + -- Blue and Yellow columns: lit placeholders until those games are supported. + local blueSpec = { + accent = PAL.blue, interior = PAL.cardBlue, + alpha = 0.92, glowScale = 0.5, period = 3.4, heading = "Choose or drop a Blue ROM", detail = "Blue support is on the way.", - button = { text = "Coming soon", bg = PAL.disabled, fg = PAL.disabledInk }, - hint = "not yet available", - }) - column(2 * third, { - color = PAL.gold, label = "YELLOW", dashed = true, alpha = 0.72, + button = { kind = "disabled", text = "Coming soon" }, hint = "not yet available", + } + local yellowSpec = { + accent = PAL.gold, interior = PAL.cardGold, + alpha = 0.92, glowScale = 0.5, period = 3.8, heading = "Choose or drop a Yellow ROM", detail = "Yellow support is on the way.", - button = { text = "Coming soon", bg = PAL.disabled, fg = PAL.disabledInk }, - hint = "not yet available", - }) + button = { kind = "disabled", text = "Coming soon" }, hint = "not yet available", + } - -- footer + local cardH = math.max(contentH(redSpec), contentH(blueSpec), contentH(yellowSpec)) + local regionTop = logoY + logoDH + 14 * s + local top = regionTop + math.max(0, (footerTop - regionTop - cardH) / 2) + + -- Draw one neon cartridge card and return its button/link hit rects. + local function drawCard(colX, spec, ty) + local cx = colX + third / 2 + local x = cx - cardW / 2 + local y = ty + local a = spec.alpha or 1 + local r = 16 * s + + -- pulsing neon halo + local g = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / spec.period) + neonGlow(x, y, cardW, cardH, r, spec.accent, (0.35 + 0.55 * g) * (spec.glowScale or 1)) + + -- card body: accent tint at the top fading into a dark interior, + border + fillGradRounded(x, y, cardW, cardH, r, spec.accent, spec.interior, 0.16 * a, 0.55 * a) + love.graphics.setLineWidth(math.max(1, 1.5 * s)) + col(spec.accent, 0.6 * a) + love.graphics.rectangle("line", x, y, cardW, cardH, r, r) + + local contentY = y + padTop + + -- heading + love.graphics.setFont(self.headFont) + col(PAL.heading, a) + printfB(spec.heading, x + padX, contentY, cardW - 2 * padX, "center") + local _, hl = self.headFont:getWrap(spec.heading, cardW - 2 * padX) + contentY = contentY + math.max(1, #hl) * self.headFont:getHeight() + gapHead + + -- detail + love.graphics.setFont(self.detailFont) + col(PAL.detail, a) + love.graphics.printf(spec.detail, x + padX, contentY, cardW - 2 * padX, "center") + local _, dl = self.detailFont:getWrap(spec.detail, cardW - 2 * padX) + contentY = contentY + math.max(1, #dl) * self.detailFont:getHeight() + gapDetail + + -- button, or a neon progress bar while extracting + local bx, bw, by = x + padX, cardW - 2 * padX, contentY + local buttonRect + if spec.progress ~= nil then + local h2 = math.max(8, 10 * s) + local track = by + (buttonH - h2) / 2 + col(PAL.bgBot, 0.85) + love.graphics.rectangle("fill", bx, track, bw, h2, h2 / 2, h2 / 2) + local pw = bw * clamp(spec.progress, 0, 1) + if pw > h2 then + neonGlow(bx, track, pw, h2, h2 / 2, spec.accent, 0.6) + col(spec.accent, a) + love.graphics.rectangle("fill", bx, track, pw, h2, h2 / 2, h2 / 2) + end + elseif spec.button then + local br = 12 * s + local kind = spec.button.kind + buttonRect = { x = bx, y = by, width = bw, height = buttonH } + if kind == "disabled" then + col(PAL.disabled, 0.4 * a) + love.graphics.rectangle("fill", bx, by, bw, buttonH, br, br) + love.graphics.setLineWidth(1) + col(spec.accent, 0.3 * a) + love.graphics.rectangle("line", bx, by, bw, buttonH, br, br) + love.graphics.setFont(self.buttonFont) + col(PAL.disabledInk, a) + printfB(spec.button.text, bx, by + (buttonH - self.buttonFont:getHeight()) / 2, bw, "center") + buttonRect = nil -- placeholder columns are inert + else + local cTop, cBot, ink + if kind == "play" then cTop, cBot, ink = PAL.playTop, PAL.playBot, PAL.playInk + else cTop, cBot, ink = PAL.chooseTop, PAL.chooseBot, PAL.white end + local hot = hoverEnabled and ptIn(buttonRect) + if hot then anyHover = true end + neonGlow(bx, by, bw, buttonH, br, cTop, (0.7 + 0.25 * g) * (hot and 1.7 or 1)) + fillGradRounded(bx, by, bw, buttonH, br, cTop, cBot, 1, 1) + if hot then + -- brighten the face on hover + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 1, 1, 0.12) + love.graphics.rectangle("fill", bx, by, bw, buttonH, br, br) + love.graphics.setBlendMode("alpha") + end + buttonShine(bx, by, bw, buttonH, br, (pulse % 2.8) / 2.8) + love.graphics.setFont(self.buttonFont) + if kind == "play" then + -- filled play triangle + label, centred as a group + local label = spec.button.text + local tw = self.buttonFont:getWidth(label) + local tri = self.buttonFont:getHeight() * 0.55 + local groupW = tri + 12 * s + tw + local gx = bx + (bw - groupW) / 2 + local gy = by + buttonH / 2 + col(ink) + love.graphics.polygon("fill", gx, gy - tri / 2, gx, gy + tri / 2, gx + tri * 0.9, gy) + printB(label, gx + tri + 12 * s, by + (buttonH - self.buttonFont:getHeight()) / 2) + else + col(ink) + printfB(spec.button.text, bx, by + (buttonH - self.buttonFont:getHeight()) / 2, bw, "center") + end + end + end + contentY = by + buttonH + gapButton + + -- subline: a hint, or the " · re-import" link + love.graphics.setFont(self.hintFont) + local linkRect + if spec.link then + local prefix = spec.link.name .. " · " + local link = "re-import" + local pw = self.hintFont:getWidth(prefix) + local lw = self.hintFont:getWidth(link) + local startX = cx - (pw + lw) / 2 + col(PAL.detail, a) + love.graphics.print(prefix, startX, contentY) + love.graphics.print(link, startX + pw, contentY) + love.graphics.setLineWidth(1) + love.graphics.line(startX + pw, contentY + hintH, startX + pw + lw, contentY + hintH) + linkRect = { x = startX + pw, y = contentY, width = lw, height = hintH + 3 } + elseif spec.hint then + col(PAL.detail, a) + love.graphics.printf(spec.hint, x + padX, contentY, cardW - 2 * padX, "center") + end + + return buttonRect, linkRect + end + + self.redButton, self.reimportRect = drawCard(0, redSpec, top) + drawCard(third, blueSpec, top) + drawCard(2 * third, yellowSpec, top) + + -- logo, centred over the split, with a gentle bob + gold glow + local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s + local lx, ly = (width - logoDW) / 2, logoY + bob + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6))) + love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0, + logoScale * 1.05, logoScale * 1.05) + love.graphics.setBlendMode("alpha") + -- main logo, with the same sweeping shine the active buttons get + local shineW = 0.16 + self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW)) + self.shineShader:send("shineW", shineW) + love.graphics.setShader(self.shineShader) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale) + love.graphics.setShader() + + -- footer: BCG mark (inverted to white, glowing brighter on hover) + warning + local bcgHot = hoverEnabled and ptIn(self.bcgButton) + if bcgHot then anyHover = true end + love.graphics.setShader(self.invertShader) + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22) + love.graphics.draw(self.bcg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0, + bcgScale * 1.04, bcgScale * 1.04) + love.graphics.setBlendMode("alpha") love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.bcg, bcgX, bcgY, 0, bcgScale, bcgScale) - col(PAL.detail) + love.graphics.setShader() + + -- trust warning; the bois.icu URL inside it is a real hover link love.graphics.setFont(self.warningFont) + col(PAL.warning) love.graphics.printf(TRUST_WARNING, (width - warningWidth) / 2, warningY, warningWidth, "center") + self.linkUrlRect = nil + do + local wrapX = (width - warningWidth) / 2 + local lh = self.warningFont:getHeight() + local _, lines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) + for i, line in ipairs(lines) do + local sidx = line:find(COMMUNITY_URL, 1, true) + if sidx then + -- the URL sits on a centred wrapped line: find its exact x-offset so the + -- coloured link overdraws the plain-warning glyphs already printed there + local before = line:sub(1, sidx - 1) + local lineW = self.warningFont:getWidth(line) + local ux = wrapX + (warningWidth - lineW) / 2 + self.warningFont:getWidth(before) + local uy = warningY + (i - 1) * lh + local uw = self.warningFont:getWidth(COMMUNITY_URL) + self.linkUrlRect = { x = ux, y = uy, width = uw, height = lh } + local linkHot = hoverEnabled and ptIn(self.linkUrlRect) + if linkHot then anyHover = true end + col(linkHot and PAL.linkHover or PAL.link) + love.graphics.print(COMMUNITY_URL, ux, uy) + love.graphics.setLineWidth(1) + love.graphics.line(ux, uy + lh - 1, ux + uw, uy + lh - 1) + break + end + end + end + + -- CRT scanlines + vignette, over everything love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0) + love.graphics.draw(self.vignetteMesh) + love.graphics.setColor(1, 1, 1, 1) + + -- pointer cursor over any interactive element (desktop only) + if hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then + if anyHover then + self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") + love.mouse.setCursor(self.handCursor) + else + self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") + love.mouse.setCursor(self.arrowCursor) + end + end end local function inside(r, x, y) @@ -699,7 +974,7 @@ end function RomImporter:mousepressed(x, y, button) if button ~= 1 then return end - if inside(self.bcgButton, x, y) then + if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then love.system.openURL(COMMUNITY_URL) return end