The store's library shelves carry real cover art on every platform #239
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user