launcher spruce p2

This commit is contained in:
bryanthaboi
2026-07-23 09:44:09 -04:00
parent da14b7ee3f
commit 5128b840b1
+469 -194
View File
@@ -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 "<rom> 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 "<rom> · 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