diff --git a/api/_lib/challenge-card.ts b/api/_lib/challenge-card.ts index eb4748c..20410dc 100644 --- a/api/_lib/challenge-card.ts +++ b/api/_lib/challenge-card.ts @@ -9,6 +9,7 @@ import { CARD_VERSION } from '../../src/utils/share-card-version.js'; export interface SharedChallenge { id: string; + userId: string; name: string; username: string | null; avatarUrl: string | null; @@ -41,6 +42,7 @@ export async function loadChallenge(id: string, db: ChallengeDb = adminDatabases const lines = code.split('\n'); return { id, + userId: text(doc.userId, 36), name: text(doc.name, 64) || username || 'A Codey typist', username, avatarUrl: username ? `https://avatars.githubusercontent.com/${encodeURIComponent(username)}?s=200` : null, diff --git a/api/_lib/kap-look.test.ts b/api/_lib/kap-look.test.ts new file mode 100644 index 0000000..e9053fc --- /dev/null +++ b/api/_lib/kap-look.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { lookFromPrefs, playerKap } from './kap-look'; + +describe('kap look on share cards', () => { + it('reads the synced look, keeping only unlocked items', () => { + const prefs = { + sync: { slices: { kapLook: { at: 1, value: { color: 'ocean', eyes: 'shades', wear: 'cape' } } } }, + achievements: { unlockedAt: { 'streak-2': 1, 'speed-3': 1 } }, + }; + expect(lookFromPrefs(prefs)).toEqual({ color: 'ocean', eyes: 'shades' }); + expect(lookFromPrefs({})).toEqual({}); + }); + + it('falls back to the plain Kap when prefs cannot be read', async () => { + const uri = await playerKap('user1', 7, 200, async () => { throw new Error('offline'); }); + expect(uri.startsWith('data:image/svg+xml;base64,')).toBe(true); + const svg = Buffer.from(uri.split(',')[1], 'base64').toString(); + expect(svg).toContain('#fbbf24'); + }); + + it('dresses Kap from the prefs', async () => { + const uri = await playerKap('user1', 7, 200, async () => ({ + sync: { slices: { kapLook: { value: { color: 'sakura', head: 'party' } } } }, + achievements: { unlockedAt: { 'streak-3': 1, 'duel-1': 1 } }, + })); + const svg = Buffer.from(uri.split(',')[1], 'base64').toString(); + expect(svg).toContain('#f9a8d4'); + expect(svg).toContain('#f472b6'); + }); +}); diff --git a/api/_lib/kap-look.ts b/api/_lib/kap-look.ts new file mode 100644 index 0000000..e2bab3e --- /dev/null +++ b/api/_lib/kap-look.ts @@ -0,0 +1,35 @@ +import { Client, Users } from 'node-appwrite'; +import { APPWRITE } from './appwrite-admin.js'; +import { FLAME_TIERS, tierFor } from '../../src/utils/flame-tiers.js'; +import { kapDataUri, sanitizeLook, type KapLook } from '../../src/utils/kap-art.js'; + +/** + * The player's Kap for share cards: the look they chose in the Wardrobe, + * read from their account prefs (the synced `kapLook` slice), limited to + * items their synced achievements unlock. + */ + +type Prefs = Record; + +export function lookFromPrefs(prefs: Prefs): KapLook { + const slices = ((prefs.sync as { slices?: Record } | undefined)?.slices ?? {}); + const unlockedAt = ((prefs.achievements as { unlockedAt?: Record } | undefined)?.unlockedAt ?? {}); + return sanitizeLook(slices.kapLook?.value, new Set(Object.keys(unlockedAt))); +} + +async function readPrefs(userId: string): Promise { + if (!APPWRITE.apiKey || !APPWRITE.projectId) return {}; + const client = new Client().setEndpoint(APPWRITE.endpoint).setProject(APPWRITE.projectId).setKey(APPWRITE.apiKey); + return (await new Users(client).getPrefs({ userId })) as Prefs; +} + +/** A data URI of the player's Kap; the plain Kap when the look cannot be read. */ +export async function playerKap(userId: string, streakDays = 7, width = 200, read: (id: string) => Promise = readPrefs): Promise { + let look: KapLook = {}; + try { + if (userId) look = lookFromPrefs(await read(userId)); + } catch { + // The card still draws, with Kap in his usual amber. + } + return kapDataUri(tierFor(streakDays) ?? FLAME_TIERS[1], look, width); +} diff --git a/api/_lib/og-image.ts b/api/_lib/og-image.ts index d433b9e..ae44fa9 100644 --- a/api/_lib/og-image.ts +++ b/api/_lib/og-image.ts @@ -39,6 +39,20 @@ function stat(label: string, value: string, size = 38) { ); } +/** Bottom-left branding: the player's Kap on a coal tile when there is one, else the mark. */ +function brand(kap?: string, tile = 132) { + if (!kap) return h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')); + return h('div', { alignItems: 'center', gap: 16 }, + h('div', { width: tile, height: tile, borderRadius: Math.round(tile / 4), background: 'rgba(120,53,15,0.16)', alignItems: 'center', justifyContent: 'center' }, + img(kap, { width: Math.round(tile * 0.85), height: Math.round(tile * 0.91) }), + ), + h('div', { flexDirection: 'column' }, + h('div', { fontSize: 30, color: COAL }, 'Codey'), + h('div', { fontSize: 20, color: DEEP }, 'with Kap'), + ), + ); +} + function mark(background: string, color: string) { return h('div', { width: 44, height: 44, borderRadius: 12, background, color, fontSize: 26, alignItems: 'center', justifyContent: 'center' }, ''); } @@ -80,7 +94,7 @@ function wpmSize(text: string) { return 128; } -export function renderShareImage(run: SharedRun | null, host: string): ImageResponse { +export function renderShareImage(run: SharedRun | null, host: string, kap?: string): ImageResponse { if (!run) { const root = h('div', { width: 1200, height: 630, background: DARK, fontFamily: 'Geist' }, h('div', { width: PANEL, height: 630, background: AMBER, flexDirection: 'column', justifyContent: 'space-between', padding: '56px 52px' }, @@ -118,7 +132,7 @@ export function renderShareImage(run: SharedRun | null, host: string): ImageResp h('div', { fontSize: wpmSize(wpm), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, wpm), h('div', { fontSize: captionSize(where), color: DEEP, marginTop: 10 }, where), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap), ); const middle = hasTrace @@ -158,7 +172,7 @@ function avatar(name: string, url: string | null) { } /** The player's card: best WPM on the amber side, recent form and totals on the dark side. */ -export function renderProfileImage(profile: SharedProfile, host: string): ImageResponse { +export function renderProfileImage(profile: SharedProfile, host: string, kap?: string): ImageResponse { const best = profile.runs ? formatWpm(profile.bestWpm) : '–'; const where = !profile.runs ? 'no runs yet' : profile.bestLanguage === 'Mixed' ? 'best wpm across languages' : `best wpm in ${profile.bestLanguage}`; const hasTrend = profile.trend.length >= 3; @@ -175,7 +189,7 @@ export function renderProfileImage(profile: SharedProfile, host: string): ImageR h('div', { fontSize: wpmSize(best), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, best), h('div', { fontSize: captionSize(where), color: DEEP, marginTop: 10 }, where), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap), ); const middle = hasTrend @@ -221,7 +235,7 @@ export function renderProfileImage(profile: SharedProfile, host: string): ImageR } /** "Can you beat it?": the score to beat on the amber side, the snippet on the dark side. */ -export function renderChallengeImage(challenge: SharedChallenge, host: string): ImageResponse { +export function renderChallengeImage(challenge: SharedChallenge, host: string, kap?: string): ImageResponse { const wpm = formatWpm(challenge.wpm); const left = h('div', { width: PANEL, height: 630, background: AMBER, flexDirection: 'column', justifyContent: 'space-between', padding: '56px 52px' }, h('div', { alignItems: 'center', gap: 16 }, @@ -236,7 +250,7 @@ export function renderChallengeImage(challenge: SharedChallenge, host: string): h('div', { fontSize: wpmSize(wpm), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, wpm), h('div', { fontSize: 40, color: DEEP, marginTop: 10 }, 'wpm?'), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap, 96), ); const code = h('div', { flexDirection: 'column', gap: 8, padding: '22px 24px', borderRadius: 18, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }, diff --git a/api/_lib/share-card.test.ts b/api/_lib/share-card.test.ts index 0f92918..192c2f0 100644 --- a/api/_lib/share-card.test.ts +++ b/api/_lib/share-card.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { formatWpm } from "./og-image"; import { formatLabel, loadSharedRun, shareHtml, type SharedRun } from "./share-card"; +import { CARD_VERSION } from "../../src/utils/share-card-version"; function fakeDb(docs: Record>) { const reads: string[] = []; @@ -74,7 +75,7 @@ describe("shareHtml", () => { it("points crawlers at the rendered card and people at the profile", () => { const html = shareHtml(shared, "https://codey.example", "run_abc"); - expect(html).toContain(''); + expect(html).toContain(``); expect(html).toContain(''); expect(html).toContain(''); expect(html).toContain('location.replace("https://codey.example/profile/user_1")'); diff --git a/api/og/[id].ts b/api/og/[id].ts index 0f07d40..3e2820d 100644 --- a/api/og/[id].ts +++ b/api/og/[id].ts @@ -3,6 +3,7 @@ import { loadSharedRun } from '../_lib/share-card.js'; import { renderChallengeImage, renderProfileImage, renderShareImage } from '../_lib/og-image.js'; import { loadChallenge } from '../_lib/challenge-card.js'; import { loadSharedProfile } from '../_lib/profile-card.js'; +import { playerKap } from '../_lib/kap-look.js'; interface BinaryResponse { status: (code: number) => BinaryResponse; @@ -22,7 +23,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { const host = first(req.headers['x-forwarded-host']) || first(req.headers.host) || 'codey'; if (first(req.query.kind) === 'challenge') { const challenge = isConfigured() ? await loadChallenge(id) : null; - const image = challenge ? renderChallengeImage(challenge, host) : renderShareImage(null, host); + const image = challenge ? renderChallengeImage(challenge, host, await playerKap(challenge.userId)) : renderShareImage(null, host); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); // Challenges never change once created. @@ -32,7 +33,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { } if (first(req.query.kind) === 'profile') { const profile = isConfigured() ? await loadSharedProfile(id) : null; - const image = profile ? renderProfileImage(profile, host) : renderShareImage(null, host); + const image = profile ? renderProfileImage(profile, host, await playerKap(profile.userId, profile.bestStreak)) : renderShareImage(null, host); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', profile ? 'public, max-age=300, s-maxage=600' : 'public, max-age=300'); @@ -40,7 +41,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { return; } const run = isConfigured() ? await loadSharedRun(id) : null; - const image = renderShareImage(run, host); + const image = renderShareImage(run, host, run ? await playerKap(run.userId) : undefined); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', run ? 'public, max-age=86400, s-maxage=31536000, immutable' : 'public, max-age=300'); diff --git a/src/components/streak/kap-skins.tsx b/src/components/streak/kap-skins.tsx index bec583c..b03dedb 100644 --- a/src/components/streak/kap-skins.tsx +++ b/src/components/streak/kap-skins.tsx @@ -1,126 +1,13 @@ -/** - * Kap's wardrobe: keycap colourways plus one item per slot (head, eyes, wear). - * Items are drawn in the 240×258 Kap artwork space; the flame sits at x 120 - * on the top face, the eyes at y 156, so nothing here covers either. - */ +import { behindMarkup, frontMarkup, type KapLook } from "@/utils/kap-art"; -export type KapColorId = "amber" | "retro" | "ocean" | "sakura" | "matcha" | "graphite" | "obsidian"; -export type KapHeadId = "headphones" | "party" | "halo"; -export type KapEyesId = "shades" | "nerd" | "monocle"; -export type KapWearId = "scarf" | "headband" | "cape"; +export { KAP_COLORS, colorway, type KapColorId, type KapColorway, type KapEyesId, type KapHeadId, type KapLook, type KapWearId } from "@/utils/kap-art"; -export interface KapLook { - color?: KapColorId; - head?: KapHeadId | null; - eyes?: KapEyesId | null; - wear?: KapWearId | null; -} - -export interface KapColorway { - id: KapColorId; - name: string; - /** Top face, front skirt, sides and arms; undefined keeps the flame tier's. */ - cap?: [string, string, string]; - /** Face lines, for dark caps. */ - ink?: string; -} - -export const KAP_COLORS: KapColorway[] = [ - { id: "amber", name: "Amber" }, - { id: "retro", name: "Retro beige", cap: ["#f5f0e1", "#e3dac3", "#b3a78b"] }, - { id: "ocean", name: "Ocean", cap: ["#cffafe", "#67e8f9", "#0891b2"] }, - { id: "sakura", name: "Sakura", cap: ["#fce7f3", "#f9a8d4", "#db2777"] }, - { id: "matcha", name: "Matcha", cap: ["#ecfccb", "#bef264", "#65a30d"] }, - { id: "graphite", name: "Graphite", cap: ["#e4e4e7", "#a1a1aa", "#52525b"] }, - { id: "obsidian", name: "Obsidian", cap: ["#52525b", "#3f3f46", "#18181b"], ink: "#fafafa" }, -]; - -export function colorway(id: KapColorId | undefined): KapColorway { - return KAP_COLORS.find((color) => color.id === id) ?? KAP_COLORS[0]; -} - -/** Drawn before the body: only the parts outside the keycap show. */ +/** Accessories drawn before the body; the markup comes from our own constants, never user input. */ export function KapBehind({ look }: { look: KapLook }) { - return ( - <> - {look.wear === "cape" && ( - - - - - - )} - {look.head === "halo" && ( - - - - - )} - - ); + return ; } -/** Drawn over the face and flame. */ +/** Accessories drawn over the face and flame. */ export function KapFront({ look, ink }: { look: KapLook; ink: string }) { - return ( - <> - {look.wear === "scarf" && ( - - - - - - - )} - {look.wear === "headband" && ( - - - - - - - - )} - {look.eyes === "shades" && ( - - {/* Pixel shades, drawn on an 4px grid. */} - - - - - )} - {look.eyes === "nerd" && ( - - - - - - - )} - {look.eyes === "monocle" && ( - - - - - )} - {look.head === "headphones" && ( - - - - - - - - - )} - {look.head === "party" && ( - - - - - - - )} - - ); + return ; } diff --git a/src/lib/duel-card.ts b/src/lib/duel-card.ts index bca26da..278cdfe 100644 --- a/src/lib/duel-card.ts +++ b/src/lib/duel-card.ts @@ -1,3 +1,4 @@ +import { drawPlayerKap } from "./kap-canvas"; /** A duel's result as a 1200×630 image, in the same split style as the other cards. */ export interface DuelCardRacer { @@ -102,6 +103,7 @@ export async function createDuelCard(input: DuelCardInput, host = window.locatio context.fillStyle = COAL; context.font = `500 28px ${FONT}`; context.fillText("Codey", 110, 562); + await drawPlayerKap(context, 322, 454, 124); context.fillStyle = MUTED; context.font = `500 18px ${FONT}`; diff --git a/src/lib/kap-canvas.ts b/src/lib/kap-canvas.ts new file mode 100644 index 0000000..ac366fa --- /dev/null +++ b/src/lib/kap-canvas.ts @@ -0,0 +1,30 @@ +import { getStreak } from "@/utils/storage"; +import { FLAME_TIERS, tierFor } from "@/utils/flame-tiers"; +import { kapSvg } from "@/utils/kap-art"; +import { readLook } from "@/lib/kap-wardrobe"; + +/** + * Draws the player's Kap (their Wardrobe look and current flame) onto a + * share card, on a soft tile. Resolves quietly if the image cannot load, so + * a card never fails over the mascot. + */ +export async function drawPlayerKap(context: CanvasRenderingContext2D, x: number, y: number, tile: number) { + const tier = tierFor(getStreak().current) ?? FLAME_TIERS[1]; + const width = Math.round(tile * 0.85); + const height = Math.round(width * 258 / 240); + const url = URL.createObjectURL(new Blob([kapSvg(tier, readLook(), width * 2)], { type: "image/svg+xml" })); + try { + const image = new Image(); + image.src = url; + await image.decode(); + context.fillStyle = "rgba(120,53,15,0.16)"; + context.beginPath(); + context.roundRect(x, y, tile, tile, tile / 4); + context.fill(); + context.drawImage(image, x + (tile - width) / 2, y + (tile - height) / 2 + 2, width, height); + } catch { + // No Kap on this card. + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/src/lib/kap-wardrobe.ts b/src/lib/kap-wardrobe.ts index 2d909b0..7341339 100644 --- a/src/lib/kap-wardrobe.ts +++ b/src/lib/kap-wardrobe.ts @@ -1,48 +1,15 @@ import { describe as describeBadge, readStore } from "@/lib/achievements"; -import { KAP_COLORS, type KapColorId, type KapEyesId, type KapHeadId, type KapLook, type KapWearId } from "@/components/streak/kap-skins"; +import { isUnlocked, sanitizeLook, WARDROBE, type KapColorId, type KapLook, type WardrobeItem, type WardrobeSlot } from "@/utils/kap-art"; + +export { isUnlocked, sanitizeLook, WARDROBE, type WardrobeItem, type WardrobeSlot }; /** * Kap's wardrobe: every item is unlocked by an achievement, so progress, * device sync and "you earned it" moments all come from the badge system. */ -export type WardrobeSlot = "color" | "head" | "eyes" | "wear"; - -export interface WardrobeItem { - slot: WardrobeSlot; - id: KapColorId | KapHeadId | KapEyesId | KapWearId; - name: string; - /** Achievement id that unlocks it; null is free. */ - requires: string | null; -} - -const COLOR_UNLOCKS: Record = { - amber: null, - retro: "runs-1", - ocean: "streak-2", - sakura: "streak-3", - matcha: "runs-2", - graphite: "ranked-2", - obsidian: "time-3", -}; - -export const WARDROBE: WardrobeItem[] = [ - ...KAP_COLORS.map((color) => ({ slot: "color" as const, id: color.id, name: color.name, requires: COLOR_UNLOCKS[color.id] })), - { slot: "head", id: "headphones", name: "Headphones", requires: "time-2" }, - { slot: "head", id: "party", name: "Party hat", requires: "duel-1" }, - { slot: "head", id: "halo", name: "Halo", requires: "flawless" }, - { slot: "eyes", id: "shades", name: "Pixel shades", requires: "speed-3" }, - { slot: "eyes", id: "nerd", name: "Nerd glasses", requires: "polyglot-3" }, - { slot: "eyes", id: "monocle", name: "Monocle", requires: "daily-2" }, - { slot: "wear", id: "scarf", name: "Scarf", requires: "streak-1" }, - { slot: "wear", id: "headband", name: "Headband", requires: "streak-4" }, - { slot: "wear", id: "cape", name: "Cape", requires: "speed-4" }, -]; - export const SLOT_NAMES: Record = { color: "Keycap", head: "Head", eyes: "Eyes", wear: "Wear" }; -export const isUnlocked = (item: WardrobeItem, earned: ReadonlySet) => item.requires === null || earned.has(item.requires); - /** What it takes, from the badge's own goal text, e.g. "Hit 100 WPM in a run". */ export function unlockHint(item: WardrobeItem): string { if (!item.requires) return "Free"; @@ -63,19 +30,6 @@ export function itemsUnlockedBy(badgeId: string): WardrobeItem[] { export const LOOK_KEY = "codey_kap_look"; export const LOOK_EVENT = "codey:kap-look"; -const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; - -/** Keeps only known items that are unlocked, so a stale or synced look never shows locked gear. */ -export function sanitizeLook(value: unknown, earned: ReadonlySet): KapLook { - const raw = (value && typeof value === "object" ? value : {}) as Record; - const look: KapLook = {}; - for (const slot of SLOTS) { - const item = WARDROBE.find((candidate) => candidate.slot === slot && candidate.id === raw[slot]); - if (item && isUnlocked(item, earned)) (look as Record)[slot] = item.id; - } - return look; -} - export function readLook(earned: ReadonlySet = earnedBadges()): KapLook { try { return sanitizeLook(JSON.parse(localStorage.getItem(LOOK_KEY) ?? "null"), earned); diff --git a/src/lib/recap-card.ts b/src/lib/recap-card.ts index 40821e4..26e7c85 100644 --- a/src/lib/recap-card.ts +++ b/src/lib/recap-card.ts @@ -1,3 +1,4 @@ +import { drawPlayerKap } from "./kap-canvas"; import type { WeeklyRecap } from "./weekly-recap"; /** The weekly recap as a 1200×630 image, in the same split style as the link cards. */ @@ -69,6 +70,7 @@ export async function createRecapCard(recap: WeeklyRecap, player: { name?: strin context.fillStyle = COAL; context.font = `500 28px ${FONT}`; context.fillText("Codey", 110, 562); + await drawPlayerKap(context, 322, 454, 124); // Dark side: who, the days, the numbers. context.fillStyle = INK; diff --git a/src/lib/streak.ts b/src/lib/streak.ts index 7a77a9b..a88e344 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -1,4 +1,7 @@ import type { StreakData } from "@/utils/storage"; +import { FLAME_TIERS, tierFor, type FlameTier } from "@/utils/flame-tiers"; + +export { FLAME_TIERS, tierFor, type FlameTier }; /** * Streak state for Kap, the keycap mascot: whether today is done, how big the @@ -8,35 +11,8 @@ import type { StreakData } from "@/utils/storage"; export type StreakMood = "sleep" | "risk" | "lit" | "frozen"; -export interface FlameTier { - id: "spark" | "flame" | "blaze" | "blue" | "legend"; - name: string; - /** Days needed to reach this flame. */ - min: number; - /** Flame height relative to the "blaze" flame. */ - scale: number; - flame: string; - core: string; - /** Keycap colours when lit: top face, front skirt, sides and arms. */ - cap: [string, string, string]; - crown?: boolean; -} - -export const FLAME_TIERS: FlameTier[] = [ - { id: "spark", name: "Spark", min: 1, scale: 0.6, flame: "#f97316", core: "#fed7aa", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "flame", name: "Flame", min: 3, scale: 0.8, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "blaze", name: "Blaze", min: 7, scale: 1, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "blue", name: "Blue fire", min: 30, scale: 1.08, flame: "#3b82f6", core: "#dbeafe", cap: ["#e0f2fe", "#7dd3fc", "#0284c7"] }, - { id: "legend", name: "Legend", min: 100, scale: 1.2, flame: "#a855f7", core: "#f3e8ff", cap: ["#ede9fe", "#c4b5fd", "#8b5cf6"], crown: true }, -]; - export const STREAK_MILESTONES = [3, 7, 14, 30, 50, 100, 200, 365, 500, 1000]; -export function tierFor(days: number): FlameTier | null { - let tier: FlameTier | null = null; - for (const candidate of FLAME_TIERS) if (days >= candidate.min) tier = candidate; - return tier; -} export function dateKey(date: Date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; diff --git a/src/utils/flame-tiers.ts b/src/utils/flame-tiers.ts new file mode 100644 index 0000000..e0b9e62 --- /dev/null +++ b/src/utils/flame-tiers.ts @@ -0,0 +1,29 @@ +/** Kap's flame by streak length. Shared by the app and the share-card server, so no path aliases. */ + +export interface FlameTier { + id: "spark" | "flame" | "blaze" | "blue" | "legend"; + name: string; + /** Days needed to reach this flame. */ + min: number; + /** Flame height relative to the "blaze" flame. */ + scale: number; + flame: string; + core: string; + /** Keycap colours when lit: top face, front skirt, sides and arms. */ + cap: [string, string, string]; + crown?: boolean; +} + +export const FLAME_TIERS: FlameTier[] = [ + { id: "spark", name: "Spark", min: 1, scale: 0.6, flame: "#f97316", core: "#fed7aa", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "flame", name: "Flame", min: 3, scale: 0.8, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "blaze", name: "Blaze", min: 7, scale: 1, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "blue", name: "Blue fire", min: 30, scale: 1.08, flame: "#3b82f6", core: "#dbeafe", cap: ["#e0f2fe", "#7dd3fc", "#0284c7"] }, + { id: "legend", name: "Legend", min: 100, scale: 1.2, flame: "#a855f7", core: "#f3e8ff", cap: ["#ede9fe", "#c4b5fd", "#8b5cf6"], crown: true }, +]; + +export function tierFor(days: number): FlameTier | null { + let tier: FlameTier | null = null; + for (const candidate of FLAME_TIERS) if (days >= candidate.min) tier = candidate; + return tier; +} diff --git a/src/utils/kap-art.ts b/src/utils/kap-art.ts new file mode 100644 index 0000000..3444477 --- /dev/null +++ b/src/utils/kap-art.ts @@ -0,0 +1,213 @@ +/** + * Kap as SVG markup: the wardrobe catalog, each accessory, and a static + * "lit" Kap for share cards. The React mascot draws accessories from these + * same strings, so the app and the server-rendered cards always match. + * Shared with the API, so no path aliases. + */ +import type { FlameTier } from "./flame-tiers.js"; + +export type KapColorId = "amber" | "retro" | "ocean" | "sakura" | "matcha" | "graphite" | "obsidian"; +export type KapHeadId = "headphones" | "party" | "halo"; +export type KapEyesId = "shades" | "nerd" | "monocle"; +export type KapWearId = "scarf" | "headband" | "cape"; +export type WardrobeSlot = "color" | "head" | "eyes" | "wear"; + +export interface KapLook { + color?: KapColorId; + head?: KapHeadId | null; + eyes?: KapEyesId | null; + wear?: KapWearId | null; +} + +export interface KapColorway { + id: KapColorId; + name: string; + /** Top face, front skirt, sides and arms; undefined keeps the flame tier's. */ + cap?: [string, string, string]; + /** Face lines, for dark caps. */ + ink?: string; +} + +export const KAP_INK = "#1c1917"; + +export const KAP_COLORS: KapColorway[] = [ + { id: "amber", name: "Amber" }, + { id: "retro", name: "Retro beige", cap: ["#f5f0e1", "#e3dac3", "#b3a78b"] }, + { id: "ocean", name: "Ocean", cap: ["#cffafe", "#67e8f9", "#0891b2"] }, + { id: "sakura", name: "Sakura", cap: ["#fce7f3", "#f9a8d4", "#db2777"] }, + { id: "matcha", name: "Matcha", cap: ["#ecfccb", "#bef264", "#65a30d"] }, + { id: "graphite", name: "Graphite", cap: ["#e4e4e7", "#a1a1aa", "#52525b"] }, + { id: "obsidian", name: "Obsidian", cap: ["#52525b", "#3f3f46", "#18181b"], ink: "#fafafa" }, +]; + +export function colorway(id: KapColorId | undefined): KapColorway { + return KAP_COLORS.find((color) => color.id === id) ?? KAP_COLORS[0]; +} + +export interface WardrobeItem { + slot: WardrobeSlot; + id: KapColorId | KapHeadId | KapEyesId | KapWearId; + name: string; + /** Achievement id that unlocks it; null is free. */ + requires: string | null; +} + +const COLOR_UNLOCKS: Record = { + amber: null, + retro: "runs-1", + ocean: "streak-2", + sakura: "streak-3", + matcha: "runs-2", + graphite: "ranked-2", + obsidian: "time-3", +}; + +export const WARDROBE: WardrobeItem[] = [ + ...KAP_COLORS.map((color) => ({ slot: "color" as const, id: color.id, name: color.name, requires: COLOR_UNLOCKS[color.id] })), + { slot: "head", id: "headphones", name: "Headphones", requires: "time-2" }, + { slot: "head", id: "party", name: "Party hat", requires: "duel-1" }, + { slot: "head", id: "halo", name: "Halo", requires: "flawless" }, + { slot: "eyes", id: "shades", name: "Pixel shades", requires: "speed-3" }, + { slot: "eyes", id: "nerd", name: "Nerd glasses", requires: "polyglot-3" }, + { slot: "eyes", id: "monocle", name: "Monocle", requires: "daily-2" }, + { slot: "wear", id: "scarf", name: "Scarf", requires: "streak-1" }, + { slot: "wear", id: "headband", name: "Headband", requires: "streak-4" }, + { slot: "wear", id: "cape", name: "Cape", requires: "speed-4" }, +]; + +export const isUnlocked = (item: WardrobeItem, earned: ReadonlySet) => item.requires === null || earned.has(item.requires); + +const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; + +/** Keeps only known items that are unlocked, so a stale or synced look never shows locked gear. */ +export function sanitizeLook(value: unknown, earned: ReadonlySet): KapLook { + const raw = (value && typeof value === "object" ? value : {}) as Record; + const look: KapLook = {}; + for (const slot of SLOTS) { + const item = WARDROBE.find((candidate) => candidate.slot === slot && candidate.id === raw[slot]); + if (item && isUnlocked(item, earned)) (look as Record)[slot] = item.id; + } + return look; +} + +/* ---- Accessories, in the 240×258 artwork space ---- */ + +/** Drawn before the body: only the parts outside the keycap show. */ +export function behindMarkup(look: KapLook): string { + let out = ""; + if (look.wear === "cape") { + out += '' + + '' + + ''; + } + if (look.head === "halo") { + out += '' + + ''; + } + return out; +} + +/** Drawn over the face and flame. */ +export function frontMarkup(look: KapLook, ink: string): string { + let out = ""; + if (look.wear === "scarf") { + out += '' + + '' + + '' + + ''; + } + if (look.wear === "headband") { + out += '' + + '' + + '' + + '' + + ''; + } + if (look.eyes === "shades") { + // Pixel shades, drawn on a 4px grid. + out += '' + + '' + + ''; + } + if (look.eyes === "nerd") { + out += `` + + '' + + '' + + '' + + ''; + } + if (look.eyes === "monocle") { + out += '' + + ''; + } + if (look.head === "headphones") { + const band = "M47 128 C 38 -6, 202 -6, 193 128"; + out += `` + + `` + + '' + + '' + + '' + + ''; + } + if (look.head === "party") { + out += '' + + '' + + '' + + '' + + ''; + } + return out; +} + +/* ---- A static, happy Kap for share cards ---- */ + +function flame(cx: number, base: number, scale: number, outer: string, inner: string) { + const h = 78 * scale; + const w = 40 * scale; + return `` + + ``; +} + +const TOP_FACE = "M80 76 L160 76 Q170 76 171 86 L174 122 Q174 132 164 132 L76 132 Q66 132 66 122 L69 86 Q70 76 80 76 Z"; + +/** Kap with arms up and the flame burning, as a standalone SVG document. */ +export function kapSvg(tier: FlameTier, look: KapLook = {}, width = 240): string { + const paint = colorway(look.color); + const [top, front, side] = paint.cap ?? tier.cap; + const ink = paint.ink ?? KAP_INK; + const s = tier.scale; + const cx = 120; + const y = 156; + const crown = tier.crown ? `` : ""; + const eyes = tier.crown + ? [cx - 22, cx + 22].map((x) => ``).join("") + : ``; + const body = [ + behindMarkup(look), + '', + '', + ``, + ``, + ``, + ``, + ``, + ``, + ``, + flame(120, 108, s, tier.flame, tier.core), + crown, + eyes, + ``, + ``, + frontMarkup(look, ink), + ].join(""); + const height = Math.round((width * 258) / 240); + return `` + + '' + + `${body}`; +} + +export function kapDataUri(tier: FlameTier, look: KapLook = {}, width = 240): string { + const svg = kapSvg(tier, look, width); + const base64 = typeof btoa === "function" ? btoa(svg) : Buffer.from(svg).toString("base64"); + return `data:image/svg+xml;base64,${base64}`; +} diff --git a/src/utils/share-card-version.ts b/src/utils/share-card-version.ts index 269f638..3a06eea 100644 --- a/src/utils/share-card-version.ts +++ b/src/utils/share-card-version.ts @@ -2,4 +2,4 @@ * Version of the server-drawn share card (/api/og/). Bump it whenever * the card's design changes: platforms and browsers cache the image by URL. */ -export const CARD_VERSION = 4; +export const CARD_VERSION = 5;