diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index c8138c8..2cefe08 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { BarChart3, + Shirt, CalendarDays, Check, CornerDownLeft, @@ -31,6 +32,7 @@ import { useFlowRadio } from "./RadioProvider"; import { useTheme } from "./ThemeProvider"; import { THEMES } from "./ThemeStudioModal"; import { openKeycapStudio } from "./KeycapStudioModal"; +import { openKapWardrobe } from "./streak/KapWardrobe"; import { EDITOR_KEYCAPS, KEYCAP_COLORWAYS } from "@/lib/keycaps"; import { cn } from "@/lib/utils"; @@ -165,6 +167,7 @@ export function CommandPalette() { run: () => setPreference("editorTheme", option.id), })), { id: "keycap-studio", group: "Appearance", label: "Keycap Studio", keywords: "customize paint keyboard 3d colors", icon: Keyboard, run: openKeycapStudio }, + { id: "kap-wardrobe", group: "Appearance", label: "Kap's wardrobe", keywords: "mascot skins streak outfit hat glasses", icon: Shirt, run: openKapWardrobe }, { id: "keycaps-editor", group: "Appearance", label: "Keycaps: Editor theme", keywords: "colorway keyboard", icon: Keyboard, state: preferences.keycapTheme === EDITOR_KEYCAPS ? "active" : undefined, run: () => setPreference("keycapTheme", EDITOR_KEYCAPS) }, ...KEYCAP_COLORWAYS.map((option): Command => ({ id: `keycaps-${option.id}`, diff --git a/src/components/achievements/AchievementToaster.tsx b/src/components/achievements/AchievementToaster.tsx index a9ad81a..b942f06 100644 --- a/src/components/achievements/AchievementToaster.tsx +++ b/src/components/achievements/AchievementToaster.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { X } from "lucide-react"; +import { Shirt, X } from "lucide-react"; import { ACHIEVEMENT_EVENT, type UnlockedBadge } from "@/lib/achievements"; +import { itemsUnlockedBy } from "@/lib/kap-wardrobe"; +import { openKapWardrobe } from "@/components/streak/KapWardrobe"; import { AchievementBadge } from "./Badge"; const SHOW_MS = 5200; @@ -47,6 +49,11 @@ export function AchievementToaster() {

Achievement unlocked

{current.name}

{current.goal}

+ {itemsUnlockedBy(current.id).length > 0 && ( + + )} setQueue((items) => items.slice(1))} className="mt-1 inline-block text-xs font-semibold underline-offset-2 hover:underline"> See all achievements diff --git a/src/components/streak/KapMascot.tsx b/src/components/streak/KapMascot.tsx index 30e4eb2..d591b0c 100644 --- a/src/components/streak/KapMascot.tsx +++ b/src/components/streak/KapMascot.tsx @@ -1,6 +1,7 @@ import { useId } from "react"; import { cn } from "@/lib/utils"; import type { FlameTier, StreakMood } from "@/lib/streak"; +import { colorway, KapBehind, KapFront, type KapLook } from "./kap-skins"; /** * Kap, the streak mascot: an SA-profile keycap on MX-stem feet with a flame @@ -18,6 +19,8 @@ interface KapMascotProps { jump?: boolean; className?: string; title?: string; + /** Colourway and accessories from Kap's wardrobe. */ + look?: KapLook; } const INK = "#1c1917"; @@ -36,11 +39,11 @@ function flamePaths(cx: number, base: number, scale: number, outer: string, inne ); } -function Star({ x, y }: { x: number; y: number }) { - return ; +function Star({ x, y, ink }: { x: number; y: number; ink: string }) { + return ; } -function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { +function Face({ mood, crown, ink: INK }: { mood: StreakMood; crown: boolean; ink: string }) { const cx = 120; const y = 156; const smile = ( @@ -64,8 +67,8 @@ function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { {(mood === "lit" && !crown) || mood === "frozen" ? : null} {mood === "lit" && crown && ( <> - - + + )} @@ -84,9 +87,11 @@ function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { ); } -export function KapMascot({ mood, tier, size = 120, animate = true, jump = false, className, title }: KapMascotProps) { +export function KapMascot({ mood, tier, size = 120, animate = true, jump = false, className, title, look = {} }: KapMascotProps) { const id = useId().replace(/:/g, ""); - const cap = mood === "sleep" ? SLEEP_CAP : mood === "risk" ? RISK_CAP : mood === "frozen" ? ICE_CAP : tier?.cap ?? SLEEP_CAP; + const paint = colorway(look.color); + const cap = mood === "sleep" ? SLEEP_CAP : mood === "risk" ? RISK_CAP : mood === "frozen" ? ICE_CAP : paint.cap ?? tier?.cap ?? SLEEP_CAP; + const ink = mood === "lit" && paint.ink ? paint.ink : INK; const [top, front, side] = cap; // At risk, the flame shrinks to an ember of its usual colour. const flameScale = mood === "sleep" || !tier ? 0 : mood === "risk" || mood === "frozen" ? Math.min(0.55, tier.scale * 0.6) : tier.scale; @@ -112,6 +117,7 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false + @@ -138,7 +144,8 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false )} {crown && } - + + {mood === "frozen" && ( {/* An ice-blue scarf, tied at the side with a snowflake. */} diff --git a/src/components/streak/KapWardrobe.tsx b/src/components/streak/KapWardrobe.tsx new file mode 100644 index 0000000..d467226 --- /dev/null +++ b/src/components/streak/KapWardrobe.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Lock, Shirt, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { FLAME_TIERS } from "@/lib/streak"; +import { useStreak } from "@/hooks/useStreak"; +import { useKapLook } from "@/hooks/useKapLook"; +import { earnedBadges, isUnlocked, SLOT_NAMES, toggleItem, unlockHint, WARDROBE, writeLook, type WardrobeItem, type WardrobeSlot } from "@/lib/kap-wardrobe"; +import { KapMascot } from "./KapMascot"; +import type { KapLook } from "./kap-skins"; + +export const OPEN_WARDROBE_EVENT = "codey:open-wardrobe"; + +export function openKapWardrobe() { + window.dispatchEvent(new Event(OPEN_WARDROBE_EVENT)); +} + +const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; + +function lookWith(item: WardrobeItem): KapLook { + return item.slot === "color" ? { color: item.id as KapLook["color"] } : { [item.slot]: item.id }; +} + +/** Dress Kap: every unlocked item, and what the locked ones take. Mounted once, opened by event. */ +export function KapWardrobe() { + const [open, setOpen] = useState(false); + const look = useKapLook(); + const status = useStreak(); + const [earned, setEarned] = useState>(() => new Set()); + + useEffect(() => { + const show = () => { + setEarned(earnedBadges()); + setOpen(true); + }; + window.addEventListener(OPEN_WARDROBE_EVENT, show); + return () => window.removeEventListener(OPEN_WARDROBE_EVENT, show); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + const tier = status.tier ?? FLAME_TIERS[1]; + const unlockedCount = useMemo(() => WARDROBE.filter((item) => isUnlocked(item, earned)).length, [earned]); + if (!open) return null; + + const worn = (item: WardrobeItem) => (look as Record)[item.slot] === item.id || (item.slot === "color" && !look.color && item.id === "amber"); + + return ( +
{ if (event.target === event.currentTarget) setOpen(false); }} onKeyDown={(event) => event.stopPropagation()}> +
+
+
+ +

Kap's wardrobe

+

{unlockedCount} of {WARDROBE.length} unlocked

+
+
+
+
+
+
+

Dress Kap

+ +
+ {SLOTS.map((slot) => ( +
+

{SLOT_NAMES[slot]}

+
+ {WARDROBE.filter((item) => item.slot === slot).map((item) => { + const unlocked = isUnlocked(item, earned); + const on = unlocked && worn(item); + return ( + + ); + })} +
+
+ ))} +

Items unlock with achievements. Your look syncs to your account and shows wherever Kap does.

+
+
+
+ ); +} diff --git a/src/components/streak/StreakButton.tsx b/src/components/streak/StreakButton.tsx index 577984a..6d60430 100644 --- a/src/components/streak/StreakButton.tsx +++ b/src/components/streak/StreakButton.tsx @@ -1,3 +1,4 @@ +import { useKapLook } from "@/hooks/useKapLook"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { useStreak } from "@/hooks/useStreak"; @@ -6,6 +7,7 @@ import { StreakPanel } from "./StreakPanel"; /** Kap in the header: the streak at a glance, the full panel on click. */ export function StreakButton() { + const look = useKapLook(); const status = useStreak(); const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -44,7 +46,7 @@ export function StreakButton() { status.mood === "lit" ? "text-foreground" : "text-muted-foreground", )} > - + {status.current} {(status.mood === "risk" || status.mood === "frozen") && ( diff --git a/src/components/streak/StreakCelebration.tsx b/src/components/streak/StreakCelebration.tsx index 7db59f4..504db8f 100644 --- a/src/components/streak/StreakCelebration.tsx +++ b/src/components/streak/StreakCelebration.tsx @@ -1,3 +1,4 @@ +import { useKapLook } from "@/hooks/useKapLook"; import { useEffect, useState } from "react"; import { X } from "lucide-react"; import { STREAK_EVENT } from "@/utils/storage"; @@ -19,6 +20,7 @@ interface Celebration { /** Kap hops in when a run extends the streak; milestones get a bigger moment. */ export function StreakCelebration() { + const look = useKapLook(); const [shown, setShown] = useState(null); useEffect(() => { @@ -50,7 +52,7 @@ export function StreakCelebration() { return (
- +

{title}

{body}

diff --git a/src/components/streak/StreakPanel.tsx b/src/components/streak/StreakPanel.tsx index 8ee3aa8..57a2985 100644 --- a/src/components/streak/StreakPanel.tsx +++ b/src/components/streak/StreakPanel.tsx @@ -1,5 +1,7 @@ +import { useKapLook } from "@/hooks/useKapLook"; +import { openKapWardrobe } from "./KapWardrobe"; import { Link } from "react-router-dom"; -import { Check, Crown, Play, Snowflake } from "lucide-react"; +import { Check, Crown, Play, Shirt, Snowflake } from "lucide-react"; import { cn } from "@/lib/utils"; import { FLAME_TIERS, streakMessage, type StreakStatus } from "@/lib/streak"; import { KapMascot } from "./KapMascot"; @@ -18,6 +20,7 @@ function FlameGlyph({ color, core, className }: { color: string; core: string; c export function StreakPanel({ status, onNavigate }: { status: StreakStatus; onNavigate?: () => void }) { const message = streakMessage(status); const accent = status.tier?.flame ?? "#a1a1aa"; + const look = useKapLook(); return (
@@ -27,9 +30,19 @@ export function StreakPanel({ status, onNavigate }: { status: StreakStatus; onNa style={{ background: status.mood === "sleep" ? "transparent" : accent }} aria-hidden /> - +

{message.title}

{message.body}

+
diff --git a/src/components/streak/kap-skins.tsx b/src/components/streak/kap-skins.tsx new file mode 100644 index 0000000..bec583c --- /dev/null +++ b/src/components/streak/kap-skins.tsx @@ -0,0 +1,126 @@ +/** + * 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. + */ + +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 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. */ +export function KapBehind({ look }: { look: KapLook }) { + return ( + <> + {look.wear === "cape" && ( + + + + + + )} + {look.head === "halo" && ( + + + + + )} + + ); +} + +/** 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" && ( + + + + + + + )} + + ); +} diff --git a/src/hooks/useKapLook.ts b/src/hooks/useKapLook.ts new file mode 100644 index 0000000..8a08c83 --- /dev/null +++ b/src/hooks/useKapLook.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { SYNC_EVENT } from "@/lib/account-sync"; +import { ACHIEVEMENT_EVENT } from "@/lib/achievements"; +import { LOOK_EVENT, LOOK_KEY, readLook } from "@/lib/kap-wardrobe"; +import type { KapLook } from "@/components/streak/kap-skins"; + +/** The look Kap wears everywhere, kept current across tabs, devices and new badges. */ +export function useKapLook(): KapLook { + const [look, setLook] = useState(() => readLook()); + + useEffect(() => { + const refresh = () => setLook(readLook()); + const onStorage = (event: StorageEvent) => { + if (event.key === LOOK_KEY || event.key === null) refresh(); + }; + window.addEventListener(LOOK_EVENT, refresh); + window.addEventListener(ACHIEVEMENT_EVENT, refresh); + window.addEventListener(SYNC_EVENT, refresh); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(LOOK_EVENT, refresh); + window.removeEventListener(ACHIEVEMENT_EVENT, refresh); + window.removeEventListener(SYNC_EVENT, refresh); + window.removeEventListener("storage", onStorage); + }; + }, []); + + return look; +} diff --git a/src/lib/account-sync.ts b/src/lib/account-sync.ts index 95403ca..ccb34f8 100644 --- a/src/lib/account-sync.ts +++ b/src/lib/account-sync.ts @@ -57,6 +57,7 @@ export const SLICES: Slice[] = [ single("freezes", "codey_streak_freezes"), single("textLanguage", "codey_text_language"), single("reminder", "codey_streak_reminder"), + single("kapLook", "codey_kap_look"), { id: "sound", keys: ["codey_soundpack_id_v1", "codey_sound_volume_v1"], diff --git a/src/lib/kap-wardrobe.test.ts b/src/lib/kap-wardrobe.test.ts new file mode 100644 index 0000000..3f6c6b9 --- /dev/null +++ b/src/lib/kap-wardrobe.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { itemsUnlockedBy, isUnlocked, sanitizeLook, toggleItem, unlockHint, WARDROBE } from "./kap-wardrobe"; + +const item = (id: string) => WARDROBE.find((candidate) => candidate.id === id)!; + +describe("kap wardrobe", () => { + it("unlocks items by achievement, amber is free", () => { + expect(isUnlocked(item("amber"), new Set())).toBe(true); + expect(isUnlocked(item("shades"), new Set())).toBe(false); + expect(isUnlocked(item("shades"), new Set(["speed-3"]))).toBe(true); + }); + + it("every requirement is a real achievement with a goal", () => { + for (const entry of WARDROBE) if (entry.requires) expect(unlockHint(entry)).not.toBe("Keep practicing"); + expect(unlockHint(item("shades"))).toBe("Hit 100 WPM in a run"); + }); + + it("drops unknown and locked items from a stored look", () => { + const look = sanitizeLook({ color: "ocean", head: "halo", eyes: "laser", wear: "cape" }, new Set(["streak-2", "speed-4"])); + expect(look).toEqual({ color: "ocean", wear: "cape" }); + expect(sanitizeLook("junk", new Set())).toEqual({}); + }); + + it("toggles accessories but always keeps a colour", () => { + const on = toggleItem({}, item("party")); + expect(on.head).toBe("party"); + expect(toggleItem(on, item("party")).head).toBeNull(); + expect(toggleItem({ color: "ocean" }, item("ocean")).color).toBe("ocean"); + }); + + it("names what a badge unlocks", () => { + expect(itemsUnlockedBy("streak-4").map((entry) => entry.name)).toEqual(["Headband"]); + }); +}); diff --git a/src/lib/kap-wardrobe.ts b/src/lib/kap-wardrobe.ts new file mode 100644 index 0000000..2d909b0 --- /dev/null +++ b/src/lib/kap-wardrobe.ts @@ -0,0 +1,101 @@ +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"; + +/** + * 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"; + return describeBadge(item.requires)?.goal ?? "Keep practicing"; +} + +export function earnedBadges(): Set { + return new Set(Object.keys(readStore().unlockedAt)); +} + +/** Items a newly earned badge unlocks, for the achievement toast. */ +export function itemsUnlockedBy(badgeId: string): WardrobeItem[] { + return WARDROBE.filter((item) => item.requires === badgeId); +} + +/* ---- The chosen look ---- */ + +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); + } catch { + return {}; + } +} + +export function writeLook(look: KapLook) { + try { + localStorage.setItem(LOOK_KEY, JSON.stringify(look)); + } catch { + // Kept for this tab only. + } + window.dispatchEvent(new CustomEvent(LOOK_EVENT)); +} + +/** Puts an item on, or takes it off when it is already worn (colour always stays on). */ +export function toggleItem(look: KapLook, item: WardrobeItem): KapLook { + const current = (look as Record)[item.slot]; + if (item.slot === "color") return { ...look, color: item.id as KapColorId }; + return { ...look, [item.slot]: current === item.id ? null : item.id }; +} diff --git a/src/main.tsx b/src/main.tsx index 4a2f360..e962124 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import { AchievementToaster } from './components/achievements/AchievementToaster import { StreakCelebration } from './components/streak/StreakCelebration'; import { StreakReminder } from './components/streak/StreakReminder'; import { PwaPrompt } from './components/PwaPrompt'; +import { KapWardrobe } from './components/streak/KapWardrobe'; import './index.css'; const { Settings, History, Leaderboard, Profile, Donate, Contributors, Duel, Arcade, Daily, KeyboardAnalytics, Achievements } = Pages; @@ -55,6 +56,7 @@ createRoot(document.getElementById('root')!).render( +