The store's library shelves carry real cover art on every platform #239

Merged
enricobuehler merged 1 commits from worktree-shot-cover-art into main 2026-08-14 22:06:35 +00:00
7 changed files with 525 additions and 32 deletions
@@ -3,9 +3,12 @@ package io.unom.punktfunk.screenshots
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BlendMode
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
@@ -708,39 +711,250 @@ private fun shotGames() = listOf(
private fun shotLibraryLoader(context: Context): ImageLoader {
val engine = FakeImageLoaderEngine.Builder()
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
.default(ColorDrawable(0xFF221E44.toInt()))
.build()
return ImageLoader.Builder(context).components { add(engine) }.build()
}
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
val w = 600
val h = 900
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
private const val POSTER_W = 600
private const val POSTER_H = 900
private fun posterY(v: Float) = POSTER_H - v
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
private class ShotRand(var state: ULong) {
fun next(): Float {
state = state * 6364136223846793005UL + 1442695040888963407UL
return (state shr 33).toFloat() / (1L shl 31).toFloat()
}
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
}
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
draw(canvas)
posterTitle(canvas, title)
return BitmapDrawable(context.resources, bmp)
}
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
canvas.drawRect(
0f, 0f, w.toFloat(), h.toFloat(),
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
0f, POSTER_H.toFloat(), 0f, 0f,
stops.map { it.second }.toIntArray(),
stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
},
)
canvas.drawText(
mark, w / 2f, h / 2f + 110f,
}
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
canvas.drawCircle(
x, posterY(y), radius,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xD9FFFFFF.toInt()
textSize = 320f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textAlign = Paint.Align.CENTER
shader = RadialGradient(
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
)
},
)
return BitmapDrawable(context.resources, bmp)
}
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
canvas.drawPath(
path,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
strokeWidth = width * mult
this.color = shotAlpha(color, a)
blendMode = BlendMode.SCREEN
},
)
}
}
private fun posterTitle(canvas: Canvas, title: String) {
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
canvas.drawText(
title, POSTER_W / 2f, posterY(72f),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = shotAlpha(0xFFFFFF, 0.94f)
textSize = 46f
letterSpacing = 5f / 46f
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
textAlign = Paint.Align.CENTER
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
},
)
}
private fun drawAurora(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
val rng = ShotRand(11UL)
repeat(48) {
val x = rng.range(0f, 600f)
val y = rng.range(300f, 890f)
val r = rng.range(1.4f, 3.2f)
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
}
data class Ribbon(
val base: Float, val amp: Float, val freq: Float,
val phase: Float, val w: Float, val c: Int,
)
for (r in listOf(
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
)) {
val path = Path()
for (i in 0..60) {
val t = i / 60f
val x = t * 600f
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
}
glowStroke(canvas, path, r.w, r.c)
}
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
for ((fill, baseline, rough) in listOf(
Triple(0xFF191345.toInt(), 212f, 30f),
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..9) {
val x = i / 9f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawStarfall(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
),
)
val rng = ShotRand(23UL)
repeat(6) {
val hx = rng.range(60f, 560f)
val hy = rng.range(420f, 840f)
val len = rng.range(90f, 170f)
val dx = kotlin.math.cos(2.15f)
val dy = kotlin.math.sin(2.15f)
val path = Path()
path.moveTo(hx, posterY(hy))
path.lineTo(hx + dx * len, posterY(hy + dy * len))
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
}
for ((fill, baseline, rough) in listOf(
Triple(0xFF3A1430.toInt(), 300f, 26f),
Triple(0xFF1D0818.toInt(), 216f, 34f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline))
for (i in 1..8) {
val x = i / 8f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawNeon(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
val rng = ShotRand(7UL)
val ring = Path().apply {
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
}
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
val gateX = listOf(-105f, 105f, 0f, 0f)
val gateY = listOf(0f, 0f, -105f, 105f)
for (i in 0 until 9) {
var px: Float
var py: Float
if (i < 4) {
px = 300f + gateX[i]
py = 560f + gateY[i]
} else {
px = 40f * kotlin.math.round(rng.range(1f, 14f))
py = 40f * kotlin.math.round(rng.range(1f, 21f))
}
val path = Path()
path.moveTo(px, posterY(py))
var horizontal = rng.next() > 0.5f
repeat(rng.range(3f, 6f).toInt()) {
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
path.lineTo(px, posterY(py))
horizontal = !horizontal
}
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
glowStroke(canvas, path, 5f, color)
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
}
}
private fun drawEmber(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
),
)
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
val rng = ShotRand(41UL)
for ((fill, baseline, rough) in listOf(
Triple(0xFF5A2410.toInt(), 340f, 42f),
Triple(0xFF401708.toInt(), 255f, 56f),
Triple(0xFF200A04.toInt(), 165f, 48f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..10) {
val x = i / 10f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
repeat(20) {
val x = rng.range(30f, 570f)
val y = rng.range(180f, 620f)
val r = rng.range(2.5f, 6f)
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
}
}
@Composable
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
@@ -74,7 +74,7 @@ struct LibraryView: View {
@State private var errorText: String?
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
@State private var artLoader: (any LibraryArtSource)?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
@@ -409,7 +409,7 @@ private struct LibraryBackCatcher: View {
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
/// The hardware-keyboard cursor is on this tile drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
@@ -70,7 +70,7 @@ private extension Image {
struct PosterImage: View {
let candidates: [URL]
let title: String
let loader: LibraryArtLoader?
let loader: (any LibraryArtSource)?
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
/// no cover art by design, so for those the mark IS the poster see `placeholder`.
var icon: String?
@@ -207,15 +207,20 @@ enum ShotMock {
/// A believable shelf for the library coverflow. Decoded rather than constructed:
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
/// construction surface. No art URLs the posters render their deterministic fallback
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
/// construction surface. The `shot://art/` posters are answered by [`ShotPosterArt.source`]
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
/// by design and renders its brand mark.
static let games: [GameEntry] = {
let json = """
[
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
"art": {"portrait": "shot://art/aurora"}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
"art": {"portrait": "shot://art/starfall"}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
"art": {"portrait": "shot://art/neon"}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
"art": {"portrait": "shot://art/ember"}},
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
"role": "launcher", "icon": "steam"}
]
@@ -263,12 +268,12 @@ private struct ShotHome: View {
// MARK: - Library
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
/// so the cards swing in already carrying posters (the entrance waits on art settling).
private struct ShotLibrary: View {
var body: some View {
LibraryCoverflowView(
games: ShotMock.games, artLoader: nil,
games: ShotMock.games, artLoader: ShotPosterArt.source,
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
}
}
@@ -0,0 +1,264 @@
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
// capture time no bundled assets, nothing in a release build, and the same designs the Android
// harness draws in Canvas, so the two listings show the same shelf.
#if DEBUG
import CoreText
import Foundation
import ImageIO
import PunktfunkKit
import UniformTypeIdentifiers
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
/// the real coverflow in place of the paired-host loader.
struct ShotArtSource: LibraryArtSource {
let fixtures: [String: Data]
func data(for url: URL) async throws -> Data {
guard let data = fixtures[url.absoluteString] else {
throw CocoaError(.fileNoSuchFile)
}
return data
}
func close() async {}
}
enum ShotPosterArt {
/// Art for `ShotMock.games` keyed by the `shot://art/` URLs those entries carry.
static let source = ShotArtSource(fixtures: [
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
])
private static let W = 600
private static let H = 900
// MARK: - Canvas plumbing
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
draw(ctx)
drawTitle(ctx, title)
let image = ctx.makeImage()!
let out = NSMutableData()
let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationFinalize(dest)
return out as Data
}
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
}
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: stops.map(\.1) as CFArray,
locations: stops.map(\.0))!
ctx.drawLinearGradient(
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
}
private static func glowDot(
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
) {
let clear = color.copy(alpha: 0)!
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: [color, clear] as CFArray, locations: [0, 1])!
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0,
endCenter: center, endRadius: radius, options: [])
}
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend the cheap
/// neon-glow trick every one of these posters leans on.
private static func glowStroke(
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
) {
ctx.saveGState()
ctx.setBlendMode(.screen)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
ctx.addPath(path)
ctx.setLineWidth(width * mult)
ctx.setStrokeColor(color.copy(alpha: alpha)!)
ctx.strokePath()
}
ctx.restoreGState()
}
private static func drawTitle(_ ctx: CGContext, _ title: String) {
// A soft floor behind the caption keeps it legible over any art.
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
let text = NSAttributedString(string: title, attributes: [
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
] as [NSAttributedString.Key: Any])
let line = CTLineCreateWithAttributedString(text)
let bounds = CTLineGetBoundsWithOptions(line, [])
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
CTLineDraw(line, ctx)
ctx.restoreGState()
}
/// Deterministic LCG so every capture draws the identical poster.
private struct Rand {
var state: UInt64
mutating func next() -> CGFloat {
state = state &* 6364136223846793005 &+ 1442695040888963407
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
}
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
}
// MARK: - The four posters
private static func drawAurora(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
var rng = Rand(state: 11)
for _ in 0..<48 {
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
}
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
(700, 55, 1.15, 0.4, 30, 0x6656F2),
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
]
for r in ribbons {
let path = CGMutablePath()
for i in 0...60 {
let t = CGFloat(i) / 60
let p = CGPoint(
x: t * 600,
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
}
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
}
// A low ridge grounds the scene without it the poster's bottom half is bare sky.
for (fill, baseline, rough) in [
(rgb(0x191345), CGFloat(212), CGFloat(30)),
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...9 {
let x = CGFloat(i) / 9 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawStarfall(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
var rng = Rand(state: 23)
for _ in 0..<6 {
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
let len = rng.in_(90, 170)
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° up-left tails
let path = CGMutablePath()
path.move(to: head)
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
}
for (fill, baseline, rough) in [
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline))
for i in 1...8 {
let x = CGFloat(i) / 8 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawNeon(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
var rng = Rand(state: 7)
let ring = CGPath(
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
for i in 0..<9 {
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
var p = i < 4
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
let path = CGMutablePath()
path.move(to: p)
var horizontal = rng.next() > 0.5
for _ in 0..<Int(rng.in_(3, 6)) {
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
path.addLine(to: p)
horizontal.toggle()
}
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
glowStroke(ctx, path, width: 5, color: color)
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
}
}
private static func drawEmber(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
var rng = Rand(state: 41)
for (fill, baseline, rough) in [
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
(rgb(0x401708), CGFloat(255), CGFloat(56)),
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...10 {
let x = CGFloat(i) / 10 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
for _ in 0..<20 {
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
}
}
}
#endif
@@ -228,6 +228,16 @@ extension Artwork {
}
}
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
/// artwork without a host on the network.
public protocol LibraryArtSource: Sendable {
func data(for url: URL) async throws -> Data
/// Release pooled connections when the owning screen goes away. Sources without connections
/// have nothing to do.
func close() async
}
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
///
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
@@ -242,7 +252,7 @@ extension Artwork {
/// TLS handshake per tile.
///
/// Built once per library screen and reused across a whole grid's worth of posters.
public final class LibraryArtLoader: @unchecked Sendable {
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
private let address: String
private let port: UInt16
private let identity: SecIdentity