diff --git a/src/App.tsx b/src/App.tsx index 94fcd4b..87d2a30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { uploadRun } from "@/lib/cloud"; import { canChallenge, challengeSnippet, getChallenge, type Challenge } from "@/lib/challenges"; import { isProse, loadTextCorpus, TEXT_LANGUAGE_NAMES, passageSnippet, pickPassage, wordsSnippet, type TextCorpus, type TextLanguage } from "@/lib/text-practice"; import { TextLanguagePicker } from "@/components/TextLanguagePicker"; +import { emitKap } from "@/lib/kap-events"; +import { KapBuddy } from "@/components/streak/KapBuddy"; import { markOnboarded, OnboardingDialog, shouldOnboard, type OnboardingChoice } from "@/components/OnboardingDialog"; import { ChallengeModeBanner, ChallengeResultBanner } from "@/components/ChallengeBanners"; import { useGame, useKeyboardSound, useGhostRunner, useRankedGame, useDailyGame } from "@/hooks"; @@ -567,6 +569,13 @@ export default function App() { } } + // Kap nods along with each key and jumps at a typo. + if ((e.key.length === 1 || e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey && !e.altKey) { + const expectedChar = snippet.code[input.length]; + const typed = e.key === "Enter" ? "\n" : e.key === "Tab" ? "\t" : e.key; + emitKap({ type: "key", error: typed === "\t" ? expectedChar !== "\t" && expectedChar !== " " : typed !== expectedChar }); + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); if (ranked.isRanked) ranked.recordKeypress(); @@ -806,6 +815,7 @@ export default function App() {
+ {!result && !editorFocusMode && !showOnboarding && } {result && daily.active && } {result && activeChallenge && } diff --git a/src/components/ResultsScreen.tsx b/src/components/ResultsScreen.tsx index a9f0e8a..f7cee88 100644 --- a/src/components/ResultsScreen.tsx +++ b/src/components/ResultsScreen.tsx @@ -13,6 +13,12 @@ import { rankRejectionReason, describeRankRejection } from "@/utils/ranking"; import type { RankedStatus } from "@/hooks/useRankedGame"; import { cn } from "@/lib/utils"; import { PROSE_LANGUAGES } from "@/lib/text-practice"; +import { Kap3D } from "@/components/streak/Kap3D"; +import { KapMascot } from "@/components/streak/KapMascot"; +import { useKapLook } from "@/hooks/useKapLook"; +import { useStreak } from "@/hooks/useStreak"; +import { celebrationFor, celebrationLine } from "@/lib/kap-events"; +import { FLAME_TIERS } from "@/lib/streak"; interface ResultsScreenProps { result: RunResult; @@ -58,12 +64,31 @@ export function ResultsScreen({ const rejection = rankRejectionReason(result); const tier = resultTier(result.wpm); + const kapLook = useKapLook(); + const streak = useStreak(); + const kapTier = streak.tier ?? FLAME_TIERS[1]; + // Decided once per result, so a later re-render (e.g. Ranked verification) does not re-cheer. + const [cheer] = useState(() => celebrationFor(result, previousBest?.bestWpm ?? null, result.sourceType === "custom")); const username = user ? githubUsernameFromUser(user) : undefined; return (
{/* Title & Badge */}
+
+ } + /> +

+ {celebrationLine(cheer, result)} +

+
(null); const [state, setState] = useState<"loading" | "ready" | "failed">("loading"); - const key = JSON.stringify({ mood, tier: tier?.id ?? null, look, typing }); + const key = JSON.stringify({ mood, tier: tier?.id ?? null, look, listen, celebrate: celebrate ?? null }); useEffect(() => { const host = hostRef.current; if (!host) return; let scene: KapScene | null = null; let cancelled = false; - const options = JSON.parse(key) as { mood: StreakMood; look: KapLook; typing: boolean }; + const options = JSON.parse(key) as { mood: StreakMood; look: KapLook; listen: boolean; celebrate: CelebrationLevel | null }; + let cheer = 0; void import("./KapScene") .then(({ KapScene }) => { if (cancelled) return; try { - scene = new KapScene(host, { ...options, tier }); + scene = new KapScene(host, { mood: options.mood, look: options.look, listen: options.listen, tier }); setState("ready"); + const level = options.celebrate; + if (level) cheer = window.setTimeout(() => scene?.react({ type: "celebrate", level }), 450); } catch { setState("failed"); } @@ -42,6 +48,7 @@ export function Kap3D({ mood, tier, look, height, typing = false, fallback, clas .catch(() => setState("failed")); return () => { cancelled = true; + window.clearTimeout(cheer); scene?.dispose(); }; // The key covers every input; tier is read through it. diff --git a/src/components/streak/KapBuddy.tsx b/src/components/streak/KapBuddy.tsx new file mode 100644 index 0000000..f58649f --- /dev/null +++ b/src/components/streak/KapBuddy.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { useKapLook } from "@/hooks/useKapLook"; +import { useStreak } from "@/hooks/useStreak"; +import { BUDDY_EVENT, BUDDY_KEY, readBuddy, writeBuddy } from "@/lib/kap-buddy"; +import { SYNC_EVENT } from "@/lib/account-sync"; +import { FLAME_TIERS } from "@/lib/streak"; +import { Kap3D } from "./Kap3D"; + +/** Kap in the corner while you practice: nods along, stokes his flame, jumps at typos. */ +export function KapBuddy() { + const [on, setOn] = useState(readBuddy); + const look = useKapLook(); + const status = useStreak(); + + useEffect(() => { + const refresh = () => setOn(readBuddy()); + const onStorage = (event: StorageEvent) => event.key === BUDDY_KEY && refresh(); + window.addEventListener(BUDDY_EVENT, refresh); + window.addEventListener(SYNC_EVENT, refresh); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(BUDDY_EVENT, refresh); + window.removeEventListener(SYNC_EVENT, refresh); + window.removeEventListener("storage", onStorage); + }; + }, []); + + if (!on) return null; + return ( +
+ + +
+ ); +} diff --git a/src/components/streak/KapScene.ts b/src/components/streak/KapScene.ts index 3e9346d..a39d3b4 100644 --- a/src/components/streak/KapScene.ts +++ b/src/components/streak/KapScene.ts @@ -3,6 +3,7 @@ import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeom import type { FlameTier } from "@/utils/flame-tiers"; import { colorway, KAP_INK, type KapLook } from "@/utils/kap-art"; import type { StreakMood } from "@/lib/streak"; +import { KAP_EVENT, type KapEvent } from "@/lib/kap-events"; /** * Kap in 3D: an SA keycap (a tapered, dished rounded box) on MX-stem feet, @@ -14,8 +15,8 @@ export interface KapSceneOptions { mood: StreakMood; tier: FlameTier | null; look: KapLook; - /** React to keystrokes anywhere on the page. */ - typing?: boolean; + /** React to typing and results through the "codey:kap" events. */ + listen?: boolean; } const SLEEP_CAP: [string, string, string] = ["#e4e4e7", "#c4c4cc", "#9f9fa9"]; @@ -64,6 +65,14 @@ export class KapScene { private hop = 0; private nod = 0; private heat = 0; + /** 0-1: a typo just startled him. */ + private shock = 0; + private hopsLeft = 0; + /** Extra turn still to spin, for a celebration pirouette. */ + private twirl = 0; + private exclaim: THREE.Group | null = null; + private confetti: THREE.InstancedMesh | null = null; + private confettiState: Array<{ position: THREE.Vector3; velocity: THREE.Vector3; spin: THREE.Vector3; rotation: THREE.Euler; life: number }> = []; private nextBlink = 2; private blink = 0; @@ -106,7 +115,7 @@ export class KapScene { canvas.addEventListener("pointerdown", this.handlePointerDown); window.addEventListener("pointermove", this.handlePointerMove); window.addEventListener("pointerup", this.handlePointerUp); - if (options.typing) window.addEventListener("keydown", this.handleKey); + if (options.listen) window.addEventListener(KAP_EVENT, this.handleKapEvent); this.loop(); } @@ -209,6 +218,61 @@ export class KapScene { this.buildFace(ink, mood); if (tier && mood !== "sleep") this.buildFlame(tier, mood === "lit" ? tier.scale : Math.min(0.55, tier.scale * 0.6)); this.buildLook(look, ink); + this.buildExclaim(); + } + + /** A red "!" that pops over his head when a typo startles him. */ + private buildExclaim() { + const red = this.material("#ef4444", { emissive: "#ef4444", emissiveIntensity: 0.5 }); + const group = new THREE.Group(); + this.mesh(new THREE.CapsuleGeometry(0.07, 0.28, 4, 10), red, group).position.y = 0.3; + this.mesh(new THREE.SphereGeometry(0.08, 12, 10), red, group).position.y = 0; + group.position.set(0.75, LIFT + HEIGHT + 0.55, 0.3); + group.visible = false; + this.root.add(group); + this.exclaim = group; + } + + private burstConfetti() { + if (this.reducedMotion) return; + if (!this.confetti) { + const colors = ["#f59e0b", "#f472b6", "#38bdf8", "#4ade80", "#facc15", "#a78bfa"]; + const mesh = new THREE.InstancedMesh(this.track(new THREE.PlaneGeometry(0.09, 0.14)), this.track(new THREE.MeshBasicMaterial({ side: THREE.DoubleSide })), 70); + for (let index = 0; index < 70; index += 1) mesh.setColorAt(index, new THREE.Color(colors[index % colors.length])); + this.scene.add(mesh); + this.confetti = mesh; + } + this.confettiState = Array.from({ length: 70 }, () => ({ + position: new THREE.Vector3((Math.random() - 0.5) * 0.4, LIFT + HEIGHT + 0.4, (Math.random() - 0.5) * 0.4), + velocity: new THREE.Vector3((Math.random() - 0.5) * 3.2, 2.4 + Math.random() * 2.4, (Math.random() - 0.5) * 2), + spin: new THREE.Vector3(Math.random() * 8, Math.random() * 8, Math.random() * 8), + rotation: new THREE.Euler(Math.random() * 6, Math.random() * 6, Math.random() * 6), + life: 2.2 + Math.random() * 0.8, + })); + } + + private updateConfetti(dt: number) { + if (!this.confetti || !this.confettiState.length) return; + const matrix = new THREE.Matrix4(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + let alive = 0; + this.confettiState.forEach((piece, index) => { + piece.life -= dt; + piece.velocity.y -= 5.5 * dt; + piece.velocity.multiplyScalar(0.985); + piece.position.addScaledVector(piece.velocity, dt); + piece.rotation.x += piece.spin.x * dt; + piece.rotation.y += piece.spin.y * dt; + piece.rotation.z += piece.spin.z * dt; + const visible = piece.life > 0 && piece.position.y > 0; + if (visible) alive += 1; + scale.setScalar(visible ? Math.min(1, piece.life) : 0); + matrix.compose(piece.position, quaternion.setFromEuler(piece.rotation), scale); + this.confetti!.setMatrixAt(index, matrix); + }); + this.confetti.instanceMatrix.needsUpdate = true; + if (!alive) this.confettiState = []; } private buildFace(ink: THREE.Material, mood: StreakMood) { @@ -386,12 +450,35 @@ export class KapScene { this.renderer.domElement.style.cursor = "grab"; }; - private handleKey = (event: KeyboardEvent) => { - if (event.repeat || event.metaKey || event.ctrlKey || event.key.length > 1 && event.key !== "Backspace" && event.key !== "Enter") return; - this.nod = 1; - this.heat = Math.min(1, this.heat + 0.08); + private handleKapEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) this.react(detail); }; + /** Keystrokes nod him along and stoke the flame; a typo startles him; results make him cheer. */ + react(event: KapEvent) { + if (event.type === "key") { + if (event.error) { + this.shock = 1; + this.heat = Math.max(0, this.heat - 0.25); + } else { + this.nod = 1; + this.heat = Math.min(1, this.heat + 0.06); + } + return; + } + if (event.type === "celebrate") { + this.heat = 1; + this.shock = 0; + this.hopsLeft = event.level === "pb" ? 3 : event.level === "good" ? 2 : 1; + if (event.level === "pb") { + this.twirl = Math.PI * 2; + this.burstConfetti(); + } + this.jump(); + } + } + /** A happy hop, e.g. when the streak goes up. */ jump() { if (this.hop <= 0) this.hop = 1; @@ -427,13 +514,20 @@ export class KapScene { this.spinVelocity *= 0.9; if (Math.abs(this.spinVelocity) < 0.002) this.spin += (0.35 - this.spin) * Math.min(1, dt * 1.6); } - this.root.rotation.y = this.spin + (calm ? 0 : this.pointer.x * 0.12); + // A celebration pirouette on top of wherever he faces. + if (this.twirl > 0) this.twirl = Math.max(0, this.twirl - dt * 7); + this.root.rotation.y = this.spin + (calm ? 0 : this.pointer.x * 0.12) + (Math.PI * 2 - this.twirl) % (Math.PI * 2); // Hop with squash and stretch. let lift = 0; let squash = 1; + if (this.hop <= 0 && this.hopsLeft > 1) { + this.hopsLeft -= 1; + this.hop = 1; + } if (this.hop > 0) { this.hop = Math.max(0, this.hop - dt * 1.9); + if (this.hop === 0 && this.hopsLeft === 1) this.hopsLeft = 0; const t = 1 - this.hop; lift = Math.sin(Math.PI * Math.min(1, t * 1.15)) * 0.7; squash = t < 0.12 ? 1 - Math.sin((t / 0.12) * Math.PI) * 0.16 : t > 0.88 ? 1 - Math.sin(((t - 0.88) / 0.12) * Math.PI) * 0.12 : 1 + lift * 0.08; @@ -441,15 +535,25 @@ export class KapScene { const breathe = calm ? 0 : Math.sin(this.time * 2.2) * 0.015; this.nod = Math.max(0, this.nod - dt * 7); this.heat = Math.max(0, this.heat - dt * 0.12); - this.body.position.y = lift - this.nod * 0.05; + // A typo: he jolts back and shivers for a moment. + this.shock = Math.max(0, this.shock - dt * 2.2); + const jolt = Math.sin(Math.min(1, (1 - this.shock) * 4) * Math.PI) * this.shock; + this.body.position.y = lift - this.nod * 0.05 + jolt * 0.12; + this.body.position.x = calm ? 0 : Math.sin(this.time * 70) * 0.025 * this.shock; this.body.scale.set(1 / Math.sqrt(squash), squash + breathe, 1 / Math.sqrt(squash)); - this.body.rotation.x = -this.pointer.y * 0.08 + this.nod * 0.1; + this.body.rotation.x = -this.pointer.y * 0.08 + this.nod * 0.1 - this.shock * 0.22; + if (this.exclaim) { + this.exclaim.visible = this.shock > 0.05; + this.exclaim.scale.setScalar(0.6 + Math.min(1, (1 - this.shock) * 6) * 0.4); + } this.shadow.scale.set(1 - lift * 0.35, 0.55 * (1 - lift * 0.35), 1); // Arms wave when happy, swing when hopping. this.arms.forEach((arm, index) => { const side = index === 0 ? 1 : -1; - arm.rotation.z = (arm.userData.base as number) + (calm ? 0 : Math.sin(this.time * 3 + index) * 0.12 * side) + side * lift * 0.5; + const cheer = this.hopsLeft > 0 ? Math.sin(this.time * 14) * 0.35 * side : 0; + const startled = this.shock * side * 0.9; + arm.rotation.z = (arm.userData.base as number) + (calm ? 0 : Math.sin(this.time * 3 + index) * 0.12 * side) + side * lift * 0.5 + cheer - startled; }); // Eyes follow the pointer; a blink every few seconds. @@ -461,7 +565,13 @@ export class KapScene { } this.blink = Math.max(0, this.blink - dt * 7); const open = this.options.mood === "sleep" ? 1 : 1 - Math.sin(this.blink * Math.PI) * 0.9; - for (const eye of this.eyes) if (this.options.mood !== "sleep") eye.scale.y = 1.25 * open; + // Wide eyes when startled. + const wide = 1 + this.shock * 0.55; + for (const eye of this.eyes) { + if (this.options.mood === "sleep") continue; + eye.scale.y = 1.25 * (this.shock > 0.05 ? 1 : open) * wide; + eye.scale.x = wide; + } // The flame flickers, and burns brighter while you type. if (this.flame) { @@ -472,6 +582,7 @@ export class KapScene { if (this.flameLight) this.flameLight.intensity = (1.6 + this.heat * 2.4) * flicker; } + this.updateConfetti(dt); this.renderer.render(this.scene, this.camera); }; @@ -482,7 +593,7 @@ export class KapScene { this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDown); window.removeEventListener("pointermove", this.handlePointerMove); window.removeEventListener("pointerup", this.handlePointerUp); - window.removeEventListener("keydown", this.handleKey); + window.removeEventListener(KAP_EVENT, this.handleKapEvent); for (const item of this.disposables) item.dispose(); this.renderer.dispose(); this.renderer.domElement.remove(); diff --git a/src/components/streak/KapWardrobe.tsx b/src/components/streak/KapWardrobe.tsx index 6711f34..9fdccd1 100644 --- a/src/components/streak/KapWardrobe.tsx +++ b/src/components/streak/KapWardrobe.tsx @@ -6,6 +6,7 @@ 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 { readBuddy, writeBuddy } from "@/lib/kap-buddy"; import { Kap3D } from "./Kap3D"; import type { KapLook } from "./kap-skins"; @@ -27,10 +28,12 @@ export function KapWardrobe() { const look = useKapLook(); const status = useStreak(); const [earned, setEarned] = useState>(() => new Set()); + const [buddy, setBuddy] = useState(readBuddy); useEffect(() => { const show = () => { setEarned(earnedBadges()); + setBuddy(readBuddy()); setOpen(true); }; window.addEventListener(OPEN_WARDROBE_EVENT, show); @@ -103,6 +106,13 @@ export function KapWardrobe() {
))} +

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

diff --git a/src/lib/account-sync.ts b/src/lib/account-sync.ts index ccb34f8..4e25abb 100644 --- a/src/lib/account-sync.ts +++ b/src/lib/account-sync.ts @@ -58,6 +58,7 @@ export const SLICES: Slice[] = [ single("textLanguage", "codey_text_language"), single("reminder", "codey_streak_reminder"), single("kapLook", "codey_kap_look"), + single("kapBuddy", "codey_kap_buddy"), { id: "sound", keys: ["codey_soundpack_id_v1", "codey_sound_volume_v1"], diff --git a/src/lib/kap-buddy.ts b/src/lib/kap-buddy.ts new file mode 100644 index 0000000..654fefc --- /dev/null +++ b/src/lib/kap-buddy.ts @@ -0,0 +1,25 @@ +/** Whether Kap keeps you company in the corner of the practice screen. */ + +export const BUDDY_KEY = "codey_kap_buddy"; +export const BUDDY_EVENT = "codey:kap-buddy"; + +/** On by default where there is room beside the editor (wide screens). */ +export function readBuddy(): boolean { + try { + const saved = localStorage.getItem(BUDDY_KEY); + if (saved === "on") return true; + if (saved === "off") return false; + } catch { + // Fall through to the default. + } + return typeof window !== "undefined" && window.matchMedia?.("(min-width: 1280px)").matches === true; +} + +export function writeBuddy(on: boolean) { + try { + localStorage.setItem(BUDDY_KEY, on ? "on" : "off"); + } catch { + // This tab only. + } + window.dispatchEvent(new CustomEvent(BUDDY_EVENT)); +} diff --git a/src/lib/kap-events.test.ts b/src/lib/kap-events.test.ts new file mode 100644 index 0000000..6343e7f --- /dev/null +++ b/src/lib/kap-events.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; +import { celebrationFor, celebrationLine, emitKap, KAP_EVENT } from "./kap-events"; + +describe("kap events", () => { + it("cheers loudest for a personal best", () => { + expect(celebrationFor({ wpm: 90, accuracy: 95 }, 80)).toBe("pb"); + expect(celebrationFor({ wpm: 90, accuracy: 95 }, null)).toBe("pb"); + expect(celebrationFor({ wpm: 70, accuracy: 98 }, 80)).toBe("good"); + expect(celebrationFor({ wpm: 70, accuracy: 92 }, 80)).toBe("finish"); + // Custom drills never count as a personal best. + expect(celebrationFor({ wpm: 120, accuracy: 92 }, 80, true)).toBe("finish"); + }); + + it("says something that fits", () => { + expect(celebrationLine("pb", { wpm: 1, accuracy: 1 })).toMatch(/personal best/); + expect(celebrationLine("finish", { wpm: 50, accuracy: 85 })).toMatch(/accuracy/); + }); + + it("broadcasts on window", () => { + const listener = vi.fn(); + window.addEventListener(KAP_EVENT, listener); + emitKap({ type: "key", error: true }); + window.removeEventListener(KAP_EVENT, listener); + expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ type: "key", error: true }); + }); +}); diff --git a/src/lib/kap-events.ts b/src/lib/kap-events.ts new file mode 100644 index 0000000..29b3014 --- /dev/null +++ b/src/lib/kap-events.ts @@ -0,0 +1,31 @@ +/** + * Kap listens to the app through window events, so the typing screen and the + * results screen can talk to whichever Kap is on screen without wiring props. + */ + +export const KAP_EVENT = "codey:kap"; + +export type CelebrationLevel = "pb" | "good" | "finish"; + +export type KapEvent = + | { type: "key"; error: boolean } + | { type: "celebrate"; level: CelebrationLevel }; + +export function emitKap(event: KapEvent) { + window.dispatchEvent(new CustomEvent(KAP_EVENT, { detail: event })); +} + +/** How loudly Kap cheers a finished run. */ +export function celebrationFor(run: { wpm: number; accuracy: number }, previousBestWpm: number | null, custom = false): CelebrationLevel { + if (!custom && (previousBestWpm === null || run.wpm > previousBestWpm)) return "pb"; + if (run.accuracy >= 97) return "good"; + return "finish"; +} + +/** What Kap says on the results screen. */ +export function celebrationLine(level: CelebrationLevel, run: { wpm: number; accuracy: number }): string { + if (level === "pb") return "New personal best! Look at that flame!"; + if (level === "good") return `Clean run: ${run.accuracy.toFixed(1)}% accuracy.`; + if (run.accuracy < 90) return "Nice finish. Slow down a little, accuracy comes first."; + return "Nice finish! One more?"; +}