From cb6416df0e3cfe325d2bd81f79782f31fef71b0e Mon Sep 17 00:00:00 2001 From: Shane Neubauer Date: Thu, 23 Jul 2026 20:06:56 +1000 Subject: [PATCH 1/5] Add Prisma Arcade --- apps/site/.gitignore | 1 + .../app/arcade/_components/arcade-audio.ts | 30 + .../app/arcade/_components/arcade-screen.tsx | 287 +++++ .../app/arcade/_components/arcade.module.css | 744 +++++++++++ .../app/arcade/_components/invaders-game.tsx | 828 ++++++++++++ .../app/arcade/_components/meteors-game.tsx | 1142 +++++++++++++++++ .../app/arcade/_components/muncher-game.tsx | 976 ++++++++++++++ .../app/arcade/_components/pixel-sprite.tsx | 25 + .../src/app/arcade/_components/snake-game.tsx | 356 +++++ .../app/arcade/_components/stacker-game.tsx | 682 ++++++++++ apps/site/src/app/arcade/games.ts | 163 +++ apps/site/src/app/arcade/page.tsx | 32 + .../src/components/navigation-wrapper.tsx | 9 + 13 files changed, 5275 insertions(+) create mode 100644 apps/site/.gitignore create mode 100644 apps/site/src/app/arcade/_components/arcade-audio.ts create mode 100644 apps/site/src/app/arcade/_components/arcade-screen.tsx create mode 100644 apps/site/src/app/arcade/_components/arcade.module.css create mode 100644 apps/site/src/app/arcade/_components/invaders-game.tsx create mode 100644 apps/site/src/app/arcade/_components/meteors-game.tsx create mode 100644 apps/site/src/app/arcade/_components/muncher-game.tsx create mode 100644 apps/site/src/app/arcade/_components/pixel-sprite.tsx create mode 100644 apps/site/src/app/arcade/_components/snake-game.tsx create mode 100644 apps/site/src/app/arcade/_components/stacker-game.tsx create mode 100644 apps/site/src/app/arcade/games.ts create mode 100644 apps/site/src/app/arcade/page.tsx diff --git a/apps/site/.gitignore b/apps/site/.gitignore new file mode 100644 index 0000000000..62a2372fe7 --- /dev/null +++ b/apps/site/.gitignore @@ -0,0 +1 @@ +.prisma/ diff --git a/apps/site/src/app/arcade/_components/arcade-audio.ts b/apps/site/src/app/arcade/_components/arcade-audio.ts new file mode 100644 index 0000000000..9349a35834 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade-audio.ts @@ -0,0 +1,30 @@ +// Chunky 8-bit blips via WebAudio — no assets needed. One lazily-created +// context shared by every game; created on first user gesture so autoplay +// policy never blocks it. + +let ctx: AudioContext | null = null; + +export function beep( + freq: number, + endFreq: number, + duration: number, + volume = 0.06, + type: OscillatorType = "square", +) { + try { + ctx ??= new AudioContext(); + if (ctx.state === "suspended") void ctx.resume(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), ctx.currentTime + duration); + gain.gain.setValueAtTime(volume, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + duration); + } catch { + // No AudioContext — play on in silence. + } +} diff --git a/apps/site/src/app/arcade/_components/arcade-screen.tsx b/apps/site/src/app/arcade/_components/arcade-screen.tsx new file mode 100644 index 0000000000..a4db1a3426 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade-screen.tsx @@ -0,0 +1,287 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { GAMES, type ArcadeGame } from "../games"; +import { PixelSprite } from "./pixel-sprite"; +import { SnakeGame } from "./snake-game"; +import { InvadersGame } from "./invaders-game"; +import { StackerGame } from "./stacker-game"; +import { MuncherGame } from "./muncher-game"; +import { MeteorsGame } from "./meteors-game"; +import styles from "./arcade.module.css"; + +const HI_SCORE_STORAGE_KEY = "prisma-arcade-hiscores"; + +type GameProps = { hiScore: number; onGameOver: (score: number) => void }; + +const GAME_COMPONENTS: Record> = { + snake: SnakeGame, + invaders: InvadersGame, + stacker: StackerGame, + muncher: MuncherGame, + meteors: MeteorsGame, +}; + +const KONAMI = [ + "ArrowUp", + "ArrowUp", + "ArrowDown", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowLeft", + "ArrowRight", + "b", + "a", +]; + +const TICKER_ITEMS = [ + "★ WELCOME TO THE PRISMA ARCADE ★", + "5 GAMES ★ FREE PLAY", + "GLOBAL HIGH SCORES COMING SOON", + "NO QUARTERS REQUIRED", + "TYPE-SAFE SINCE 2016", + "WINNERS DON'T USE RAW SQL... USUALLY", +]; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +/** Chunky 8-bit coin blip via WebAudio — no assets needed. */ +function playCoinSound() { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "square"; + osc.frequency.setValueAtTime(988, ctx.currentTime); + osc.frequency.setValueAtTime(1319, ctx.currentTime + 0.08); + gain.gain.setValueAtTime(0.08, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + 0.35); + osc.onended = () => ctx.close(); + } catch { + // Autoplay policy or no AudioContext — the arcade stays silent. + } +} + +export function ArcadeScreen() { + const [credits, setCredits] = useState(0); + const [activeGame, setActiveGame] = useState(null); + const [shakingId, setShakingId] = useState(null); + const [cheatFlash, setCheatFlash] = useState(0); + // Local hi-scores until the global leaderboard backend lands. + const [hiScores, setHiScores] = useState>({}); + const konamiProgress = useRef(0); + + useEffect(() => { + try { + const stored = localStorage.getItem(HI_SCORE_STORAGE_KEY); + if (stored) setHiScores(JSON.parse(stored)); + } catch { + // Corrupt or unavailable storage — start from zero. + } + }, []); + + useEffect(() => { + if (Object.keys(hiScores).length === 0) return; + try { + localStorage.setItem(HI_SCORE_STORAGE_KEY, JSON.stringify(hiScores)); + } catch { + // Storage unavailable — scores still show for this session. + } + }, [hiScores]); + + const reportScore = useCallback((gameId: string, score: number) => { + setHiScores((prev) => (score <= (prev[gameId] ?? 0) ? prev : { ...prev, [gameId]: score })); + }, []); + + const insertCoin = useCallback(() => { + playCoinSound(); + setCredits((c) => c + 1); + }, []); + + const openGame = useCallback( + (game: ArcadeGame) => { + if (credits <= 0) { + setShakingId(game.id); + window.setTimeout(() => setShakingId(null), 350); + return; + } + setCredits((c) => c - 1); + setActiveGame(game); + }, + [credits], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setActiveGame(null); + } + + const expected = KONAMI[konamiProgress.current]; + if (event.key === expected || event.key.toLowerCase() === expected) { + konamiProgress.current += 1; + if (konamiProgress.current === KONAMI.length) { + konamiProgress.current = 0; + setCredits((c) => c + 30); + setCheatFlash((n) => n + 1); + } + } else { + konamiProgress.current = event.key === KONAMI[0] ? 1 : 0; + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const tickerText = [...TICKER_ITEMS, ...TICKER_ITEMS]; + + return ( +
+
+
+
+ +
+
+

PRISMA PRESENTS

+

+ PRISMA +
+ ARCADE +

+

INSERT COIN TO PLAY

+
+ +
+ +

+ CREDITS {formatScore(credits)} +

+
+ +
+ {GAMES.map((game) => ( + + ))} +
+ +
+

★ HALL OF FAME ★

+ {[ + ["1ST", "???", "AWAITING CHALLENGER", 0], + ["2ND", "???", "AWAITING CHALLENGER", 0], + ["3RD", "???", "AWAITING CHALLENGER", 0], + ].map(([rank, initials, note, score]) => ( +
+ {rank} + {initials} + {note} + {formatScore(score as number)} +
+ ))} +

+ Global leaderboards go live when the games do. Practice your initials. +

+
+ + + ◀ EXIT TO PRISMA.IO + +
+ + {activeGame && ( +
{ + // Click-away quit would be brutal mid-game; only for placeholders. + if (activeGame.status === "coming-soon") setActiveGame(null); + }} + > +
event.stopPropagation()} + > +
+

{activeGame.title}

+ {activeGame.status === "playable" && GAME_COMPONENTS[activeGame.id] ? ( + (() => { + const Game = GAME_COMPONENTS[activeGame.id]; + return ( + reportScore(activeGame.id, score)} + /> + ); + })() + ) : ( + <> +

COMING SOON

+

{activeGame.blurb}

+
+ TODAY'S BEST — NOBODY YET + ALL-TIME BEST — COULD BE YOU +
+ + )} + +
+
+ )} + + {cheatFlash > 0 && ( +

+ CHEAT ACTIVATED! +30 CREDITS +

+ )} + +
+
+ {tickerText.map((item, i) => ( + {item} + ))} +
+
+ +
+
+
+ ); +} diff --git a/apps/site/src/app/arcade/_components/arcade.module.css b/apps/site/src/app/arcade/_components/arcade.module.css new file mode 100644 index 0000000000..fe03c69838 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade.module.css @@ -0,0 +1,744 @@ +/* ========================================================================== + PRISMA ARCADE — deliberately off-brand. CRT glow, scanlines, pixel type. + ========================================================================== */ + +.arcade { + --arcade-bg: #08010f; + --arcade-magenta: #f472b6; + --arcade-cyan: #22d3ee; + --arcade-yellow: #facc15; + --arcade-text: #e2e8f0; + position: relative; + min-height: 100svh; + overflow: hidden; + background: + radial-gradient(ellipse 120% 80% at 50% -20%, #2b0a4e 0%, transparent 60%), var(--arcade-bg); + color: var(--arcade-text); + font-family: var(--font-arcade), "Courier New", monospace; + image-rendering: pixelated; + cursor: crosshair; +} + +.arcade *::selection { + background: var(--arcade-magenta); + color: #08010f; +} + +/* --- background layers ------------------------------------------------- */ + +.stars, +.starsFar { + position: absolute; + inset: 0; + pointer-events: none; +} + +.stars { + background-image: + radial-gradient(1px 1px at 20% 30%, #fff 100%, transparent), + radial-gradient(2px 2px at 60% 70%, var(--arcade-cyan) 100%, transparent), + radial-gradient(1px 1px at 50% 50%, #fff 100%, transparent), + radial-gradient(2px 2px at 80% 10%, var(--arcade-magenta) 100%, transparent), + radial-gradient(1px 1px at 90% 60%, #fff 100%, transparent), + radial-gradient(1px 1px at 33% 80%, #fff 100%, transparent), + radial-gradient(2px 2px at 15% 65%, #fff 100%, transparent); + background-size: 550px 550px; + animation: twinkle 4s steps(2) infinite; +} + +.starsFar { + background-image: + radial-gradient(1px 1px at 10% 10%, #ffffffaa 100%, transparent), + radial-gradient(1px 1px at 40% 60%, #ffffff88 100%, transparent), + radial-gradient(1px 1px at 70% 40%, #ffffffaa 100%, transparent), + radial-gradient(1px 1px at 95% 85%, #ffffff88 100%, transparent); + background-size: 350px 350px; + animation: twinkle 3s steps(2) infinite reverse; +} + +@keyframes twinkle { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +.gridFloor { + position: absolute; + left: -25%; + right: -25%; + bottom: -2%; + height: 42%; + pointer-events: none; + background-image: + linear-gradient(to top, rgba(244, 114, 182, 0.45) 2px, transparent 2px), + linear-gradient(to right, rgba(34, 211, 238, 0.35) 2px, transparent 2px); + background-size: 64px 64px; + transform: perspective(320px) rotateX(62deg); + transform-origin: center top; + animation: floorScroll 1.4s linear infinite; + mask-image: linear-gradient(to bottom, transparent, black 30%); +} + +@keyframes floorScroll { + from { + background-position: + 0 0, + 0 0; + } + to { + background-position: + 0 64px, + 0 0; + } +} + +/* --- CRT overlay -------------------------------------------------------- */ + +.crt { + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 2px, + rgba(0, 0, 0, 0.22) 3px, + rgba(0, 0, 0, 0.22) 4px + ); + animation: flicker 0.12s steps(2) infinite; +} + +.vignette { + position: fixed; + inset: 0; + z-index: 51; + pointer-events: none; + background: radial-gradient( + ellipse 90% 90% at 50% 50%, + transparent 55%, + rgba(0, 0, 0, 0.55) 100% + ); +} + +@keyframes flicker { + 0%, + 100% { + opacity: 0.9; + } + 50% { + opacity: 1; + } +} + +/* --- header -------------------------------------------------------------- */ + +.content { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + gap: 3rem; + padding: 4rem 1.5rem 6rem; + max-width: 72rem; + margin: 0 auto; +} + +.pretitle { + font-size: 0.75rem; + letter-spacing: 0.35em; + color: var(--arcade-cyan); + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.title { + font-size: clamp(1.75rem, 6vw, 4rem); + text-align: center; + line-height: 1.2; + color: #fff; + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 24px rgba(244, 114, 182, 0.8), + 0 0 64px rgba(34, 211, 238, 0.5); + animation: titlePulse 2.4s ease-in-out infinite; +} + +@keyframes titlePulse { + 0%, + 100% { + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 24px rgba(244, 114, 182, 0.8), + 0 0 64px rgba(34, 211, 238, 0.5); + } + 50% { + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 40px rgba(244, 114, 182, 1), + 0 0 96px rgba(34, 211, 238, 0.8); + } +} + +.blink { + animation: blink 1.1s steps(2, start) infinite; +} + +@keyframes blink { + to { + visibility: hidden; + } +} + +.insertCoin { + font-size: clamp(0.7rem, 2vw, 1rem); + letter-spacing: 0.2em; + color: var(--arcade-yellow); + text-shadow: 0 0 12px var(--arcade-yellow); +} + +/* --- credits / coin slot ------------------------------------------------- */ + +.coinRow { + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; + justify-content: center; +} + +.coinSlot { + font-family: inherit; + font-size: 0.7rem; + letter-spacing: 0.15em; + color: #08010f; + background: var(--arcade-yellow); + border: none; + padding: 0.9rem 1.4rem; + cursor: pointer; + clip-path: polygon( + 0 8px, + 8px 8px, + 8px 0, + calc(100% - 8px) 0, + calc(100% - 8px) 8px, + 100% 8px, + 100% calc(100% - 8px), + calc(100% - 8px) calc(100% - 8px), + calc(100% - 8px) 100%, + 8px 100%, + 8px calc(100% - 8px), + 0 calc(100% - 8px) + ); + box-shadow: 0 0 20px rgba(250, 204, 21, 0.5); + transition: transform 0.05s steps(1); +} + +.coinSlot:active { + transform: translateY(3px); +} + +.credits { + font-size: 0.75rem; + letter-spacing: 0.2em; + color: var(--arcade-text); +} + +.creditsCount { + color: var(--arcade-yellow); + text-shadow: 0 0 10px var(--arcade-yellow); +} + +/* --- cabinets ------------------------------------------------------------ */ + +.cabinets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: 2rem; + width: 100%; +} + +.cabinet { + --game-color: #fff; + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem 1.25rem 1.75rem; + background: linear-gradient(180deg, #140525 0%, #0b0217 100%); + border: 3px solid var(--game-color); + clip-path: polygon( + 0 12px, + 12px 12px, + 12px 0, + calc(100% - 12px) 0, + calc(100% - 12px) 12px, + 100% 12px, + 100% calc(100% - 12px), + calc(100% - 12px) calc(100% - 12px), + calc(100% - 12px) 100%, + 12px 100%, + 12px calc(100% - 12px), + 0 calc(100% - 12px) + ); + cursor: pointer; + text-align: center; + font-family: inherit; + color: inherit; + transition: transform 0.1s steps(2); +} + +.cabinet:hover, +.cabinet:focus-visible { + transform: translateY(-6px); + filter: drop-shadow(0 0 18px var(--game-color)); + outline: none; +} + +.cabinetMarquee { + font-size: 0.8rem; + line-height: 1.5; + color: var(--game-color); + text-shadow: 0 0 12px var(--game-color); + letter-spacing: 0.08em; +} + +.cabinetScreen { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 4 / 3; + background: + repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.35) 2px 4px), + radial-gradient(ellipse at 50% 40%, #1e0b38 0%, #05010a 80%); + border: 3px solid #2c1b45; + overflow: hidden; +} + +.cabinetScreen svg { + width: 55%; + height: auto; + filter: drop-shadow(0 0 10px var(--game-color)); + animation: spriteBob 1.2s steps(2) infinite; +} + +@keyframes spriteBob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +.comingSoon { + position: absolute; + bottom: 0.6rem; + left: 0; + right: 0; + font-size: 0.55rem; + letter-spacing: 0.3em; + color: var(--arcade-yellow); + text-shadow: 0 0 8px var(--arcade-yellow); +} + +.tagline { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.15rem; + line-height: 1.3; + color: #94a3b8; +} + +.hiScore { + font-size: 0.6rem; + letter-spacing: 0.2em; + color: var(--arcade-cyan); +} + +.hiScoreValue { + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.startHint { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: var(--game-color); +} + +.shake { + animation: shake 0.3s steps(6); +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-8px); + } + 50% { + transform: translateX(8px); + } + 75% { + transform: translateX(-4px); + } +} + +/* --- hall of fame --------------------------------------------------------- */ + +.hallOfFame { + width: 100%; + max-width: 40rem; + border: 3px solid var(--arcade-magenta); + padding: 1.5rem 1.25rem; + background: rgba(20, 5, 37, 0.8); + box-shadow: 0 0 24px rgba(244, 114, 182, 0.3); +} + +.hallTitle { + font-size: 0.85rem; + letter-spacing: 0.25em; + text-align: center; + color: var(--arcade-magenta); + text-shadow: 0 0 12px var(--arcade-magenta); + margin-bottom: 1.25rem; +} + +.hallRow { + display: grid; + grid-template-columns: 3rem 4rem 1fr auto; + gap: 0.75rem; + align-items: baseline; + font-size: 0.65rem; + letter-spacing: 0.12em; + padding: 0.5rem 0; + color: #94a3b8; +} + +.hallRow:first-of-type { + color: var(--arcade-yellow); + text-shadow: 0 0 8px var(--arcade-yellow); +} + +.hallNote { + margin-top: 1.25rem; + text-align: center; + font-family: var(--font-arcade-alt), monospace; + font-size: 1rem; + color: #64748b; +} + +/* --- ticker ---------------------------------------------------------------- */ + +.ticker { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + overflow: hidden; + border-top: 3px solid var(--arcade-cyan); + background: rgba(5, 1, 10, 0.92); + padding: 0.65rem 0; +} + +.tickerTrack { + display: flex; + width: max-content; + gap: 3rem; + white-space: nowrap; + font-size: 0.65rem; + letter-spacing: 0.25em; + color: var(--arcade-cyan); + animation: tickerScroll 22s linear infinite; +} + +@keyframes tickerScroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +/* --- game overlay ----------------------------------------------------------- */ + +.overlay { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 0, 5, 0.92); + padding: 1.5rem; + overflow-y: auto; +} + +.overlayScreen { + position: relative; + margin: auto; + width: min(100%, 34rem); + border: 4px solid var(--game-color, #fff); + background: + repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.3) 2px 4px), #0a0118; + padding: 2.5rem 1.75rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow: hidden; + box-shadow: 0 0 40px var(--game-color, #fff); +} + +.rollBar { + position: absolute; + left: 0; + right: 0; + height: 5rem; + background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.08), transparent); + animation: rollBar 3s linear infinite; + pointer-events: none; +} + +@keyframes rollBar { + from { + top: -6rem; + } + to { + top: 110%; + } +} + +.overlayTitle { + font-size: 1.1rem; + color: var(--game-color, #fff); + text-shadow: 0 0 16px var(--game-color, #fff); + letter-spacing: 0.1em; + line-height: 1.5; +} + +.overlayComingSoon { + font-size: 1.5rem; + color: #fff; + letter-spacing: 0.15em; + text-shadow: + 2px 2px 0 var(--arcade-magenta), + -2px -2px 0 var(--arcade-cyan); +} + +.overlayBlurb { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.2rem; + line-height: 1.4; + color: #94a3b8; +} + +.overlayScores { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.6rem; + letter-spacing: 0.2em; + color: var(--arcade-cyan); +} + +.backBtn { + font-family: inherit; + font-size: 0.65rem; + letter-spacing: 0.2em; + align-self: center; + color: var(--arcade-yellow); + background: transparent; + border: 3px solid var(--arcade-yellow); + padding: 0.8rem 1.5rem; + cursor: pointer; +} + +.backBtn:hover { + background: var(--arcade-yellow); + color: #08010f; + box-shadow: 0 0 20px var(--arcade-yellow); +} + +/* --- playable game ----------------------------------------------------------- */ + +.gameWrap { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; +} + +.gameHud { + display: flex; + justify-content: space-between; + font-size: 0.65rem; + letter-spacing: 0.15em; + color: var(--arcade-cyan); +} + +.gameHud b { + font-weight: 400; + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.gameScreen { + position: relative; + border: 3px solid var(--game-color, #4ade80); + overflow: hidden; +} + +.gameCanvas { + display: block; + width: 100%; + height: auto; + image-rendering: pixelated; + background: #060210; + touch-action: none; +} + +.gameScanlines { + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.25) 2px 4px); +} + +.gameMsg { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + background: rgba(2, 0, 5, 0.75); + font-size: 0.85rem; + letter-spacing: 0.15em; + line-height: 1.6; + text-align: center; + color: #fff; + text-shadow: 0 0 14px var(--game-color, #4ade80); + padding: 1rem; +} + +.gameMsgSub { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.1rem; + letter-spacing: 0.05em; + color: #94a3b8; + text-shadow: none; +} + +.newBest { + color: var(--arcade-yellow); + text-shadow: 0 0 14px var(--arcade-yellow); + animation: blink 0.6s steps(2, start) infinite; +} + +.gameControls { + font-size: 0.55rem; + letter-spacing: 0.2em; + text-align: center; + color: #64748b; +} + +.gameLives { + color: #4ade80; + letter-spacing: 0.3em; + text-shadow: 0 0 8px #4ade80; +} + +.waveBanner { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + font-size: 1.1rem; + letter-spacing: 0.3em; + color: var(--game-color, #22d3ee); + text-shadow: 0 0 18px var(--game-color, #22d3ee); + animation: blink 0.7s steps(2, start) infinite; +} + +/* --- misc -------------------------------------------------------------------- */ + +.exitLink { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: #64748b; + text-decoration: none; + border-bottom: 2px dotted #64748b; + padding-bottom: 0.2rem; +} + +.exitLink:hover { + color: var(--arcade-cyan); + border-color: var(--arcade-cyan); + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.cheatFlash { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + font-size: clamp(1.25rem, 4vw, 2.5rem); + color: var(--arcade-yellow); + text-shadow: + 3px 3px 0 var(--arcade-magenta), + 0 0 40px var(--arcade-yellow); + animation: cheatFlash 2s steps(8) forwards; +} + +@keyframes cheatFlash { + 0% { + opacity: 0; + transform: scale(0.5); + } + 10% { + opacity: 1; + transform: scale(1.1); + } + 20% { + transform: scale(1); + } + 80% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .arcade *, + .crt, + .stars, + .starsFar, + .gridFloor, + .tickerTrack { + animation: none !important; + } +} diff --git a/apps/site/src/app/arcade/_components/invaders-game.tsx b/apps/site/src/app/arcade/_components/invaders-game.tsx new file mode 100644 index 0000000000..bf307f9382 --- /dev/null +++ b/apps/site/src/app/arcade/_components/invaders-game.tsx @@ -0,0 +1,828 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const W = 480; +const H = 540; +const SCALE = 2; + +const COLS = 11; +const ROWS = 5; +const SLOT_W = 36; +const SLOT_H = 32; +const MARCH_DX = 8; +const DESCEND = 16; +const EDGE = 10; +const GRID_START_Y = 80; + +const PLAYER_Y = H - 52; +const PLAYER_SPEED = 260; +const PLAYER_HALF_W = 13; +const PLAYER_H = 16; + +const BULLET_SPEED = 460; +const ENEMY_BULLET_SPEED = 190; +const MAX_ENEMY_BULLETS = 3; + +const UFO_Y = 46; +const UFO_SPEED = 90; + +const BUNKER_CELL = 4; +const BUNKER_Y = H - 124; +const MARCH_NOTES = [110, 98, 87, 78]; + +// Invaders reaching this line means the planet falls. +const INVASION_Y = H - 96; + +type Vec = { x: number; y: number }; +type Phase = "ready" | "playing" | "paused" | "over"; +type Explosion = { x: number; y: number; ttl: number }; +type Ufo = { x: number; dir: number; points: number }; +type Bunker = { x: number; y: number; cells: boolean[][] }; + +type InvaderType = { + points: number; + color: string; + frames: [string[], string[]]; +}; + +// prettier-ignore +const SQUID: InvaderType = { + points: 30, + color: "#f472b6", + frames: [ + [ + "...XX...", + "..XXXX..", + ".XXXXXX.", + "XX.XX.XX", + "XXXXXXXX", + "..X..X..", + ".X.XX.X.", + "X.X..X.X", + ], + [ + "...XX...", + "..XXXX..", + ".XXXXXX.", + "XX.XX.XX", + "XXXXXXXX", + ".X.XX.X.", + "X......X", + ".X....X.", + ], + ], +}; + +// prettier-ignore +const CRAB: InvaderType = { + points: 20, + color: "#facc15", + frames: [ + [ + "..X.....X..", + "...X...X...", + "..XXXXXXX..", + ".XX.XXX.XX.", + "XXXXXXXXXXX", + "X.XXXXXXX.X", + "X.X.....X.X", + "...XX.XX...", + ], + [ + "..X.....X..", + "X..X...X..X", + "X.XXXXXXX.X", + "XXX.XXX.XXX", + "XXXXXXXXXXX", + ".XXXXXXXXX.", + "..X.....X..", + ".X.......X.", + ], + ], +}; + +// prettier-ignore +const OCTOPUS: InvaderType = { + points: 10, + color: "#22d3ee", + frames: [ + [ + "....XXXX....", + ".XXXXXXXXXX.", + "XXXXXXXXXXXX", + "XXX..XX..XXX", + "XXXXXXXXXXXX", + "...XX..XX...", + "..XX.XX.XX..", + "XX........XX", + ], + [ + "....XXXX....", + ".XXXXXXXXXX.", + "XXXXXXXXXXXX", + "XXX..XX..XXX", + "XXXXXXXXXXXX", + "..XXX..XXX..", + ".XX..XX..XX.", + "..XX....XX..", + ], + ], +}; + +const ROW_TYPES: InvaderType[] = [SQUID, CRAB, CRAB, OCTOPUS, OCTOPUS]; + +// prettier-ignore +const PLAYER_SPRITE = [ + "......X......", + ".....XXX.....", + ".....XXX.....", + ".XXXXXXXXXXX.", + "XXXXXXXXXXXXX", + "XXXXXXXXXXXXX", + "XXXXXXXXXXXXX", + "XXXXXXXXXXXXX", +]; + +// prettier-ignore +const UFO_SPRITE = [ + ".....XXXXXX.....", + "...XXXXXXXXXX...", + "..XXXXXXXXXXXX..", + ".XX.XX.XX.XX.XX.", + "XXXXXXXXXXXXXXXX", + "..XXX..XX..XXX..", + "...X........X...", +]; + +// prettier-ignore +const BUNKER_SHAPE = [ + ".XXXXXXXXX.", + "XXXXXXXXXXX", + "XXXXXXXXXXX", + "XXXXXXXXXXX", + "XXXXXXXXXXX", + "XXXX...XXXX", + "XXX.....XXX", + "XXX.....XXX", +]; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +function makeBunkers(): Bunker[] { + const width = BUNKER_SHAPE[0].length * BUNKER_CELL; + return [96, 192, 288, 384].map((center) => ({ + x: center - width / 2, + y: BUNKER_Y, + cells: BUNKER_SHAPE.map((row) => [...row].map((c) => c === "X")), + })); +} + +function drawSprite( + ctx: CanvasRenderingContext2D, + rows: string[], + x: number, + y: number, + color: string, +) { + ctx.fillStyle = color; + for (let r = 0; r < rows.length; r++) { + for (let c = 0; c < rows[r].length; c++) { + if (rows[r][c] === "X") { + ctx.fillRect(x + c * SCALE, y + r * SCALE, SCALE, SCALE); + } + } + } +} + +export function InvadersGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + const playerX = useRef(W / 2); + const keys = useRef(new Set()); + const touchTargetX = useRef(null); + const playerBullet = useRef(null); + const enemyBullets = useRef([]); + const grid = useRef({ x: 0, y: GRID_START_Y, dir: 1, anim: 0, alive: [] as boolean[][] }); + const bunkers = useRef(makeBunkers()); + const ufo = useRef(null); + const explosions = useRef([]); + + const marchAcc = useRef(0); + const noteIndex = useRef(0); + const fireAcc = useRef(0); + const nextFireIn = useRef(1000); + const ufoTimer = useRef(9000); + const freeze = useRef(0); + const pendingWave = useRef(false); + + const scoreRef = useRef(0); + const livesRef = useRef(3); + const waveRef = useRef(1); + const phaseRef = useRef("ready"); + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); + const [wave, setWave] = useState(1); + const [waveBanner, setWaveBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const showWaveBanner = useCallback((n: number) => { + setWaveBanner(`WAVE ${n.toString().padStart(2, "0")}`); + window.clearTimeout(bannerTimeout.current); + bannerTimeout.current = window.setTimeout(() => setWaveBanner(null), 1400); + }, []); + + useEffect(() => () => window.clearTimeout(bannerTimeout.current), []); + + const aliveCount = useCallback(() => grid.current.alive.flat().filter(Boolean).length, []); + + const resetGrid = useCallback((waveNumber: number) => { + grid.current = { + x: EDGE, + y: Math.min(GRID_START_Y + (waveNumber - 1) * DESCEND, GRID_START_Y + DESCEND * 6), + dir: 1, + anim: 0, + alive: Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => true)), + }; + marchAcc.current = 0; + }, []); + + const reset = useCallback(() => { + scoreRef.current = 0; + livesRef.current = 3; + waveRef.current = 1; + setScore(0); + setLives(3); + setWave(1); + bestAtRoundStart.current = hiScoreRef.current; + playerX.current = W / 2; + playerBullet.current = null; + enemyBullets.current = []; + explosions.current = []; + ufo.current = null; + ufoTimer.current = 9000; + fireAcc.current = 0; + nextFireIn.current = 1000; + freeze.current = 0; + pendingWave.current = false; + bunkers.current = makeBunkers(); + resetGrid(1); + changePhase("ready"); + }, [resetGrid, changePhase]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + changePhase("over"); + onGameOverRef.current(scoreRef.current); + }, [changePhase]); + + const invaderRect = useCallback((row: number, col: number) => { + const type = ROW_TYPES[row]; + const width = type.frames[0][0].length * SCALE; + const x = grid.current.x + col * SLOT_W + (SLOT_W - width) / 2; + const y = grid.current.y + row * SLOT_H; + return { x, y, w: width, h: 16 }; + }, []); + + const erodeBunker = useCallback((bunker: Bunker, hitX: number, hitY: number, radius: number) => { + const cx = Math.floor((hitX - bunker.x) / BUNKER_CELL); + const cy = Math.floor((hitY - bunker.y) / BUNKER_CELL); + for (let dy = -3; dy <= 3; dy++) { + for (let dx = -3; dx <= 3; dx++) { + const dist = dx * dx + dy * dy; + if (dist > radius * radius) continue; + // Ragged edges: cells at the blast rim survive randomly. + if (dist > (radius - 1) * (radius - 1) && Math.random() < 0.4) continue; + const row = bunker.cells[cy + dy]; + if (row && row[cx + dx] !== undefined) row[cx + dx] = false; + } + } + }, []); + + /** Returns true when the point hits a live bunker cell (and erodes it). */ + const hitBunker = useCallback( + (x: number, y: number, radius: number) => { + for (const bunker of bunkers.current) { + const cx = Math.floor((x - bunker.x) / BUNKER_CELL); + const cy = Math.floor((y - bunker.y) / BUNKER_CELL); + if (bunker.cells[cy]?.[cx]) { + erodeBunker(bunker, x, y, radius); + return true; + } + } + return false; + }, + [erodeBunker], + ); + + const playerHit = useCallback(() => { + explosions.current.push({ x: playerX.current, y: PLAYER_Y + 8, ttl: 400 }); + enemyBullets.current = []; + playerBullet.current = null; + livesRef.current -= 1; + setLives(livesRef.current); + if (livesRef.current <= 0) { + gameOver(); + return; + } + beep(300, 50, 0.5, 0.09, "sawtooth"); + playerX.current = W / 2; + freeze.current = 1200; + }, [gameOver]); + + const marchStep = useCallback(() => { + const g = grid.current; + let minCol = COLS; + let maxCol = -1; + let maxRow = -1; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (!g.alive[r][c]) continue; + if (c < minCol) minCol = c; + if (c > maxCol) maxCol = c; + if (r > maxRow) maxRow = r; + } + } + if (maxCol < 0) return; + + const nextX = g.x + g.dir * MARCH_DX; + const left = nextX + minCol * SLOT_W; + const right = nextX + maxCol * SLOT_W + SLOT_W; + if (left < EDGE || right > W - EDGE) { + g.y += DESCEND; + g.dir *= -1; + // The marching wall grinds bunkers away as it reaches them. + for (const bunker of bunkers.current) { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (!g.alive[r][c]) continue; + const rect = invaderRect(r, c); + for (let cy = 0; cy < bunker.cells.length; cy++) { + for (let cx = 0; cx < bunker.cells[cy].length; cx++) { + if (!bunker.cells[cy][cx]) continue; + const px = bunker.x + cx * BUNKER_CELL; + const py = bunker.y + cy * BUNKER_CELL; + if ( + px < rect.x + rect.w && + px + BUNKER_CELL > rect.x && + py < rect.y + rect.h && + py + BUNKER_CELL > rect.y + ) { + bunker.cells[cy][cx] = false; + } + } + } + } + } + } + if (g.y + maxRow * SLOT_H + 16 >= INVASION_Y) { + gameOver(); + return; + } + } else { + g.x = nextX; + } + g.anim ^= 1; + beep(MARCH_NOTES[noteIndex.current], MARCH_NOTES[noteIndex.current], 0.07, 0.05, "square"); + noteIndex.current = (noteIndex.current + 1) % MARCH_NOTES.length; + }, [gameOver, invaderRect]); + + const tryFire = useCallback(() => { + if (playerBullet.current || freeze.current > 0) return; + playerBullet.current = { x: playerX.current, y: PLAYER_Y - 10 }; + beep(900, 300, 0.07, 0.04); + }, []); + + const tick = useCallback( + (dt: number) => { + const g = grid.current; + + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && pendingWave.current) { + pendingWave.current = false; + resetGrid(waveRef.current); + } + return; + } + + // Player movement — keyboard or touch drag. + const k = keys.current; + let vx = 0; + if (k.has("arrowleft") || k.has("a")) vx -= 1; + if (k.has("arrowright") || k.has("d")) vx += 1; + if (vx !== 0) { + touchTargetX.current = null; + playerX.current += vx * PLAYER_SPEED * (dt / 1000); + } else if (touchTargetX.current !== null) { + const delta = touchTargetX.current - playerX.current; + const step = PLAYER_SPEED * 1.4 * (dt / 1000); + playerX.current += Math.abs(delta) <= step ? delta : Math.sign(delta) * step; + } + playerX.current = Math.min( + W - EDGE - PLAYER_HALF_W, + Math.max(EDGE + PLAYER_HALF_W, playerX.current), + ); + + if (k.has(" ") || k.has("arrowup") || k.has("w")) tryFire(); + + // March. + const alive = aliveCount(); + marchAcc.current += dt; + const interval = Math.max(60, (70 + alive * 13) * Math.pow(0.95, waveRef.current - 1)); + if (marchAcc.current >= interval) { + marchAcc.current = 0; + marchStep(); + if (phaseRef.current === "over") return; + } + + // Enemy fire. + fireAcc.current += dt; + if ( + fireAcc.current >= nextFireIn.current && + enemyBullets.current.length < MAX_ENEMY_BULLETS + ) { + fireAcc.current = 0; + nextFireIn.current = (500 + Math.random() * 800) * Math.pow(0.93, waveRef.current - 1); + const columns: number[] = []; + for (let c = 0; c < COLS; c++) { + if (g.alive.some((row) => row[c])) columns.push(c); + } + if (columns.length > 0) { + const col = columns[Math.floor(Math.random() * columns.length)]; + let bottomRow = -1; + for (let r = ROWS - 1; r >= 0; r--) { + if (g.alive[r][col]) { + bottomRow = r; + break; + } + } + if (bottomRow >= 0) { + const rect = invaderRect(bottomRow, col); + enemyBullets.current.push({ x: rect.x + rect.w / 2, y: rect.y + rect.h }); + } + } + } + + // UFO. + if (ufo.current) { + ufo.current.x += ufo.current.dir * UFO_SPEED * (dt / 1000); + if (ufo.current.x < -40 || ufo.current.x > W + 40) ufo.current = null; + } else { + ufoTimer.current -= dt; + if (ufoTimer.current <= 0) { + const dir = Math.random() < 0.5 ? 1 : -1; + ufo.current = { + x: dir === 1 ? -32 : W + 32, + dir, + points: [50, 100, 150][Math.floor(Math.random() * 3)], + }; + ufoTimer.current = 14000 + Math.random() * 10000; + beep(600, 900, 0.25, 0.03, "triangle"); + } + } + + // Player bullet. + const pb = playerBullet.current; + if (pb) { + pb.y -= BULLET_SPEED * (dt / 1000); + if (pb.y < 30) { + playerBullet.current = null; + } else if (hitBunker(pb.x, pb.y, 2.4)) { + playerBullet.current = null; + } else { + const u = ufo.current; + if (u && pb.x > u.x - 16 && pb.x < u.x + 16 && pb.y > UFO_Y && pb.y < UFO_Y + 14) { + scoreRef.current += u.points; + setScore(scoreRef.current); + explosions.current.push({ x: u.x, y: UFO_Y + 7, ttl: 300 }); + ufo.current = null; + playerBullet.current = null; + beep(1200, 200, 0.3, 0.07); + } else { + outer: for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (!g.alive[r][c]) continue; + const rect = invaderRect(r, c); + if ( + pb.x > rect.x && + pb.x < rect.x + rect.w && + pb.y > rect.y && + pb.y < rect.y + rect.h + ) { + g.alive[r][c] = false; + scoreRef.current += ROW_TYPES[r].points; + setScore(scoreRef.current); + explosions.current.push({ + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2, + ttl: 250, + }); + playerBullet.current = null; + beep(200, 40, 0.15, 0.07); + break outer; + } + } + } + } + } + } + + // Enemy bullets. + const remaining: Vec[] = []; + for (const bullet of enemyBullets.current) { + bullet.y += ENEMY_BULLET_SPEED * (dt / 1000); + const pBullet = playerBullet.current; + if (pBullet && Math.abs(bullet.x - pBullet.x) < 5 && Math.abs(bullet.y - pBullet.y) < 10) { + explosions.current.push({ x: bullet.x, y: bullet.y, ttl: 200 }); + playerBullet.current = null; + continue; + } + if (bullet.y > H - 24) continue; + if (hitBunker(bullet.x, bullet.y + 8, 1.8)) continue; + if ( + bullet.x > playerX.current - PLAYER_HALF_W && + bullet.x < playerX.current + PLAYER_HALF_W && + bullet.y + 8 > PLAYER_Y && + bullet.y < PLAYER_Y + PLAYER_H + ) { + playerHit(); + return; + } + remaining.push(bullet); + } + enemyBullets.current = remaining; + + // Explosions decay. + explosions.current = explosions.current.filter((e) => (e.ttl -= dt) > 0); + + // Wave cleared. + if (aliveCount() === 0 && !pendingWave.current) { + waveRef.current += 1; + setWave(waveRef.current); + showWaveBanner(waveRef.current); + playerBullet.current = null; + enemyBullets.current = []; + pendingWave.current = true; + freeze.current = 1400; + beep(440, 1320, 0.4, 0.06); + } + }, + [aliveCount, marchStep, invaderRect, hitBunker, playerHit, tryFire, resetGrid, showWaveBanner], + ); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const g = grid.current; + + ctx.fillStyle = "#060210"; + ctx.fillRect(0, 0, W, H); + + // Ground. + ctx.fillStyle = "#4ade80"; + ctx.fillRect(0, H - 16, W, 2); + + // Bunkers. + ctx.fillStyle = "#4ade80"; + for (const bunker of bunkers.current) { + for (let cy = 0; cy < bunker.cells.length; cy++) { + for (let cx = 0; cx < bunker.cells[cy].length; cx++) { + if (bunker.cells[cy][cx]) { + ctx.fillRect( + bunker.x + cx * BUNKER_CELL, + bunker.y + cy * BUNKER_CELL, + BUNKER_CELL, + BUNKER_CELL, + ); + } + } + } + } + + // UFO. + if (ufo.current) { + drawSprite(ctx, UFO_SPRITE, ufo.current.x - 16, UFO_Y, "#f87171"); + } + + // Invaders. + for (let r = 0; r < ROWS; r++) { + const type = ROW_TYPES[r]; + for (let c = 0; c < COLS; c++) { + if (!g.alive[r][c]) continue; + const rect = invaderRect(r, c); + drawSprite(ctx, type.frames[g.anim as 0 | 1], rect.x, rect.y, type.color); + } + } + + // Player — flickers while respawning. + const respawning = freeze.current > 0 && !pendingWave.current && phaseRef.current === "playing"; + if (!respawning || Math.floor(freeze.current / 120) % 2 === 0) { + drawSprite(ctx, PLAYER_SPRITE, playerX.current - PLAYER_HALF_W, PLAYER_Y, "#4ade80"); + } + + // Bullets. + if (playerBullet.current) { + ctx.fillStyle = "#f8fafc"; + ctx.fillRect(playerBullet.current.x - 1, playerBullet.current.y - 8, 2, 8); + } + ctx.fillStyle = "#f472b6"; + for (const bullet of enemyBullets.current) { + ctx.fillRect(bullet.x - 1.5, bullet.y, 3, 8); + } + + // Explosions — expanding pixel bursts. + for (const e of explosions.current) { + const progress = 1 - e.ttl / 300; + const radius = 4 + progress * 10; + ctx.fillStyle = progress < 0.5 ? "#facc15" : "#f87171"; + for (let i = 0; i < 8; i++) { + const angle = (Math.PI / 4) * i; + ctx.fillRect( + e.x + Math.cos(angle) * radius - 1.5, + e.y + Math.sin(angle) * radius - 1.5, + 3, + 3, + ); + } + } + }, [invaderRect]); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — + // death already reports via gameOver(), so only cover the quit path here. + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + tick(dt); + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, tick, draw]); + + const start = useCallback(() => { + beep(440, 880, 0.12); + showWaveBanner(waveRef.current); + changePhase("playing"); + }, [changePhase, showWaveBanner]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const currentPhase = phaseRef.current; + + if (["arrowleft", "arrowright", "arrowup", "a", "d", "w", " "].includes(key)) { + event.preventDefault(); + keys.current.add(key); + if (currentPhase === "ready") start(); + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + const onKeyUp = (event: KeyboardEvent) => { + keys.current.delete(event.key.toLowerCase()); + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, [start, changePhase, reset]); + + const canvasX = useCallback((clientX: number) => { + const canvas = canvasRef.current; + if (!canvas) return W / 2; + const rect = canvas.getBoundingClientRect(); + return ((clientX - rect.left) / rect.width) * W; + }, []); + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + else if (currentPhase === "playing") tryFire(); + touchTargetX.current = canvasX(event.touches[0].clientX); + }, + [start, reset, changePhase, tryFire, canvasX], + ); + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + touchTargetX.current = canvasX(event.touches[0].clientX); + }, + [canvasX], + ); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + WAVE {wave.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ + {waveBanner && phase === "playing" &&
{waveBanner}
} + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + + Press any key to defend the planet — or tap + + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Enter or tap to play again + + )} +
+ )} +
+
+

◀ ▶ MOVE — SPACE FIRE — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/_components/meteors-game.tsx b/apps/site/src/app/arcade/_components/meteors-game.tsx new file mode 100644 index 0000000000..c5d399bfd7 --- /dev/null +++ b/apps/site/src/app/arcade/_components/meteors-game.tsx @@ -0,0 +1,1142 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const W = 480; +const H = 480; + +// Vector aesthetic — everything is a glowing white outline on near-black. +const COLOR = "#f8fafc"; +const DANGER = "#fca5a5"; + +// Ship physics. +const SHIP_ROT = (230 * Math.PI) / 180; // rad/s +const SHIP_THRUST = 320; // px/s^2 along heading +const SHIP_MAX_SPEED = 360; // px/s +const SHIP_FRICTION = 0.4; // exponential velocity damping per second +const SHIP_RADIUS = 11; +const INVULN_TIME = 2000; // ms of blinking immunity after respawn +const DEATH_FREEZE = 1500; // ms the ship stays shattered before it can respawn +const SAFE_RADIUS = 90; // center must be clear of rocks within this to respawn + +// The classic bracket ship, pointing along +x at angle 0. +const SHIP_SHAPE: [number, number][] = [ + [14, 0], + [-10, -9], + [-6, 0], + [-10, 9], +]; + +// Bullets. +const BULLET_SPEED = 480; // px/s, added to the ship's own velocity +const BULLET_LIFE = 1100; // ms +const MAX_BULLETS = 4; +const FIRE_COOLDOWN = 220; // ms between shots when the fire key is held + +// Hyperspace. +const HYPERSPACE_COOLDOWN = 1000; // ms +const HYPERSPACE_DEATH_CHANCE = 1 / 6; + +const EXTRA_LIFE_EVERY = 10000; + +// Saucers. +const SAUCER_MIN_DELAY = 18000; +const SAUCER_MAX_DELAY = 28000; +const SAUCER_SPEED = 110; // px/s horizontal crossing speed +const SAUCER_BULLET_SPEED = 280; +const SAUCER_BULLET_LIFE = 1400; +const BIG_SAUCER = { r: 16, points: 200, fireEvery: 1200 }; +const SMALL_SAUCER = { r: 10, points: 1000, fireEvery: 1000 }; + +type Phase = "ready" | "playing" | "paused" | "over"; +type RockSize = "large" | "medium" | "small"; + +const ROCK_SPECS: Record< + RockSize, + { radius: number; points: number; minSpeed: number; maxSpeed: number; mass: number } +> = { + large: { radius: 42, points: 20, minSpeed: 60, maxSpeed: 90, mass: 4 }, + medium: { radius: 24, points: 50, minSpeed: 90, maxSpeed: 140, mass: 2 }, + small: { radius: 13, points: 100, minSpeed: 140, maxSpeed: 200, mass: 1 }, +}; +const ROCK_CHILD: Record = { + large: "medium", + medium: "small", + small: null, +}; + +type Ship = { + x: number; + y: number; + vx: number; + vy: number; + angle: number; + thrusting: boolean; + alive: boolean; + invuln: number; +}; +type Rock = { + x: number; + y: number; + vx: number; + vy: number; + size: RockSize; + radius: number; + angle: number; + spin: number; + shape: number[]; +}; +type Bullet = { x: number; y: number; vx: number; vy: number; ttl: number }; +type Saucer = { + x: number; + y: number; + vx: number; + vy: number; + big: boolean; + r: number; + points: number; + fireEvery: number; + fireTimer: number; + crossed: number; // horizontal distance travelled, used to leave after a full crossing +}; +type Debris = { + x: number; + y: number; + vx: number; + vy: number; + angle: number; + spin: number; + ttl: number; + ax: number; + ay: number; + bx: number; + by: number; +}; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +const wrap = (v: number, max: number) => ((v % max) + max) % max; + +/** Squared distance on the toroidal (wrapping) playfield. */ +function torDist2(ax: number, ay: number, bx: number, by: number) { + let dx = Math.abs(ax - bx); + if (dx > W / 2) dx = W - dx; + let dy = Math.abs(ay - by); + if (dy > H / 2) dy = H - dy; + return dx * dx + dy * dy; +} + +/** Shortest signed delta from `from` to `to` across a wrapping axis of length `size`. */ +function torDelta(from: number, to: number, size: number) { + let d = to - from; + if (d > size / 2) d -= size; + else if (d < -size / 2) d += size; + return d; +} + +/** + * Closest-approach squared distance between a target center and the motion + * segment a projectile swept this frame (from its previous position to `bx,by`, + * displacement `vx*dts, vy*dts`), measured wrap-aware on the torus. Sampling the + * end point alone lets fast projectiles tunnel through small targets under the + * dt clamp; sweeping the whole segment closes that gap. + */ +function sweptDist2( + bx: number, + by: number, + vx: number, + vy: number, + dts: number, + cx: number, + cy: number, +) { + // Bullet end position relative to the target center (wrap-aware). + const ex = torDelta(cx, bx, W); + const ey = torDelta(cy, by, H); + // This frame's displacement; the segment starts one displacement back. + const dx = vx * dts; + const dy = vy * dts; + const sx = ex - dx; + const sy = ey - dy; + const len2 = dx * dx + dy * dy; + if (len2 === 0) return sx * sx + sy * sy; + let t = -(sx * dx + sy * dy) / len2; + if (t < 0) t = 0; + else if (t > 1) t = 1; + const px = sx + t * dx; + const py = sy + t * dy; + return px * px + py * py; +} + +function makeRock(size: RockSize, x: number, y: number): Rock { + const spec = ROCK_SPECS[size]; + const verts = 8 + Math.floor(Math.random() * 4); // 8-11 vertices + const shape = Array.from({ length: verts }, () => 0.72 + Math.random() * 0.55); + const speed = spec.minSpeed + Math.random() * (spec.maxSpeed - spec.minSpeed); + const dir = Math.random() * Math.PI * 2; + return { + x, + y, + vx: Math.cos(dir) * speed, + vy: Math.sin(dir) * speed, + size, + radius: spec.radius, + angle: Math.random() * Math.PI * 2, + spin: (Math.random() - 0.5) * 1.2, + shape, + }; +} + +export function MeteorsGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + const ship = useRef({ + x: W / 2, + y: H / 2, + vx: 0, + vy: 0, + angle: -Math.PI / 2, + thrusting: false, + alive: true, + invuln: 0, + }); + const rocks = useRef([]); + const bullets = useRef([]); + const saucer = useRef(null); + const saucerBullets = useRef([]); + const debris = useRef([]); + + const keys = useRef(new Set()); + const touch = useRef({ left: false, right: false, thrust: false }); + const tapInfo = useRef<{ t: number; x: number; y: number; moved: boolean } | null>(null); + + const fireCooldown = useRef(0); + const hyperCooldown = useRef(0); + const deathTimer = useRef(0); + const waveDelay = useRef(0); + const nextExtraLife = useRef(EXTRA_LIFE_EVERY); + const saucerTimer = useRef(SAUCER_MIN_DELAY); + const thrustSound = useRef(0); + const warbleSound = useRef(0); + const beatTimer = useRef(0); + const beatHigh = useRef(false); + + const scoreRef = useRef(0); + const livesRef = useRef(3); + const waveRef = useRef(1); + const phaseRef = useRef("ready"); + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); + const [wave, setWave] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const showBanner = useCallback((text: string) => { + setBanner(text); + window.clearTimeout(bannerTimeout.current); + bannerTimeout.current = window.setTimeout(() => setBanner(null), 1400); + }, []); + + useEffect(() => () => window.clearTimeout(bannerTimeout.current), []); + + const spawnWave = useCallback((n: number) => { + const count = Math.min(4 + n - 1, 10); + const s = ship.current; + const next: Rock[] = []; + for (let i = 0; i < count; i++) { + let x = 0; + let y = 0; + // Keep large rocks a safe distance from the ship at wave start. + do { + x = Math.random() * W; + y = Math.random() * H; + } while (torDist2(x, y, s.x, s.y) < 120 * 120); + next.push(makeRock("large", x, y)); + } + rocks.current = next; + }, []); + + const reset = useCallback(() => { + scoreRef.current = 0; + livesRef.current = 3; + waveRef.current = 1; + setScore(0); + setLives(3); + setWave(1); + bestAtRoundStart.current = hiScoreRef.current; + ship.current = { + x: W / 2, + y: H / 2, + vx: 0, + vy: 0, + angle: -Math.PI / 2, + thrusting: false, + alive: true, + invuln: INVULN_TIME, + }; + bullets.current = []; + saucer.current = null; + saucerBullets.current = []; + debris.current = []; + fireCooldown.current = 0; + hyperCooldown.current = 0; + deathTimer.current = 0; + waveDelay.current = 0; + nextExtraLife.current = EXTRA_LIFE_EVERY; + saucerTimer.current = SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + thrustSound.current = 0; + warbleSound.current = 0; + beatTimer.current = 0; + beatHigh.current = false; + spawnWave(1); + changePhase("ready"); + }, [spawnWave, changePhase]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + changePhase("over"); + onGameOverRef.current(scoreRef.current); + }, [changePhase]); + + const addScore = useCallback( + (points: number) => { + scoreRef.current += points; + setScore(scoreRef.current); + while (scoreRef.current >= nextExtraLife.current) { + nextExtraLife.current += EXTRA_LIFE_EVERY; + livesRef.current += 1; + setLives(livesRef.current); + showBanner("1UP"); + beep(784, 1568, 0.25, 0.07, "triangle"); + } + }, + [showBanner], + ); + + const tryFire = useCallback(() => { + const s = ship.current; + if (!s.alive) return; + if (fireCooldown.current > 0 || bullets.current.length >= MAX_BULLETS) return; + fireCooldown.current = FIRE_COOLDOWN; + const cos = Math.cos(s.angle); + const sin = Math.sin(s.angle); + bullets.current.push({ + x: s.x + cos * 14, + y: s.y + sin * 14, + vx: cos * BULLET_SPEED + s.vx, + vy: sin * BULLET_SPEED + s.vy, + ttl: BULLET_LIFE, + }); + beep(880, 440, 0.08, 0.04, "square"); + }, []); + + const createDebris = useCallback((s: Ship) => { + const cos = Math.cos(s.angle); + const sin = Math.sin(s.angle); + const rot = (px: number, py: number) => ({ + x: s.x + px * cos - py * sin, + y: s.y + px * sin + py * cos, + }); + const next: Debris[] = []; + for (let i = 0; i < SHIP_SHAPE.length; i++) { + const a = rot(SHIP_SHAPE[i][0], SHIP_SHAPE[i][1]); + const b = rot( + SHIP_SHAPE[(i + 1) % SHIP_SHAPE.length][0], + SHIP_SHAPE[(i + 1) % SHIP_SHAPE.length][1], + ); + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + const outAngle = Math.atan2(my - s.y, mx - s.x); + const sp = 24 + Math.random() * 44; + next.push({ + x: mx, + y: my, + vx: s.vx * 0.5 + Math.cos(outAngle) * sp, + vy: s.vy * 0.5 + Math.sin(outAngle) * sp, + angle: 0, + spin: (Math.random() - 0.5) * 5, + ttl: 1300, + ax: a.x - mx, + ay: a.y - my, + bx: b.x - mx, + by: b.y - my, + }); + } + debris.current = next; + }, []); + + const killShip = useCallback(() => { + const s = ship.current; + if (!s.alive || s.invuln > 0) return; + createDebris(s); + s.alive = false; + s.thrusting = false; + touch.current = { left: false, right: false, thrust: false }; + deathTimer.current = DEATH_FREEZE; + livesRef.current -= 1; + setLives(livesRef.current); + beep(400, 40, 0.6, 0.09, "sawtooth"); + }, [createDebris]); + + const respawn = useCallback((invuln: number) => { + const s = ship.current; + s.x = W / 2; + s.y = H / 2; + s.vx = 0; + s.vy = 0; + s.angle = -Math.PI / 2; + s.thrusting = false; + s.alive = true; + s.invuln = invuln; + debris.current = []; + }, []); + + const splitRock = useCallback( + (rock: Rock, awardPoints: boolean) => { + const spec = ROCK_SPECS[rock.size]; + if (awardPoints) addScore(spec.points); + beep( + rock.size === "large" ? 120 : rock.size === "medium" ? 180 : 240, + rock.size === "large" ? 30 : rock.size === "medium" ? 40 : 50, + rock.size === "large" ? 0.35 : rock.size === "medium" ? 0.25 : 0.18, + rock.size === "large" ? 0.08 : 0.06, + "sawtooth", + ); + const child = ROCK_CHILD[rock.size]; + const spawned: Rock[] = []; + if (child) { + for (let i = 0; i < 2; i++) spawned.push(makeRock(child, rock.x, rock.y)); + } + return spawned; + }, + [addScore], + ); + + const spawnSaucer = useCallback(() => { + const score = scoreRef.current; + let big: boolean; + if (score < 8000) big = true; + else if (score >= 30000) big = false; + else big = Math.random() < 0.5; + const spec = big ? BIG_SAUCER : SMALL_SAUCER; + const dir = Math.random() < 0.5 ? 1 : -1; + saucer.current = { + x: dir === 1 ? -spec.r : W + spec.r, + y: Math.random() * H, + vx: dir * SAUCER_SPEED, + vy: (Math.random() - 0.5) * 40, + big, + r: spec.r, + points: spec.points, + fireEvery: spec.fireEvery, + fireTimer: spec.fireEvery * (0.5 + Math.random()), + crossed: 0, + }; + beep(500, 900, 0.3, 0.04, "sine"); + }, []); + + const saucerFire = useCallback(() => { + const u = saucer.current; + if (!u) return; + let angle: number; + if (u.big) { + angle = Math.random() * Math.PI * 2; + } else { + const s = ship.current; + const dx = torDelta(u.x, s.x, W); + const dy = torDelta(u.y, s.y, H); + angle = Math.atan2(dy, dx) + ((Math.random() - 0.5) * 30 * Math.PI) / 180; + } + saucerBullets.current.push({ + x: u.x, + y: u.y, + vx: Math.cos(angle) * SAUCER_BULLET_SPEED, + vy: Math.sin(angle) * SAUCER_BULLET_SPEED, + ttl: SAUCER_BULLET_LIFE, + }); + beep(300, 600, 0.1, 0.05, "square"); + }, []); + + const nextWave = useCallback(() => { + waveRef.current += 1; + setWave(waveRef.current); + showBanner(`WAVE ${waveRef.current.toString().padStart(2, "0")}`); + beep(440, 660, 0.3, 0.06, "triangle"); + spawnWave(waveRef.current); + }, [showBanner, spawnWave]); + + const doHyperspace = useCallback(() => { + const s = ship.current; + if (!s.alive || hyperCooldown.current > 0) return; + hyperCooldown.current = HYPERSPACE_COOLDOWN; + s.x = Math.random() * W; + s.y = Math.random() * H; + s.vx = 0; + s.vy = 0; + beep(1200, 200, 0.15, 0.05, "sine"); + // Classic risk: a small chance the jump ends in an explosion. + if (Math.random() < HYPERSPACE_DEATH_CHANCE) { + s.invuln = 0; + killShip(); + } + }, [killShip]); + + const centerClear = useCallback(() => { + for (const rock of rocks.current) { + if (torDist2(rock.x, rock.y, W / 2, H / 2) < (SAFE_RADIUS + rock.radius) ** 2) return false; + } + return true; + }, []); + + const tick = useCallback( + (dt: number) => { + const dts = dt / 1000; + const s = ship.current; + + fireCooldown.current = Math.max(0, fireCooldown.current - dt); + hyperCooldown.current = Math.max(0, hyperCooldown.current - dt); + + // --- ship control / physics --- + if (s.alive) { + if (s.invuln > 0) s.invuln = Math.max(0, s.invuln - dt); + const k = keys.current; + const left = k.has("arrowleft") || k.has("a") || touch.current.left; + const right = k.has("arrowright") || k.has("d") || touch.current.right; + if (left) s.angle -= SHIP_ROT * dts; + if (right) s.angle += SHIP_ROT * dts; + + s.thrusting = k.has("arrowup") || k.has("w") || touch.current.thrust; + if (s.thrusting) { + s.vx += Math.cos(s.angle) * SHIP_THRUST * dts; + s.vy += Math.sin(s.angle) * SHIP_THRUST * dts; + thrustSound.current -= dt; + if (thrustSound.current <= 0) { + thrustSound.current = 150; + beep(70, 55, 0.12, 0.04, "sawtooth"); + } + } + // Exponential drift damping, then hard speed cap. + const damp = Math.exp(-SHIP_FRICTION * dts); + s.vx *= damp; + s.vy *= damp; + const sp = Math.hypot(s.vx, s.vy); + if (sp > SHIP_MAX_SPEED) { + s.vx = (s.vx / sp) * SHIP_MAX_SPEED; + s.vy = (s.vy / sp) * SHIP_MAX_SPEED; + } + s.x = wrap(s.x + s.vx * dts, W); + s.y = wrap(s.y + s.vy * dts, H); + + if (k.has(" ")) tryFire(); + } else { + // Shattered — count down, then wait for a clear center to respawn. + if (deathTimer.current > 0) { + deathTimer.current -= dt; + } else if (livesRef.current <= 0) { + gameOver(); + return; + } else if (centerClear()) { + respawn(INVULN_TIME); + } + } + + // --- debris --- + if (debris.current.length > 0) { + for (const d of debris.current) { + d.x = wrap(d.x + d.vx * dts, W); + d.y = wrap(d.y + d.vy * dts, H); + d.angle += d.spin * dts; + d.ttl -= dt; + } + debris.current = debris.current.filter((d) => d.ttl > 0); + } + + // --- bullets --- + for (const b of bullets.current) { + b.x = wrap(b.x + b.vx * dts, W); + b.y = wrap(b.y + b.vy * dts, H); + b.ttl -= dt; + } + bullets.current = bullets.current.filter((b) => b.ttl > 0); + + // --- rocks --- + for (const rock of rocks.current) { + rock.x = wrap(rock.x + rock.vx * dts, W); + rock.y = wrap(rock.y + rock.vy * dts, H); + rock.angle += rock.spin * dts; + } + + // --- saucer --- + if (saucer.current) { + const u = saucer.current; + u.x += u.vx * dts; + u.crossed += Math.abs(u.vx) * dts; + u.y = wrap(u.y + u.vy * dts, H); + warbleSound.current -= dt; + if (warbleSound.current <= 0) { + warbleSound.current = 550; + beep(u.big ? 440 : 620, u.big ? 620 : 440, 0.14, 0.035, "sine"); + } + u.fireTimer -= dt; + if (u.fireTimer <= 0) { + u.fireTimer = u.fireEvery * (0.7 + Math.random() * 0.6); + saucerFire(); + } + // Leave once it has travelled the full width plus a margin. + if (u.crossed > W + u.r * 2) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + } + } else { + saucerTimer.current -= dt; + if (saucerTimer.current <= 0) spawnSaucer(); + } + + // --- saucer bullets --- + for (const b of saucerBullets.current) { + b.x = wrap(b.x + b.vx * dts, W); + b.y = wrap(b.y + b.vy * dts, H); + b.ttl -= dt; + } + saucerBullets.current = saucerBullets.current.filter((b) => b.ttl > 0); + + // --- collisions: player bullets vs rocks / saucer --- + const survivingRocks: Rock[] = []; + const spentBullets = new Set(); + for (const rock of rocks.current) { + let hit = false; + for (const b of bullets.current) { + if (spentBullets.has(b)) continue; + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, rock.x, rock.y) < rock.radius ** 2) { + hit = true; + spentBullets.add(b); + survivingRocks.push(...splitRock(rock, true)); + break; + } + } + if (!hit) survivingRocks.push(rock); + } + rocks.current = survivingRocks; + + if (saucer.current) { + const u = saucer.current; + for (const b of bullets.current) { + if (spentBullets.has(b)) continue; + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, u.x, u.y) < u.r ** 2) { + spentBullets.add(b); + addScore(u.points); + beep(900, 120, 0.35, 0.08, "sawtooth"); + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + break; + } + } + } + if (spentBullets.size > 0) { + bullets.current = bullets.current.filter((b) => !spentBullets.has(b)); + } + + // --- collisions: saucer vs rocks (splits, no points) --- + if (saucer.current) { + const u = saucer.current; + const kept: Rock[] = []; + let smashed = false; + for (const rock of rocks.current) { + if (!smashed && torDist2(u.x, u.y, rock.x, rock.y) < (u.r + rock.radius) ** 2) { + smashed = true; + kept.push(...splitRock(rock, false)); + } else { + kept.push(rock); + } + } + rocks.current = kept; + if (smashed) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + } + } + + // --- collisions vs ship --- + if (s.alive && s.invuln <= 0) { + for (const rock of rocks.current) { + if (torDist2(s.x, s.y, rock.x, rock.y) < (rock.radius + SHIP_RADIUS) ** 2) { + killShip(); + break; + } + } + } + if (s.alive && s.invuln <= 0) { + for (const b of saucerBullets.current) { + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, s.x, s.y) < (SHIP_RADIUS + 3) ** 2) { + saucerBullets.current = saucerBullets.current.filter((x) => x !== b); + killShip(); + break; + } + } + } + if (s.alive && s.invuln <= 0 && saucer.current) { + const u = saucer.current; + if (torDist2(s.x, s.y, u.x, u.y) < (u.r + SHIP_RADIUS) ** 2) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + killShip(); + } + } + + // --- wave progression --- + if (rocks.current.length === 0) { + if (waveDelay.current <= 0) waveDelay.current = 1600; + else { + waveDelay.current -= dt; + if (waveDelay.current <= 0) { + waveDelay.current = 0; + nextWave(); + } + } + } + + // --- two-tone heartbeat, faster as the field thins out --- + const mass = rocks.current.reduce((sum, r) => sum + ROCK_SPECS[r.size].mass, 0); + beatTimer.current -= dt; + if (beatTimer.current <= 0 && mass > 0 && phaseRef.current === "playing") { + beatTimer.current = Math.max(240, 200 + mass * 28); + beatHigh.current = !beatHigh.current; + beep(beatHigh.current ? 60 : 44, beatHigh.current ? 60 : 44, 0.12, 0.05, "triangle"); + } + }, + [ + tryFire, + gameOver, + centerClear, + respawn, + splitRock, + addScore, + spawnSaucer, + saucerFire, + killShip, + nextWave, + ], + ); + + // --- drawing ------------------------------------------------------------ + + const strokePolygon = useCallback( + (ctx: CanvasRenderingContext2D, pts: [number, number][], ox: number, oy: number) => { + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const px = pts[i][0] + ox; + const py = pts[i][1] + oy; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.stroke(); + }, + [], + ); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const s = ship.current; + + ctx.fillStyle = "#04060d"; + ctx.fillRect(0, 0, W, H); + + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.shadowBlur = 8; + + // Wrapping offsets so shapes near an edge appear on the far side too. + const offsets = (x: number, y: number, r: number) => { + const list: [number, number][] = [[0, 0]]; + const ox = x < r ? W : x > W - r ? -W : 0; + const oy = y < r ? H : y > H - r ? -H : 0; + if (ox) list.push([ox, 0]); + if (oy) list.push([0, oy]); + if (ox && oy) list.push([ox, oy]); + return list; + }; + + // Rocks. + ctx.strokeStyle = COLOR; + ctx.shadowColor = COLOR; + for (const rock of rocks.current) { + const pts: [number, number][] = rock.shape.map((mult, i) => { + const a = rock.angle + (i / rock.shape.length) * Math.PI * 2; + return [ + rock.x + Math.cos(a) * rock.radius * mult, + rock.y + Math.sin(a) * rock.radius * mult, + ]; + }); + // Lobes reach 1.27x the nominal radius (0.72 + 0.55 max multiplier), so + // widen the wrap margin to that extent to avoid seam pop-in. + for (const [ox, oy] of offsets(rock.x, rock.y, rock.radius * 1.27)) + strokePolygon(ctx, pts, ox, oy); + } + + // Player bullets. + ctx.fillStyle = COLOR; + ctx.shadowColor = COLOR; + for (const b of bullets.current) { + ctx.beginPath(); + ctx.arc(b.x, b.y, 2, 0, Math.PI * 2); + ctx.fill(); + } + + // Saucer bullets. + ctx.fillStyle = DANGER; + ctx.shadowColor = DANGER; + for (const b of saucerBullets.current) { + ctx.beginPath(); + ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2); + ctx.fill(); + } + + // Saucer — two stacked polygons. + if (saucer.current) { + const u = saucer.current; + ctx.strokeStyle = DANGER; + ctx.shadowColor = DANGER; + const r = u.r; + const body: [number, number][] = [ + [-r, 0], + [-r * 0.45, -r * 0.5], + [r * 0.45, -r * 0.5], + [r, 0], + [r * 0.45, r * 0.45], + [-r * 0.45, r * 0.45], + ]; + const dome: [number, number][] = [ + [-r * 0.45, -r * 0.5], + [-r * 0.22, -r], + [r * 0.22, -r], + [r * 0.45, -r * 0.5], + ]; + for (const [ox, oy] of offsets(u.x, u.y, r)) { + strokePolygon(ctx, body, u.x + ox, u.y + oy); + ctx.beginPath(); + ctx.moveTo(u.x + ox - r, u.y + oy); + ctx.lineTo(u.x + ox + r, u.y + oy); + ctx.stroke(); + strokePolygon(ctx, dome, u.x + ox, u.y + oy); + } + } + + // Debris (shattered ship). + ctx.strokeStyle = COLOR; + ctx.shadowColor = COLOR; + for (const d of debris.current) { + const cos = Math.cos(d.angle); + const sin = Math.sin(d.angle); + ctx.beginPath(); + ctx.moveTo(d.x + d.ax * cos - d.ay * sin, d.y + d.ax * sin + d.ay * cos); + ctx.lineTo(d.x + d.bx * cos - d.by * sin, d.y + d.bx * sin + d.by * cos); + ctx.stroke(); + } + + // Ship — blinks while invulnerable. + const blink = s.invuln > 0 && Math.floor(s.invuln / 120) % 2 === 0; + if (s.alive && !blink) { + const cos = Math.cos(s.angle); + const sin = Math.sin(s.angle); + const pts: [number, number][] = SHIP_SHAPE.map(([px, py]) => [ + s.x + px * cos - py * sin, + s.y + px * sin + py * cos, + ]); + ctx.strokeStyle = COLOR; + ctx.shadowColor = COLOR; + for (const [ox, oy] of offsets(s.x, s.y, 16)) strokePolygon(ctx, pts, ox, oy); + // Thrust flame flickers behind the notch. + if (s.thrusting && Math.random() < 0.6) { + const flame: [number, number][] = [ + [-6, -4], + [-16 - Math.random() * 5, 0], + [-6, 4], + ]; + const fpts: [number, number][] = flame.map(([px, py]) => [ + s.x + px * cos - py * sin, + s.y + px * sin + py * cos, + ]); + ctx.beginPath(); + ctx.moveTo(fpts[0][0], fpts[0][1]); + ctx.lineTo(fpts[1][0], fpts[1][1]); + ctx.lineTo(fpts[2][0], fpts[2][1]); + ctx.stroke(); + } + } + + ctx.shadowBlur = 0; + }, [strokePolygon]); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — a death + // reports through gameOver(), so this only covers the quit path. + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + tick(dt); + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, tick, draw]); + + const start = useCallback(() => { + beep(440, 880, 0.12); + showBanner("WAVE 01"); + changePhase("playing"); + }, [changePhase, showBanner]); + + useEffect(() => { + const GAME_KEYS = [ + "arrowleft", + "arrowright", + "arrowup", + "arrowdown", + "a", + "d", + "w", + " ", + "shift", + ]; + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const currentPhase = phaseRef.current; + + if (GAME_KEYS.includes(key)) { + event.preventDefault(); + keys.current.add(key); + if (currentPhase === "ready") { + start(); + return; + } + if (currentPhase === "playing") { + if (key === " " && !event.repeat) tryFire(); + if ((key === "shift" || key === "arrowdown") && !event.repeat) doHyperspace(); + } + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + const onKeyUp = (event: KeyboardEvent) => { + keys.current.delete(event.key.toLowerCase()); + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, [start, changePhase, reset, tryFire, doHyperspace]); + + const canvasX = useCallback((clientX: number) => { + const canvas = canvasRef.current; + if (!canvas) return W / 2; + const rect = canvas.getBoundingClientRect(); + return ((clientX - rect.left) / rect.width) * W; + }, []); + + const applyZones = useCallback( + (touches: React.TouchList) => { + let left = false; + let right = false; + let thrust = false; + for (let i = 0; i < touches.length; i++) { + const x = canvasX(touches[i].clientX); + if (x < W / 3) left = true; + else if (x > (2 * W) / 3) right = true; + else thrust = true; + } + touch.current = { left, right, thrust }; + }, + [canvasX], + ); + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") { + start(); + return; + } + if (currentPhase === "over") { + reset(); + return; + } + if (currentPhase === "paused") { + changePhase("playing"); + return; + } + // A deliberate two-finger *tap* triggers hyperspace: the second finger + // must land shortly after a still first touch. Otherwise the extra finger + // is part of the hold-to-turn-and-thrust scheme, so fall through to zones. + if (event.touches.length >= 2) { + const info = tapInfo.current; + if (info && !info.moved && performance.now() - info.t < 250) { + doHyperspace(); + tapInfo.current = null; + applyZones(event.touches); + return; + } + tapInfo.current = null; + applyZones(event.touches); + return; + } + const t = event.touches[0]; + tapInfo.current = { t: performance.now(), x: t.clientX, y: t.clientY, moved: false }; + applyZones(event.touches); + }, + [start, reset, changePhase, doHyperspace, applyZones], + ); + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + const info = tapInfo.current; + if (info) { + const t = event.touches[0]; + if (t && (Math.abs(t.clientX - info.x) > 12 || Math.abs(t.clientY - info.y) > 12)) { + info.moved = true; + } + } + if (phaseRef.current === "playing") applyZones(event.touches); + }, + [applyZones], + ); + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 0) { + const info = tapInfo.current; + // A brief, still touch is a fire tap. + if ( + info && + !info.moved && + performance.now() - info.t < 250 && + phaseRef.current === "playing" + ) { + tryFire(); + } + tapInfo.current = null; + touch.current = { left: false, right: false, thrust: false }; + } else if (phaseRef.current === "playing") { + applyZones(event.touches); + } + }, + [tryFire, applyZones], + ); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + WAVE {wave.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ + {banner && phase === "playing" &&
{banner}
} + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + Press any key to launch — or tap + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Enter or tap to play again + + )} +
+ )} +
+
+

◀ ▶ TURN — ▲ THRUST — SPACE FIRE — ⇧ JUMP — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/_components/muncher-game.tsx b/apps/site/src/app/arcade/_components/muncher-game.tsx new file mode 100644 index 0000000000..de7581fe95 --- /dev/null +++ b/apps/site/src/app/arcade/_components/muncher-game.tsx @@ -0,0 +1,976 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const TILE = 24; +const COLS = 19; +const ROWS = 21; +const W = COLS * TILE; // 456 +const H = ROWS * TILE; // 504 + +// Original maze — 19x21, left-right symmetric, one wraparound tunnel row (T), +// a central enemy den (G) with a single top gap, and four corner pellets (o). +// prettier-ignore +const MAZE = [ + "###################", + "#.................#", + "#o##.##.#.#.##.##o#", + "#.................#", + "#.##.##.#.#.##.##.#", + "#.................#", + "#.##.##.#.#.##.##.#", + "#.................#", + "#.##.####.####.##.#", + "#.##.###GGG###.##.#", + "T......#GGG#......T", + "#.##.###GGG###.##.#", + "#.##.#########.##.#", + "#.................#", + "#.##.##.#.#.##.##.#", + "#........P........#", + "#.##.##.#.#.##.##.#", + "#.................#", + "#o##.##.#.#.##.##o#", + "#.................#", + "###################", +]; + +const WALL_FILL = "#3b3fc4"; +const WALL_EDGE = "#6d72ff"; +const DOT_COLOR = "#facc15"; + +// Den geometry (interior + the single exit tile above the gap). +const DEN_C = 9; +const DEN_TOP = 9; +const DEN_BOTTOM = 11; +const DEN_CENTER_R = 10; +const GATE_R = 7; // corridor tile the enemies emerge onto + +// Speeds in tiles per second. +const PLAYER_SPEED = 7; +const ENEMY_SPEED = 6.5; +const FRIGHT_SPEED = 4.5; +const EYES_SPEED = 13; +const DEN_SPEED = 5; + +type Phase = "ready" | "playing" | "paused" | "over"; +type DirName = "up" | "down" | "left" | "right"; +type EnemyKind = "chaser" | "ambusher" | "wanderer" | "patroller"; +type EnemyMode = "chase" | "frightened" | "eyes"; +type EnemyState = "den" | "leaving" | "out"; + +type Vec = { x: number; y: number }; +type Actor = { c: number; r: number; prog: number; dir: DirName }; +type Player = Actor & { desired: DirName }; +type Enemy = Actor & { + kind: EnemyKind; + color: string; + mode: EnemyMode; + state: EnemyState; + release: number; + home: { c: number; r: number }; + spawn: { c: number; r: number }; +}; +type Fruit = { c: number; r: number; ttl: number; value: number }; + +const DIRS: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +}; +const OPPOSITE: Record = { + up: "down", + down: "up", + left: "right", + right: "left", +}; +const DIR_ORDER: DirName[] = ["up", "left", "down", "right"]; +const KEY_DIRS: Record = { + arrowup: "up", + arrowdown: "down", + arrowleft: "left", + arrowright: "right", + w: "up", + s: "down", + a: "left", + d: "right", +}; + +const wrapCol = (c: number) => (c + COLS) % COLS; + +function tileChar(c: number, r: number) { + if (r < 0 || r >= ROWS) return "#"; + return MAZE[r][wrapCol(c)]; +} + +/** Walls block everyone; den tiles block anyone not allowed inside. */ +function isBlocked(c: number, r: number, allowDen: boolean) { + const ch = tileChar(c, r); + if (ch === "#") return true; + if (ch === "G" && !allowDen) return true; + return false; +} + +function canStep(c: number, r: number, dir: DirName, allowDen: boolean) { + const d = DIRS[dir]; + return !isBlocked(wrapCol(c + d.x), r + d.y, allowDen); +} + +/** + * Flip an actor's heading without teleporting it. The actor is partway from its + * current tile toward the tile ahead; reversing re-roots it on that forward tile + * and inverts the progress, so the on-screen position is unchanged. + */ +function reverseActor(a: Actor) { + a.c = wrapCol(a.c + DIRS[a.dir].x); + a.r += DIRS[a.dir].y; + a.dir = OPPOSITE[a.dir]; + a.prog = 1 - a.prog; +} + +function findSpawn(): { c: number; r: number } { + for (let r = 0; r < ROWS; r++) { + const c = MAZE[r].indexOf("P"); + if (c >= 0) return { c, r }; + } + return { c: 9, r: 15 }; +} + +const PLAYER_SPAWN = findSpawn(); +let TOTAL_DOTS = 0; +for (const row of MAZE) { + for (const ch of row) if (ch === "." || ch === "o") TOTAL_DOTS++; +} + +const ENEMY_DEFS: Omit[] = [ + { + kind: "chaser", + color: "#f87171", + c: 9, + r: 9, + release: 0, + home: { c: 17, r: 1 }, + spawn: { c: 9, r: 9 }, + }, + { + kind: "ambusher", + color: "#f472b6", + c: 8, + r: 10, + release: 3000, + home: { c: 1, r: 1 }, + spawn: { c: 8, r: 10 }, + }, + { + kind: "wanderer", + color: "#22d3ee", + c: 10, + r: 10, + release: 6000, + home: { c: 17, r: 19 }, + spawn: { c: 10, r: 10 }, + }, + { + kind: "patroller", + color: "#fb923c", + c: 9, + r: 11, + release: 9000, + home: { c: 1, r: 19 }, + spawn: { c: 9, r: 11 }, + }, +]; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +function makeEnemy(def: (typeof ENEMY_DEFS)[number]): Enemy { + return { + ...def, + c: def.spawn.c, + r: def.spawn.r, + prog: 0, + dir: "up", + mode: "chase", + state: "den", + }; +} + +function levelSpeedMult(level: number) { + return Math.min(1.3, 1 + 0.05 * (level - 1)); +} + +function frightenedDuration(level: number) { + return Math.max(2000, 6000 - 500 * (level - 1)); +} + +export function MuncherGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + const player = useRef({ ...PLAYER_SPAWN, prog: 0, dir: "left", desired: "left" }); + const enemies = useRef(ENEMY_DEFS.map(makeEnemy)); + const dots = useRef([]); + const dotCount = useRef(0); + const fruit = useRef(null); + const fruitStage = useRef(0); + + const denClock = useRef(0); + const patrolTimer = useRef(0); + const patrolChase = useRef(true); + const frightTimer = useRef(0); + const eatValue = useRef(200); + const freeze = useRef(0); + const pending = useRef<"death" | null>(null); + const anim = useRef(0); + + const scoreRef = useRef(0); + const livesRef = useRef(3); + const levelRef = useRef(1); + const phaseRef = useRef("ready"); + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); + const [level, setLevel] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const showBanner = useCallback((text: string) => { + setBanner(text); + window.clearTimeout(bannerTimeout.current); + bannerTimeout.current = window.setTimeout(() => setBanner(null), 1400); + }, []); + + useEffect(() => () => window.clearTimeout(bannerTimeout.current), []); + + const buildDots = useCallback(() => { + const grid: number[][] = []; + let count = 0; + for (let r = 0; r < ROWS; r++) { + const row: number[] = []; + for (let c = 0; c < COLS; c++) { + const ch = MAZE[r][c]; + if (ch === ".") { + row.push(1); + count++; + } else if (ch === "o") { + row.push(2); + count++; + } else { + row.push(0); + } + } + grid.push(row); + } + dots.current = grid; + dotCount.current = count; + }, []); + + const placeActors = useCallback(() => { + player.current = { ...PLAYER_SPAWN, prog: 0, dir: "left", desired: "left" }; + enemies.current = ENEMY_DEFS.map(makeEnemy); + denClock.current = 0; + patrolTimer.current = 0; + patrolChase.current = true; + frightTimer.current = 0; + eatValue.current = 200; + }, []); + + const reset = useCallback(() => { + scoreRef.current = 0; + livesRef.current = 3; + levelRef.current = 1; + setScore(0); + setLives(3); + setLevel(1); + bestAtRoundStart.current = hiScoreRef.current; + buildDots(); + fruit.current = null; + fruitStage.current = 0; + freeze.current = 0; + pending.current = null; + anim.current = 0; + placeActors(); + changePhase("ready"); + }, [buildDots, placeActors, changePhase]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + changePhase("over"); + onGameOverRef.current(scoreRef.current); + }, [changePhase]); + + const start = useCallback(() => { + beep(440, 880, 0.12); + showBanner("READY!"); + changePhase("playing"); + }, [changePhase, showBanner]); + + // Alternating two-note "waka" as dots are eaten. + const wakaHigh = useRef(false); + + const eatAt = useCallback((c: number, r: number) => { + const cell = dots.current[r]?.[c]; + if (!cell) return; + if (cell === 1) { + scoreRef.current += 10; + wakaHigh.current = !wakaHigh.current; + beep(wakaHigh.current ? 320 : 240, wakaHigh.current ? 260 : 200, 0.05, 0.04, "square"); + } else { + scoreRef.current += 50; + // Power pellet — frighten every active enemy and reverse it. + frightTimer.current = frightenedDuration(levelRef.current); + eatValue.current = 200; + for (const e of enemies.current) { + if (e.state === "out" && e.mode !== "eyes") { + e.mode = "frightened"; + reverseActor(e); + } + } + beep(180, 520, 0.3, 0.07, "square"); + } + dots.current[r][c] = 0; + dotCount.current -= 1; + setScore(scoreRef.current); + + // Fruit surfaces twice per level, at roughly a third and two thirds eaten. + const eaten = TOTAL_DOTS - dotCount.current; + const thresholds = [Math.floor(TOTAL_DOTS * 0.32), Math.floor(TOTAL_DOTS * 0.66)]; + if (fruitStage.current < 2 && eaten >= thresholds[fruitStage.current] && !fruit.current) { + fruit.current = { + c: DEN_C, + r: 13, + ttl: 9000, + value: 100 + 100 * levelRef.current, + }; + fruitStage.current += 1; + } + }, []); + + // --- movement ----------------------------------------------------------- + + const advance = useCallback( + ( + a: Actor, + speed: number, + dt: number, + allowDen: (a: Actor) => boolean, + onArrive: (a: Actor) => void, + ) => { + a.prog += speed * (dt / 1000); + let guard = 0; + while (a.prog >= 1 && guard++ < 8) { + a.prog -= 1; + a.c = wrapCol(a.c + DIRS[a.dir].x); + a.r += DIRS[a.dir].y; + onArrive(a); + if (!canStep(a.c, a.r, a.dir, allowDen(a))) { + a.prog = 0; + break; + } + } + }, + [], + ); + + const updatePlayer = useCallback( + (dt: number) => { + const p = player.current; + // Buffered turning: apply the queued direction whenever it becomes legal. + if (p.prog === 0 && canStep(p.c, p.r, p.desired, false)) p.dir = p.desired; + if (p.prog === 0 && !canStep(p.c, p.r, p.dir, false)) return; + advance( + p, + PLAYER_SPEED * levelSpeedMult(levelRef.current), + dt, + () => false, + (a) => { + const pl = a as Player; + const f = fruit.current; + eatAt(pl.c, pl.r); + if (f && f === fruit.current && f.c === pl.c && f.r === pl.r) { + scoreRef.current += f.value; + setScore(scoreRef.current); + fruit.current = null; + beep(700, 1200, 0.25, 0.07, "triangle"); + } + if (canStep(pl.c, pl.r, pl.desired, false)) pl.dir = pl.desired; + }, + ); + }, + [advance, eatAt], + ); + + const chooseEnemyDir = useCallback((e: Enemy) => { + const allowDen = e.mode === "eyes"; + const opp = OPPOSITE[e.dir]; + let options = DIR_ORDER.filter((d) => d !== opp && canStep(e.c, e.r, d, allowDen)); + if (options.length === 0) options = DIR_ORDER.filter((d) => canStep(e.c, e.r, d, allowDen)); + if (options.length === 0) return; + + if (e.mode === "frightened") { + e.dir = options[Math.floor(Math.random() * options.length)]; + return; + } + if (e.kind === "wanderer" && e.mode === "chase") { + e.dir = options[Math.floor(Math.random() * options.length)]; + return; + } + + let target: { c: number; r: number }; + const p = player.current; + if (e.mode === "eyes") { + target = { c: DEN_C, r: DEN_CENTER_R }; + } else if (e.kind === "chaser") { + target = { c: p.c, r: p.r }; + } else if (e.kind === "ambusher") { + target = { c: p.c + 4 * DIRS[p.dir].x, r: p.r + 4 * DIRS[p.dir].y }; + } else { + // patroller: alternates chasing and retreating to its home corner + target = patrolChase.current ? { c: p.c, r: p.r } : e.home; + } + + let best = options[0]; + let bestDist = Infinity; + // Iterate in the classic priority order so ties resolve deterministically. + for (const d of DIR_ORDER) { + if (!options.includes(d)) continue; + const nc = e.c + DIRS[d].x; + const nr = e.r + DIRS[d].y; + const dist = (nc - target.c) ** 2 + (nr - target.r) ** 2; + if (dist < bestDist) { + bestDist = dist; + best = d; + } + } + e.dir = best; + }, []); + + const onEnemyArrive = useCallback( + (e: Enemy) => { + if (e.state === "den") { + // Bounce inside the den until the release timer lets it leave. + if (e.r <= DEN_TOP) e.dir = "down"; + else if (e.r >= DEN_BOTTOM) e.dir = "up"; + return; + } + if (e.state === "leaving") { + // Slide to the exit column, then climb out through the gate. + if (e.c !== DEN_C) e.dir = e.c < DEN_C ? "right" : "left"; + else if (e.r > GATE_R) e.dir = "up"; + else { + e.state = "out"; + e.mode = frightTimer.current > 0 ? "frightened" : "chase"; + e.dir = Math.random() < 0.5 ? "left" : "right"; + } + return; + } + // Eyes that have made it home turn around and re-enter play. + if (e.mode === "eyes" && e.c === DEN_C && e.r === DEN_CENTER_R) { + e.mode = "chase"; + e.state = "leaving"; + e.dir = "up"; + return; + } + chooseEnemyDir(e); + }, + [chooseEnemyDir], + ); + + const enemyAllowDen = useCallback((a: Actor) => { + const e = a as Enemy; + return e.state === "den" || e.state === "leaving" || e.mode === "eyes"; + }, []); + + const enemySpeed = useCallback((e: Enemy) => { + if (e.mode === "eyes") return EYES_SPEED; + if (e.state === "den" || e.state === "leaving") return DEN_SPEED; + if (e.mode === "frightened") return FRIGHT_SPEED; + return ENEMY_SPEED * levelSpeedMult(levelRef.current); + }, []); + + const nextLevel = useCallback(() => { + levelRef.current += 1; + setLevel(levelRef.current); + showBanner(`LEVEL ${levelRef.current.toString().padStart(2, "0")}`); + beep(523, 1568, 0.45, 0.07, "triangle"); + buildDots(); + fruit.current = null; + fruitStage.current = 0; + placeActors(); + freeze.current = 1600; + }, [showBanner, buildDots, placeActors]); + + const killPlayer = useCallback(() => { + beep(520, 60, 0.6, 0.08, "sawtooth"); + livesRef.current -= 1; + setLives(livesRef.current); + freeze.current = 1200; + pending.current = "death"; + }, []); + + const actorPixel = useCallback((a: Actor): Vec => { + let x = (a.c + 0.5) * TILE + DIRS[a.dir].x * a.prog * TILE; + let y = (a.r + 0.5) * TILE + DIRS[a.dir].y * a.prog * TILE; + if (x < 0) x += W; + if (x > W) x -= W; + return { x, y }; + }, []); + + const checkCollisions = useCallback(() => { + const p = player.current; + const pp = actorPixel(p); + for (const e of enemies.current) { + if (e.state !== "out" || e.mode === "eyes") continue; + const ep = actorPixel(e); + const rawDx = Math.abs(pp.x - ep.x); + const dx = Math.min(rawDx, W - rawDx); + if (dx > TILE * 0.55 || Math.abs(pp.y - ep.y) > TILE * 0.55) continue; + if (e.mode === "frightened") { + scoreRef.current += eatValue.current; + setScore(scoreRef.current); + eatValue.current = Math.min(1600, eatValue.current * 2); + e.mode = "eyes"; + beep(1000, 1600, 0.18, 0.07, "square"); + } else { + killPlayer(); + return; + } + } + }, [actorPixel, killPlayer]); + + const tick = useCallback( + (dt: number) => { + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && pending.current === "death") { + pending.current = null; + if (livesRef.current <= 0) { + gameOver(); + return; + } + placeActors(); + } + return; + } + + anim.current += dt; + denClock.current += dt; + + patrolTimer.current += dt; + if (patrolTimer.current >= 8000) { + patrolTimer.current -= 8000; + patrolChase.current = !patrolChase.current; + } + + if (frightTimer.current > 0) { + frightTimer.current -= dt; + if (frightTimer.current <= 0) { + for (const e of enemies.current) { + if (e.mode === "frightened") e.mode = "chase"; + } + } + } + + // Staggered release from the den. + for (const e of enemies.current) { + if (e.state === "den" && denClock.current >= e.release) e.state = "leaving"; + } + + updatePlayer(dt); + for (const e of enemies.current) { + advance(e, enemySpeed(e), dt, enemyAllowDen, (a) => onEnemyArrive(a as Enemy)); + } + + checkCollisions(); + if (freeze.current > 0) return; // a death was just triggered + + if (fruit.current) { + fruit.current.ttl -= dt; + if (fruit.current.ttl <= 0) fruit.current = null; + } + + if (dotCount.current <= 0) nextLevel(); + }, + [ + updatePlayer, + advance, + enemySpeed, + enemyAllowDen, + onEnemyArrive, + checkCollisions, + gameOver, + placeActors, + nextLevel, + ], + ); + + // --- drawing ------------------------------------------------------------ + + const drawEyes = useCallback( + (ctx: CanvasRenderingContext2D, x: number, y: number, dir: DirName, radius: number) => { + const off = radius * 0.55; + const look = DIRS[dir]; + for (const sx of [-1, 1]) { + ctx.fillStyle = "#f8fafc"; + ctx.beginPath(); + ctx.ellipse(x + sx * off, y, radius * 0.42, radius * 0.55, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#1e293b"; + ctx.beginPath(); + ctx.arc( + x + sx * off + look.x * radius * 0.22, + y + look.y * radius * 0.28, + radius * 0.22, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + }, + [], + ); + + const drawEnemy = useCallback( + (ctx: CanvasRenderingContext2D, e: Enemy) => { + const { x, y } = actorPixel(e); + const R = TILE * 0.4; + if (e.mode === "eyes") { + drawEyes(ctx, x, y, e.dir, R * 0.9); + return; + } + + const flashing = + e.mode === "frightened" && + frightTimer.current > 0 && + frightTimer.current < 2000 && + Math.floor(frightTimer.current / 220) % 2 === 0; + const body = e.mode === "frightened" ? (flashing ? "#f8fafc" : "#2036c8") : e.color; + + // Blocky critter body: rounded-square shell with two stubby antennae and + // a wavy foot fringe — deliberately not a domed ghost silhouette. + ctx.fillStyle = body; + ctx.beginPath(); + ctx.roundRect(x - R, y - R, R * 2, R * 2, [R * 0.7, R * 0.7, R * 0.28, R * 0.28]); + ctx.fill(); + // wavy feet + const wobble = Math.floor(anim.current / 130) % 2 === 0; + ctx.beginPath(); + for (let i = 0; i < 3; i++) { + const fx = x - R + (R * 2 * (i + 0.5)) / 3; + ctx.moveTo(fx - R * 0.33, y + R); + ctx.lineTo(fx, y + R - (wobble === (i % 2 === 0) ? R * 0.5 : R * 0.28)); + ctx.lineTo(fx + R * 0.33, y + R); + } + ctx.fillStyle = "#0a0118"; + ctx.fill(); + // antennae + ctx.strokeStyle = body; + ctx.lineWidth = 2; + for (const sx of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(x + sx * R * 0.4, y - R); + ctx.lineTo(x + sx * R * 0.7, y - R * 1.5); + ctx.stroke(); + ctx.fillStyle = body; + ctx.beginPath(); + ctx.arc(x + sx * R * 0.7, y - R * 1.55, R * 0.16, 0, Math.PI * 2); + ctx.fill(); + } + + if (e.mode === "frightened") { + ctx.fillStyle = flashing ? "#c81f4b" : "#a5f3fc"; + for (const sx of [-1, 1]) { + ctx.fillRect(x + sx * R * 0.42 - 2, y - 3, 4, 4); + } + ctx.strokeStyle = flashing ? "#c81f4b" : "#a5f3fc"; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i <= 4; i++) { + const fx = x - R * 0.6 + (R * 1.2 * i) / 4; + ctx.lineTo(fx, y + R * 0.45 + (i % 2 === 0 ? 0 : 3)); + } + ctx.stroke(); + } else { + drawEyes(ctx, x, y - R * 0.1, e.dir, R * 0.85); + } + }, + [actorPixel, drawEyes], + ); + + const drawMuncher = useCallback( + (ctx: CanvasRenderingContext2D) => { + const p = player.current; + const { x, y } = actorPixel(p); + const R = TILE * 0.42; + const moving = p.prog > 0 || canStep(p.c, p.r, p.dir, false); + const chomp = moving ? (Math.sin(anim.current / 55) + 1) / 2 : 0.15; + const mouth = (0.06 + chomp * 0.32) * Math.PI; + const base = + p.dir === "right" + ? 0 + : p.dir === "left" + ? Math.PI + : p.dir === "up" + ? -Math.PI / 2 + : Math.PI / 2; + ctx.fillStyle = "#facc15"; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.arc(x, y, R, base + mouth, base + Math.PI * 2 - mouth); + ctx.closePath(); + ctx.fill(); + }, + [actorPixel], + ); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + + ctx.fillStyle = "#060210"; + ctx.fillRect(0, 0, W, H); + + // Walls. + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (MAZE[r][c] !== "#") continue; + const x = c * TILE; + const y = r * TILE; + ctx.fillStyle = WALL_FILL; + ctx.beginPath(); + ctx.roundRect(x + 2, y + 2, TILE - 4, TILE - 4, 6); + ctx.fill(); + ctx.strokeStyle = WALL_EDGE; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + // Den gate — a thin bar across the exit. + ctx.fillStyle = "#ff6bcb"; + ctx.fillRect(DEN_C * TILE + 4, DEN_TOP * TILE - 1, TILE - 8, 3); + + // Dots and pellets. + const pulse = 3 + Math.sin(anim.current / 160) * 2; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const cell = dots.current[r]?.[c]; + if (!cell) continue; + const cx = c * TILE + TILE / 2; + const cy = r * TILE + TILE / 2; + ctx.fillStyle = DOT_COLOR; + if (cell === 1) { + ctx.fillRect(cx - 2, cy - 2, 4, 4); + } else { + const s = 5 + pulse; + ctx.fillRect(cx - s / 2, cy - s / 2, s, s); + } + } + } + + // Fruit — an original pixel cherry pair. + if (fruit.current) { + const f = fruit.current; + const fx = f.c * TILE + TILE / 2; + const fy = f.r * TILE + TILE / 2; + ctx.fillStyle = "#ef4444"; + ctx.beginPath(); + ctx.arc(fx - 4, fy + 4, 5, 0, Math.PI * 2); + ctx.arc(fx + 5, fy + 5, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#fca5a5"; + ctx.fillRect(fx - 6, fy + 1, 2, 2); + ctx.strokeStyle = "#4ade80"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(fx - 4, fy + 4); + ctx.lineTo(fx + 2, fy - 8); + ctx.lineTo(fx + 5, fy + 5); + ctx.stroke(); + ctx.fillStyle = "#4ade80"; + ctx.fillRect(fx + 1, fy - 10, 5, 3); + } + + drawMuncher(ctx); + for (const e of enemies.current) drawEnemy(ctx, e); + }, [drawMuncher, drawEnemy]); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — a death + // reports through gameOver(), so this only covers the quit path. + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + tick(dt); + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, tick, draw]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const currentPhase = phaseRef.current; + + if (key in KEY_DIRS) { + event.preventDefault(); + if (currentPhase === "ready") { + player.current.desired = KEY_DIRS[key]; + start(); + return; + } + if (currentPhase === "playing") player.current.desired = KEY_DIRS[key]; + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter" || key === " ") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [start, changePhase, reset]); + + const touchStart = useRef<{ x: number; y: number } | null>(null); + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") { + start(); + return; + } + if (currentPhase === "over") { + reset(); + return; + } + if (currentPhase === "paused") { + changePhase("playing"); + return; + } + const t = event.touches[0]; + touchStart.current = { x: t.clientX, y: t.clientY }; + }, + [start, reset, changePhase], + ); + + const onTouchMove = useCallback((event: React.TouchEvent) => { + const s = touchStart.current; + if (!s || phaseRef.current !== "playing") return; + const t = event.touches[0]; + const dx = t.clientX - s.x; + const dy = t.clientY - s.y; + if (Math.abs(dx) < 14 && Math.abs(dy) < 14) return; + player.current.desired = + Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up"; + touchStart.current = { x: t.clientX, y: t.clientY }; + }, []); + + const onTouchEnd = useCallback(() => { + touchStart.current = null; + }, []); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + LV {level.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ + {banner && phase === "playing" &&
{banner}
} + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + Press any key to munch — or tap + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Enter or tap to play again + + )} +
+ )} +
+
+

◀ ▶ ▲ ▼ MOVE — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/_components/pixel-sprite.tsx b/apps/site/src/app/arcade/_components/pixel-sprite.tsx new file mode 100644 index 0000000000..c3e68cadae --- /dev/null +++ b/apps/site/src/app/arcade/_components/pixel-sprite.tsx @@ -0,0 +1,25 @@ +import type { PixelGrid } from "../games"; + +/** Renders a character-grid pixel sprite as a crisp-edged SVG. */ +export function PixelSprite({ sprite, label }: { sprite: PixelGrid; label?: string }) { + const height = sprite.rows.length; + const width = Math.max(...sprite.rows.map((row) => row.length)); + + return ( + + {sprite.rows.flatMap((row, y) => + [...row].map((char, x) => { + const fill = sprite.palette[char]; + if (!fill) return null; + return ; + }), + )} + + ); +} diff --git a/apps/site/src/app/arcade/_components/snake-game.tsx b/apps/site/src/app/arcade/_components/snake-game.tsx new file mode 100644 index 0000000000..ef8b040498 --- /dev/null +++ b/apps/site/src/app/arcade/_components/snake-game.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const COLS = 21; +const ROWS = 21; +const CELL = 24; +const START_TICK_MS = 140; +const MIN_TICK_MS = 70; +const SPEEDUP_MS = 3; +const POINTS_PER_APPLE = 10; + +type Vec = { x: number; y: number }; +type Phase = "ready" | "playing" | "paused" | "over"; + +const KEY_DIRS: Record = { + arrowup: { x: 0, y: -1 }, + arrowdown: { x: 0, y: 1 }, + arrowleft: { x: -1, y: 0 }, + arrowright: { x: 1, y: 0 }, + w: { x: 0, y: -1 }, + s: { x: 0, y: 1 }, + a: { x: -1, y: 0 }, + d: { x: 1, y: 0 }, +}; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +export function SnakeGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + // Game state lives in refs — the rAF loop mutates it every tick without + // paying for a React render. Only phase/score cross into React state. + const snakeRef = useRef([]); + const dirRef = useRef({ x: 1, y: 0 }); + const queueRef = useRef([]); + const foodRef = useRef({ x: 0, y: 0 }); + const tickRef = useRef(START_TICK_MS); + const scoreRef = useRef(0); + const phaseRef = useRef("ready"); + const touchStart = useRef<{ x: number; y: number } | null>(null); + // Snapshot of the hi-score when the round began — the live prop updates + // as soon as onGameOver fires, so it can't be used to detect a new best. + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const placeFood = useCallback(() => { + const snake = snakeRef.current; + let food: Vec; + do { + food = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; + } while (snake.some((cell) => cell.x === food.x && cell.y === food.y)); + foodRef.current = food; + }, []); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + + ctx.fillStyle = "#060210"; + ctx.fillRect(0, 0, COLS * CELL, ROWS * CELL); + + ctx.strokeStyle = "rgba(74, 222, 128, 0.07)"; + ctx.lineWidth = 1; + for (let i = 1; i < COLS; i++) { + ctx.beginPath(); + ctx.moveTo(i * CELL + 0.5, 0); + ctx.lineTo(i * CELL + 0.5, ROWS * CELL); + ctx.stroke(); + } + for (let i = 1; i < ROWS; i++) { + ctx.beginPath(); + ctx.moveTo(0, i * CELL + 0.5); + ctx.lineTo(COLS * CELL, i * CELL + 0.5); + ctx.stroke(); + } + + const food = foodRef.current; + ctx.fillStyle = "#f87171"; + ctx.fillRect(food.x * CELL + 2, food.y * CELL + 2, CELL - 4, CELL - 4); + ctx.fillStyle = "#fecaca"; + ctx.fillRect(food.x * CELL + 4, food.y * CELL + 4, 5, 5); + + const snake = snakeRef.current; + snake.forEach((cell, i) => { + ctx.fillStyle = i === 0 ? "#bbf7d0" : i % 2 === 0 ? "#4ade80" : "#22c55e"; + ctx.fillRect(cell.x * CELL + 1, cell.y * CELL + 1, CELL - 2, CELL - 2); + }); + + // Eyes on the head, facing the direction of travel. + if (snake.length > 0) { + const head = snake[0]; + const dir = dirRef.current; + ctx.fillStyle = "#060210"; + const cx = head.x * CELL + CELL / 2; + const cy = head.y * CELL + CELL / 2; + const forward = 5; + const side = 5; + const eyeA = { + x: cx + dir.x * forward + dir.y * side - 2, + y: cy + dir.y * forward + dir.x * side - 2, + }; + const eyeB = { + x: cx + dir.x * forward - dir.y * side - 2, + y: cy + dir.y * forward - dir.x * side - 2, + }; + ctx.fillRect(eyeA.x, eyeA.y, 4, 4); + ctx.fillRect(eyeB.x, eyeB.y, 4, 4); + } + }, []); + + const reset = useCallback(() => { + const cx = Math.floor(COLS / 2); + const cy = Math.floor(ROWS / 2); + snakeRef.current = [ + { x: cx, y: cy }, + { x: cx - 1, y: cy }, + { x: cx - 2, y: cy }, + ]; + dirRef.current = { x: 1, y: 0 }; + queueRef.current = []; + tickRef.current = START_TICK_MS; + scoreRef.current = 0; + setScore(0); + bestAtRoundStart.current = hiScoreRef.current; + placeFood(); + changePhase("ready"); + }, [placeFood, changePhase]); + + const queueDirection = useCallback((dir: Vec) => { + const queue = queueRef.current; + const last = queue.length > 0 ? queue[queue.length - 1] : dirRef.current; + const isSame = last.x === dir.x && last.y === dir.y; + const isReverse = last.x + dir.x === 0 && last.y + dir.y === 0; + if (!isSame && !isReverse && queue.length < 2) { + queue.push(dir); + } + }, []); + + const step = useCallback(() => { + const next = queueRef.current.shift(); + if (next) dirRef.current = next; + + const snake = snakeRef.current; + const dir = dirRef.current; + const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; + const food = foodRef.current; + const eating = head.x === food.x && head.y === food.y; + + // The tail cell vacates this tick unless we're growing into it. + const body = eating ? snake : snake.slice(0, -1); + const hitWall = head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS; + const hitSelf = body.some((cell) => cell.x === head.x && cell.y === head.y); + + if (hitWall || hitSelf) { + beep(220, 55, 0.5, 0.08); + changePhase("over"); + onGameOver(scoreRef.current); + return; + } + + snake.unshift(head); + if (eating) { + scoreRef.current += POINTS_PER_APPLE; + setScore(scoreRef.current); + tickRef.current = Math.max(MIN_TICK_MS, tickRef.current - SPEEDUP_MS); + placeFood(); + beep(660, 990, 0.09); + } else { + snake.pop(); + } + }, [changePhase, onGameOver, placeFood]); + + const start = useCallback( + (dir?: Vec) => { + if (dir) { + dirRef.current = dir.x + dirRef.current.x === 0 ? dirRef.current : dir; + } + beep(440, 880, 0.12); + changePhase("playing"); + }, + [changePhase], + ); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — + // death already reports via step(), so only cover the quit path here. + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + + let raf = 0; + let last = performance.now(); + let acc = 0; + const frame = (now: number) => { + acc += now - last; + last = now; + while (acc >= tickRef.current && phaseRef.current === "playing") { + acc -= tickRef.current; + step(); + } + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, step, draw]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const dir = KEY_DIRS[key]; + const currentPhase = phaseRef.current; + + if (dir) { + event.preventDefault(); + if (currentPhase === "ready") start(dir); + else if (currentPhase === "playing") queueDirection(dir); + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter" || key === " ") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [start, queueDirection, changePhase, reset]); + + const onTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + touchStart.current = { x: touch.clientX, y: touch.clientY }; + }, []); + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + const startPoint = touchStart.current; + touchStart.current = null; + const currentPhase = phaseRef.current; + + if (currentPhase === "over") { + reset(); + return; + } + + if (!startPoint) return; + const touch = event.changedTouches[0]; + const dx = touch.clientX - startPoint.x; + const dy = touch.clientY - startPoint.y; + + if (Math.abs(dx) < 24 && Math.abs(dy) < 24) { + if (currentPhase === "ready") start(); + else if (currentPhase === "paused") changePhase("playing"); + return; + } + + const dir: Vec = + Math.abs(dx) > Math.abs(dy) ? { x: Math.sign(dx), y: 0 } : { x: 0, y: Math.sign(dy) }; + if (currentPhase === "ready") start(dir); + else if (currentPhase === "playing") queueDirection(dir); + }, + [start, queueDirection, changePhase, reset], + ); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + Press an arrow key or swipe to move + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Enter or tap to play again + + )} +
+ )} +
+
+

ARROWS / WASD MOVE — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/_components/stacker-game.tsx b/apps/site/src/app/arcade/_components/stacker-game.tsx new file mode 100644 index 0000000000..0d7d3a14b7 --- /dev/null +++ b/apps/site/src/app/arcade/_components/stacker-game.tsx @@ -0,0 +1,682 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const COLS = 10; +const ROWS = 20; +const CELL = 24; +const BOARD_X = 16; +const BOARD_Y = 16; +const W = 380; +const H = 512; +const PANEL_X = 276; + +const LINE_POINTS = [0, 100, 300, 500, 800]; +const LINES_PER_LEVEL = 10; +const CLEAR_FLASH_MS = 260; + +type Phase = "ready" | "playing" | "paused" | "over"; +type PieceType = "I" | "O" | "T" | "S" | "Z" | "J" | "L"; +type Cell = string | null; +type Active = { type: PieceType; rot: number; x: number; y: number }; + +const PIECE_DEFS: Record = { + I: { + color: "#22d3ee", + size: 4, + cells: [ + [0, 1], + [1, 1], + [2, 1], + [3, 1], + ], + }, + O: { + color: "#facc15", + size: 2, + cells: [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ], + }, + T: { + color: "#c084fc", + size: 3, + cells: [ + [1, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, + S: { + color: "#4ade80", + size: 3, + cells: [ + [1, 0], + [2, 0], + [0, 1], + [1, 1], + ], + }, + Z: { + color: "#f87171", + size: 3, + cells: [ + [0, 0], + [1, 0], + [1, 1], + [2, 1], + ], + }, + J: { + color: "#60a5fa", + size: 3, + cells: [ + [0, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, + L: { + color: "#fb923c", + size: 3, + cells: [ + [2, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, +}; + +const PIECE_TYPES = Object.keys(PIECE_DEFS) as PieceType[]; + +// Precompute all four rotation states for each piece (clockwise). +const ROTATIONS: Record = Object.fromEntries( + PIECE_TYPES.map((type) => { + const { size, cells } = PIECE_DEFS[type]; + const states: [number, number][][] = [cells]; + for (let i = 0; i < 3; i++) { + states.push(states[i].map(([x, y]) => [size - 1 - y, x] as [number, number])); + } + return [type, states]; + }), +) as Record; + +const KICKS = [0, -1, 1, -2, 2]; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +function emptyBoard(): Cell[][] { + return Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => null)); +} + +export function StackerGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + const board = useRef(emptyBoard()); + const active = useRef(null); + const bag = useRef([]); + const nextPiece = useRef("T"); + const keys = useRef(new Set()); + const gravityAcc = useRef(0); + const clearing = useRef([]); + const freeze = useRef(0); + const touchState = useRef<{ x: number; y: number; t: number; moved: number } | null>(null); + + const scoreRef = useRef(0); + const linesRef = useRef(0); + const levelRef = useRef(1); + const phaseRef = useRef("ready"); + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const [lines, setLines] = useState(0); + const [level, setLevel] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const showBanner = useCallback((text: string) => { + setBanner(text); + window.clearTimeout(bannerTimeout.current); + bannerTimeout.current = window.setTimeout(() => setBanner(null), 1200); + }, []); + + useEffect(() => () => window.clearTimeout(bannerTimeout.current), []); + + const drawFromBag = useCallback((): PieceType => { + if (bag.current.length === 0) { + const fresh = [...PIECE_TYPES]; + for (let i = fresh.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [fresh[i], fresh[j]] = [fresh[j], fresh[i]]; + } + bag.current = fresh; + } + return bag.current.pop() as PieceType; + }, []); + + const collides = useCallback((type: PieceType, rot: number, px: number, py: number) => { + for (const [cx, cy] of ROTATIONS[type][rot]) { + const x = px + cx; + const y = py + cy; + if (x < 0 || x >= COLS || y >= ROWS) return true; + if (y >= 0 && board.current[y][x]) return true; + } + return false; + }, []); + + const gameOver = useCallback(() => { + active.current = null; + beep(300, 40, 0.7, 0.09, "sawtooth"); + changePhase("over"); + onGameOverRef.current(scoreRef.current); + }, [changePhase]); + + const spawn = useCallback(() => { + const type = nextPiece.current; + nextPiece.current = drawFromBag(); + const piece: Active = { type, rot: 0, x: 3, y: -1 }; + if (collides(type, 0, piece.x, piece.y)) { + gameOver(); + return; + } + active.current = piece; + }, [drawFromBag, collides, gameOver]); + + const addScore = useCallback((points: number) => { + scoreRef.current += points; + setScore(scoreRef.current); + }, []); + + const lock = useCallback(() => { + const piece = active.current; + if (!piece) return; + let toppedOut = false; + for (const [cx, cy] of ROTATIONS[piece.type][piece.rot]) { + const y = piece.y + cy; + if (y < 0) { + toppedOut = true; + continue; + } + board.current[y][piece.x + cx] = PIECE_DEFS[piece.type].color; + } + active.current = null; + if (toppedOut) { + gameOver(); + return; + } + + const full: number[] = []; + for (let y = 0; y < ROWS; y++) { + if (board.current[y].every(Boolean)) full.push(y); + } + if (full.length > 0) { + clearing.current = full; + freeze.current = CLEAR_FLASH_MS; + if (full.length === 4) beep(400, 1400, 0.35, 0.08); + else beep(500, 900, 0.15, 0.06); + } else { + beep(150, 90, 0.06, 0.05); + spawn(); + } + }, [gameOver, spawn]); + + const finishClear = useCallback(() => { + const cleared = clearing.current.length; + board.current = board.current.filter((_, y) => !clearing.current.includes(y)); + while (board.current.length < ROWS) { + board.current.unshift(Array.from({ length: COLS }, () => null)); + } + clearing.current = []; + addScore(LINE_POINTS[cleared] * levelRef.current); + linesRef.current += cleared; + setLines(linesRef.current); + const newLevel = Math.floor(linesRef.current / LINES_PER_LEVEL) + 1; + if (newLevel > levelRef.current) { + levelRef.current = newLevel; + setLevel(newLevel); + showBanner(`LEVEL ${newLevel.toString().padStart(2, "0")}`); + beep(660, 1320, 0.2, 0.07); + } + spawn(); + }, [addScore, spawn, showBanner]); + + const move = useCallback( + (dx: number) => { + const piece = active.current; + if (!piece || freeze.current > 0) return; + if (!collides(piece.type, piece.rot, piece.x + dx, piece.y)) { + piece.x += dx; + } + }, + [collides], + ); + + const rotate = useCallback( + (dir: 1 | -1) => { + const piece = active.current; + if (!piece || freeze.current > 0) return; + const newRot = (piece.rot + dir + 4) % 4; + for (const kick of KICKS) { + if (!collides(piece.type, newRot, piece.x + kick, piece.y)) { + piece.rot = newRot; + piece.x += kick; + beep(300, 420, 0.04, 0.03); + return; + } + } + }, + [collides], + ); + + const softStep = useCallback(() => { + const piece = active.current; + if (!piece) return; + if (!collides(piece.type, piece.rot, piece.x, piece.y + 1)) { + piece.y += 1; + } else { + lock(); + } + }, [collides, lock]); + + const hardDrop = useCallback(() => { + const piece = active.current; + if (!piece || freeze.current > 0) return; + let dropped = 0; + while (!collides(piece.type, piece.rot, piece.x, piece.y + 1)) { + piece.y += 1; + dropped++; + } + addScore(dropped * 2); + beep(200, 70, 0.06, 0.05); + lock(); + }, [collides, addScore, lock]); + + const reset = useCallback(() => { + board.current = emptyBoard(); + bag.current = []; + scoreRef.current = 0; + linesRef.current = 0; + levelRef.current = 1; + setScore(0); + setLines(0); + setLevel(1); + bestAtRoundStart.current = hiScoreRef.current; + clearing.current = []; + freeze.current = 0; + gravityAcc.current = 0; + nextPiece.current = drawFromBag(); + active.current = null; + changePhase("ready"); + }, [drawFromBag, changePhase]); + + const start = useCallback(() => { + beep(440, 880, 0.12); + spawn(); + changePhase("playing"); + }, [spawn, changePhase]); + + const tick = useCallback( + (dt: number) => { + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && clearing.current.length > 0) { + finishClear(); + } + return; + } + if (!active.current) return; + + const softDropping = keys.current.has("arrowdown") || keys.current.has("s"); + const interval = softDropping ? 40 : Math.max(70, 800 * Math.pow(0.82, levelRef.current - 1)); + gravityAcc.current += dt; + while (gravityAcc.current >= interval) { + gravityAcc.current -= interval; + const before = active.current?.y ?? 0; + softStep(); + if (softDropping && active.current && active.current.y > before) { + addScore(1); + } + if (!active.current || freeze.current > 0) break; + } + }, + [softStep, finishClear, addScore], + ); + + const drawCell = useCallback( + (ctx: CanvasRenderingContext2D, px: number, py: number, color: string, size = CELL) => { + ctx.fillStyle = color; + ctx.fillRect(px, py, size, size); + ctx.fillStyle = "rgba(255, 255, 255, 0.3)"; + ctx.fillRect(px, py, size, 3); + ctx.fillRect(px, py, 3, size); + ctx.fillStyle = "rgba(0, 0, 0, 0.3)"; + ctx.fillRect(px, py + size - 3, size, 3); + ctx.fillRect(px + size - 3, py, 3, size); + }, + [], + ); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + + ctx.fillStyle = "#060210"; + ctx.fillRect(0, 0, W, H); + + // Board well. + ctx.fillStyle = "#0b0520"; + ctx.fillRect(BOARD_X, BOARD_Y, COLS * CELL, ROWS * CELL); + ctx.strokeStyle = "rgba(192, 132, 252, 0.5)"; + ctx.lineWidth = 2; + ctx.strokeRect(BOARD_X - 1, BOARD_Y - 1, COLS * CELL + 2, ROWS * CELL + 2); + ctx.strokeStyle = "rgba(192, 132, 252, 0.06)"; + ctx.lineWidth = 1; + for (let x = 1; x < COLS; x++) { + ctx.beginPath(); + ctx.moveTo(BOARD_X + x * CELL + 0.5, BOARD_Y); + ctx.lineTo(BOARD_X + x * CELL + 0.5, BOARD_Y + ROWS * CELL); + ctx.stroke(); + } + for (let y = 1; y < ROWS; y++) { + ctx.beginPath(); + ctx.moveTo(BOARD_X, BOARD_Y + y * CELL + 0.5); + ctx.lineTo(BOARD_X + COLS * CELL, BOARD_Y + y * CELL + 0.5); + ctx.stroke(); + } + + // Locked cells (clearing rows flash white). + for (let y = 0; y < ROWS; y++) { + const flashing = clearing.current.includes(y) && Math.floor(freeze.current / 65) % 2 === 0; + for (let x = 0; x < COLS; x++) { + const cell = board.current[y][x]; + if (!cell) continue; + drawCell(ctx, BOARD_X + x * CELL, BOARD_Y + y * CELL, flashing ? "#f8fafc" : cell); + } + } + + const piece = active.current; + if (piece) { + const { color } = PIECE_DEFS[piece.type]; + + // Ghost — where the piece would land. + let ghostY = piece.y; + while (!collides(piece.type, piece.rot, piece.x, ghostY + 1)) ghostY++; + if (ghostY !== piece.y) { + ctx.strokeStyle = "rgba(255, 255, 255, 0.25)"; + ctx.lineWidth = 2; + for (const [cx, cy] of ROTATIONS[piece.type][piece.rot]) { + const y = ghostY + cy; + if (y < 0) continue; + ctx.strokeRect( + BOARD_X + (piece.x + cx) * CELL + 2, + BOARD_Y + y * CELL + 2, + CELL - 4, + CELL - 4, + ); + } + } + + for (const [cx, cy] of ROTATIONS[piece.type][piece.rot]) { + const y = piece.y + cy; + if (y < 0) continue; + drawCell(ctx, BOARD_X + (piece.x + cx) * CELL, BOARD_Y + y * CELL, color); + } + } + + // Next-piece panel. + ctx.strokeStyle = "rgba(192, 132, 252, 0.5)"; + ctx.lineWidth = 2; + ctx.strokeRect(PANEL_X, BOARD_Y, 88, 88); + ctx.fillStyle = "#94a3b8"; + ctx.font = "11px monospace"; + ctx.fillText("NEXT", PANEL_X + 30, BOARD_Y + 14); + const next = nextPiece.current; + const def = PIECE_DEFS[next]; + const previewCell = 16; + const offsetX = PANEL_X + 44 - (def.size * previewCell) / 2; + const offsetY = BOARD_Y + 52 - previewCell; + for (const [cx, cy] of ROTATIONS[next][0]) { + drawCell(ctx, offsetX + cx * previewCell, offsetY + cy * previewCell, def.color, previewCell); + } + }, [collides, drawCell]); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — + // death already reports via gameOver(), so only cover the quit path here. + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + tick(dt); + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, tick, draw]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const currentPhase = phaseRef.current; + const gameKeys = [ + "arrowleft", + "arrowright", + "arrowdown", + "arrowup", + "a", + "d", + "s", + "w", + "x", + "z", + " ", + ]; + + if (gameKeys.includes(key)) { + event.preventDefault(); + keys.current.add(key); + if (currentPhase === "ready") { + start(); + return; + } + if (currentPhase !== "playing") return; + // Native key repeat gives us held-move for free; block it for + // rotate and hard drop, which must fire once per press. + if (key === "arrowleft" || key === "a") move(-1); + else if (key === "arrowright" || key === "d") move(1); + else if ((key === "arrowup" || key === "x" || key === "w") && !event.repeat) rotate(1); + else if (key === "z" && !event.repeat) rotate(-1); + else if (key === " " && !event.repeat) hardDrop(); + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + const onKeyUp = (event: KeyboardEvent) => { + keys.current.delete(event.key.toLowerCase()); + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, [start, move, rotate, hardDrop, changePhase, reset]); + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") { + start(); + return; + } + if (currentPhase === "over") { + reset(); + return; + } + if (currentPhase === "paused") { + changePhase("playing"); + return; + } + const touch = event.touches[0]; + touchState.current = { x: touch.clientX, y: touch.clientY, t: performance.now(), moved: 0 }; + }, + [start, reset, changePhase], + ); + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + const state = touchState.current; + if (!state || phaseRef.current !== "playing") return; + const touch = event.touches[0]; + const canvas = canvasRef.current; + const scale = canvas ? canvas.getBoundingClientRect().width / W : 1; + const threshold = CELL * scale; + // Drag sideways to slide the piece, one column per cell-width. + while (touch.clientX - state.x > threshold) { + move(1); + state.x += threshold; + state.moved++; + } + while (state.x - touch.clientX > threshold) { + move(-1); + state.x -= threshold; + state.moved++; + } + // Drag down to soft-drop. + while (touch.clientY - state.y > threshold) { + softStep(); + state.y += threshold; + state.moved++; + } + }, + [move, softStep], + ); + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + const state = touchState.current; + touchState.current = null; + if (!state || phaseRef.current !== "playing") return; + const touch = event.changedTouches[0]; + const dt = performance.now() - state.t; + const dy = touch.clientY - state.y; + // Fast flick down → hard drop; quick tap → rotate. + if (dt < 300 && dy > 60) hardDrop(); + else if (dt < 250 && state.moved === 0) rotate(1); + }, + [hardDrop, rotate], + ); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + LINES {lines.toString().padStart(3, "0")} + + + LV {level.toString().padStart(2, "0")} + + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ + {banner && phase === "playing" &&
{banner}
} + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + Press any key to start — or tap + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Enter or tap to play again + + )} +
+ )} +
+
+

◀ ▶ MOVE — ▲ ROTATE — ▼ SOFT — SPACE DROP — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/games.ts b/apps/site/src/app/arcade/games.ts new file mode 100644 index 0000000000..2de3221fd4 --- /dev/null +++ b/apps/site/src/app/arcade/games.ts @@ -0,0 +1,163 @@ +/** + * The Prisma Arcade game registry. + * + * Each game will get its own playable canvas implementation; for now each + * entry is a placeholder cabinet. Sprites are tiny pixel-art grids rendered + * by — one character per pixel, "." is transparent, every + * other character is looked up in the sprite's palette. + */ + +export type PixelGrid = { + rows: string[]; + palette: Record; +}; + +export type ArcadeGame = { + id: string; + title: string; + tagline: string; + /** Accent color used for the cabinet glow, per-game. */ + color: string; + /** Placeholder until global persistence lands. */ + hiScore: number; + status: "playable" | "coming-soon"; + sprite: PixelGrid; + blurb: string; +}; + +export const GAMES: ArcadeGame[] = [ + { + id: "snake", + title: "SNAKE", + tagline: "The one from your childhood.", + blurb: "Eat the apples. Grow the tail. Don't hit the walls — and don't bite yourself.", + color: "#4ade80", + hiScore: 0, + status: "playable", + sprite: { + palette: { G: "#4ade80", D: "#16a34a", R: "#f87171", W: "#f8fafc" }, + rows: [ + "............", + ".GGGGGGGGG..", + ".GW......G..", + ".G.......G..", + ".GGGGGGGGG..", + ".D..........", + ".D..........", + ".DGGGGGGGG..", + ".........G..", + "..RR.....G..", + "..RR..GGGG..", + "............", + ], + }, + }, + { + id: "invaders", + title: "INVADERS", + tagline: "Defend the planet. Again.", + blurb: "Wave after wave of aliens descend. Shoot them down before they reach the ground.", + color: "#22d3ee", + hiScore: 0, + status: "playable", + sprite: { + palette: { M: "#22d3ee", E: "#0f172a" }, + rows: [ + "...........", + "..M.....M..", + "...M...M...", + "..MMMMMMM..", + ".MM.MMM.MM.", + "MMMMMMMMMMM", + "M.MMMMMMM.M", + "M.M.....M.M", + "...MM.MM...", + "...........", + ], + }, + }, + { + id: "stacker", + title: "STACKER", + tagline: "The falling blocks. You know the ones.", + blurb: "Stack the falling pieces, clear the lines, chase the elusive four-at-once.", + color: "#c084fc", + hiScore: 0, + status: "playable", + sprite: { + palette: { + P: "#c084fc", + Y: "#facc15", + R: "#f87171", + G: "#4ade80", + B: "#60a5fa", + O: "#fb923c", + C: "#22d3ee", + }, + rows: [ + "............", + ".....P......", + "....PPP.....", + "............", + "............", + "........RR..", + "YY......RR..", + "YY.G..B..O..", + ".GG.BBB..O..", + "CCCC.....OO.", + ], + }, + }, + { + id: "muncher", + title: "MUNCHER", + tagline: "Chomp the maze. Dodge the critters.", + blurb: "Gobble every dot, grab a power pellet, and turn the tables on the bugs chasing you.", + color: "#facc15", + hiScore: 0, + status: "playable", + sprite: { + palette: { Y: "#facc15", W: "#fde68a" }, + rows: [ + "............", + "..YYYY......", + ".YYYYYY.....", + ".YYYY.......", + ".YYY....WW..", + ".YYY....WW..", + ".YYYY.......", + ".YYYYYY.....", + "..YYYY......", + "............", + "......WW....", + "......WW....", + ], + }, + }, + { + id: "meteors", + title: "METEORS", + tagline: "Drift, spin, shoot the rocks.", + blurb: "Blast the tumbling rocks to bits, dodge the flying saucer, and don't get boxed in.", + color: "#f8fafc", + hiScore: 0, + status: "playable", + sprite: { + palette: { W: "#f8fafc", D: "#94a3b8" }, + rows: [ + "............", + "....W.......", + "...WWW......", + "..WW.WW.....", + ".WWWWWWW....", + "............", + ".DDD....DD..", + "DD..DD.DDDD.", + "D....D.D..DD", + "DD..DD.DDDD.", + ".DDDD...DD..", + "............", + ], + }, + }, +]; diff --git a/apps/site/src/app/arcade/page.tsx b/apps/site/src/app/arcade/page.tsx new file mode 100644 index 0000000000..954b3acd86 --- /dev/null +++ b/apps/site/src/app/arcade/page.tsx @@ -0,0 +1,32 @@ +import { createPageMetadata } from "@/lib/page-metadata"; +import { Press_Start_2P, VT323 } from "next/font/google"; +import { ArcadeScreen } from "./_components/arcade-screen"; + +const pressStart = Press_Start_2P({ + weight: "400", + subsets: ["latin"], + variable: "--font-arcade", + display: "swap", +}); + +const vt323 = VT323({ + weight: "400", + subsets: ["latin"], + variable: "--font-arcade-alt", + display: "swap", +}); + +export const metadata = createPageMetadata({ + title: "Prisma Arcade | Insert Coin to Play", + description: + "Step into the Prisma Arcade — three retro games, global high scores, and zero quarters required. Schema Snake, Query Invaders, and Migration Breakout are coming soon.", + path: "/arcade", +}); + +export default function ArcadePage() { + return ( +
+ +
+ ); +} diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 34288d5e0b..ebf80e02ad 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -62,6 +62,11 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { setMounted(true); }, []); + // /arcade is a full-screen takeover experience with no site chrome + if (pathname.startsWith("/arcade")) { + return null; + } + const currentUtmParams: UtmParams = mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {}; @@ -90,6 +95,10 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { export function FooterWrapper() { const pathname = usePathname(); + if (pathname.startsWith("/arcade")) { + return null; + } + // Determine button variant based on pathname const getButtonVariant = (): ColorType => { if (orm.includes(pathname.split("?")[0])) { From 57d379b3f6c0cb530d9725ff1189d33acef53576 Mon Sep 17 00:00:00 2001 From: Shane Neubauer Date: Fri, 7 Aug 2026 17:57:50 +1000 Subject: [PATCH 2/5] Add games --- .../app/arcade/_components/arcade-screen.tsx | 4 +- .../app/arcade/_components/comet-cat-game.tsx | 413 ++++++++++++++++++ apps/site/src/app/arcade/games.ts | 31 ++ 3 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 apps/site/src/app/arcade/_components/comet-cat-game.tsx diff --git a/apps/site/src/app/arcade/_components/arcade-screen.tsx b/apps/site/src/app/arcade/_components/arcade-screen.tsx index a4db1a3426..95dbfe74d2 100644 --- a/apps/site/src/app/arcade/_components/arcade-screen.tsx +++ b/apps/site/src/app/arcade/_components/arcade-screen.tsx @@ -9,6 +9,7 @@ import { InvadersGame } from "./invaders-game"; import { StackerGame } from "./stacker-game"; import { MuncherGame } from "./muncher-game"; import { MeteorsGame } from "./meteors-game"; +import { CometCatGame } from "./comet-cat-game"; import styles from "./arcade.module.css"; const HI_SCORE_STORAGE_KEY = "prisma-arcade-hiscores"; @@ -21,6 +22,7 @@ const GAME_COMPONENTS: Record> = { stacker: StackerGame, muncher: MuncherGame, meteors: MeteorsGame, + comet: CometCatGame, }; const KONAMI = [ @@ -38,7 +40,7 @@ const KONAMI = [ const TICKER_ITEMS = [ "★ WELCOME TO THE PRISMA ARCADE ★", - "5 GAMES ★ FREE PLAY", + "6 GAMES ★ FREE PLAY", "GLOBAL HIGH SCORES COMING SOON", "NO QUARTERS REQUIRED", "TYPE-SAFE SINCE 2016", diff --git a/apps/site/src/app/arcade/_components/comet-cat-game.tsx b/apps/site/src/app/arcade/_components/comet-cat-game.tsx new file mode 100644 index 0000000000..f8ec98ca3a --- /dev/null +++ b/apps/site/src/app/arcade/_components/comet-cat-game.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { beep } from "./arcade-audio"; +import styles from "./arcade.module.css"; + +const W = 420; +const H = 520; +const GROUND_Y = H - 16; + +const CAT_X = 110; +const CAT_W = 30; +const CAT_H = 22; + +const GRAVITY = 1500; +const FLAP_VY = -420; +const MAX_FALL = 520; + +const PILLAR_W = 56; +const PILLAR_SPACING = 230; +const BASE_GAP = 150; +const BASE_SPEED = 145; + +// The new brand stripes, top to bottom — the comet tail. +const TAIL_COLORS = ["#7cdae1", "#edcd5f", "#e37780"]; +const TAIL_STRIPE_H = 7; + +type Phase = "ready" | "playing" | "paused" | "over"; +type Pillar = { x: number; gapY: number; passed: boolean }; +type TrailPoint = { x: number; y: number }; +type Star = { x: number; y: number; speed: number; size: number }; + +// Original pixel cat, 14x10 — gray tabby, NOT a pastry. +// prettier-ignore +const CAT_SPRITE = [ + "..DD......DD..", + ".DGGD....DGGD.", + ".DGGGDDDDGGGD.", + ".DGGGGGGGGGGD.", + "DGGGGGGGGGGGGD", + "DGGKKGGGGKKGGD", + "DGPGGGDDGGGPGD", + "DGGGGGGGGGGGGD", + ".DGGGGGGGGGGD.", + "..DDDDDDDDDD..", +]; + +const CAT_PALETTE: Record = { + G: "#9ca3af", + D: "#4b5563", + K: "#1f2937", + P: "#f2a0ac", +}; + +function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +export function CometCatGame({ + hiScore, + onGameOver, +}: { + hiScore: number; + onGameOver: (score: number) => void; +}) { + const canvasRef = useRef(null); + + const cat = useRef({ y: H / 2, vy: 0 }); + const pillars = useRef([]); + const trail = useRef([]); + const stars = useRef([]); + const worldX = useRef(0); + // Countdown for the post-collision tumble before the GAME OVER screen. + const dying = useRef(0); + + const scoreRef = useRef(0); + const phaseRef = useRef("ready"); + const bestAtRoundStart = useRef(0); + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const speed = useCallback(() => Math.min(220, BASE_SPEED + scoreRef.current * 1.5), []); + const gap = useCallback(() => Math.max(120, BASE_GAP - scoreRef.current * 0.5), []); + + const spawnPillar = useCallback((x: number) => { + const margin = 90; + pillars.current.push({ + x, + gapY: margin + Math.random() * (GROUND_Y - margin * 2), + passed: false, + }); + }, []); + + const reset = useCallback(() => { + cat.current = { y: H / 2, vy: 0 }; + pillars.current = []; + trail.current = []; + worldX.current = 0; + dying.current = 0; + scoreRef.current = 0; + setScore(0); + bestAtRoundStart.current = hiScoreRef.current; + spawnPillar(W + 120); + stars.current = Array.from({ length: 40 }, () => ({ + x: Math.random() * W, + y: Math.random() * H, + speed: 15 + Math.random() * 35, + size: Math.random() < 0.25 ? 2 : 1, + })); + changePhase("ready"); + }, [spawnPillar, changePhase]); + + const flap = useCallback(() => { + if (dying.current > 0) return; + cat.current.vy = FLAP_VY; + beep(500, 740, 0.06, 0.04); + }, []); + + const start = useCallback(() => { + beep(440, 880, 0.12); + changePhase("playing"); + flap(); + }, [changePhase, flap]); + + const die = useCallback(() => { + dying.current = 700; + beep(300, 60, 0.5, 0.08, "sawtooth"); + }, []); + + const tick = useCallback( + (dt: number) => { + const dts = dt / 1000; + const c = cat.current; + + if (dying.current > 0) { + // Tumble off screen, then call it. + dying.current -= dt; + c.vy = Math.min(MAX_FALL, c.vy + GRAVITY * dts); + c.y += c.vy * dts; + if (dying.current <= 0 || c.y > H + 60) { + changePhase("over"); + onGameOverRef.current(scoreRef.current); + } + return; + } + + const v = speed(); + worldX.current += v * dts; + + c.vy = Math.min(MAX_FALL, c.vy + GRAVITY * dts); + c.y += c.vy * dts; + if (c.y < 4) { + c.y = 4; + c.vy = 0; + } + + // Trail follows the cat's path and scrolls with the world. + for (const p of trail.current) p.x -= v * dts; + trail.current.push({ x: CAT_X - 6, y: c.y + CAT_H / 2 }); + while (trail.current.length > 0 && trail.current[0].x < -30) { + trail.current.shift(); + } + + for (const star of stars.current) { + star.x -= star.speed * dts; + if (star.x < 0) { + star.x += W; + star.y = Math.random() * H; + } + } + + const g = gap(); + for (const pillar of pillars.current) { + pillar.x -= v * dts; + if (!pillar.passed && pillar.x + PILLAR_W < CAT_X) { + pillar.passed = true; + scoreRef.current += 1; + setScore(scoreRef.current); + beep(880, 1320, 0.08, 0.05); + } + } + if (pillars.current[0] && pillars.current[0].x < -PILLAR_W) { + pillars.current.shift(); + } + const last = pillars.current[pillars.current.length - 1]; + if (!last || last.x < W - PILLAR_SPACING) { + spawnPillar(W + PILLAR_W); + } + + // Collisions: ground, then pillars. + if (c.y + CAT_H >= GROUND_Y) { + c.y = GROUND_Y - CAT_H; + die(); + return; + } + const catLeft = CAT_X - CAT_W / 2 + 3; + const catRight = CAT_X + CAT_W / 2 - 3; + for (const pillar of pillars.current) { + if (catRight < pillar.x || catLeft > pillar.x + PILLAR_W) continue; + const gapTop = pillar.gapY - g / 2; + const gapBottom = pillar.gapY + g / 2; + if (c.y + 3 < gapTop || c.y + CAT_H - 3 > gapBottom) { + die(); + return; + } + } + }, + [speed, gap, spawnPillar, die, changePhase], + ); + + const draw = useCallback(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const c = cat.current; + + ctx.fillStyle = "#060210"; + ctx.fillRect(0, 0, W, H); + + ctx.fillStyle = "#e2e8f0"; + for (const star of stars.current) { + ctx.fillRect(star.x, star.y, star.size, star.size); + } + + // Comet tail: three brand stripes tracing the flight path, with the + // classic chunky zigzag — segments alternate a 2px offset in 12px blocks. + const points = trail.current; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const wob = Math.floor((a.x + worldX.current) / 12) % 2 === 0 ? 2 : -2; + const width = Math.max(1, b.x - a.x + 1); + for (let s = 0; s < TAIL_COLORS.length; s++) { + ctx.fillStyle = TAIL_COLORS[s]; + ctx.fillRect( + a.x, + a.y - (TAIL_COLORS.length * TAIL_STRIPE_H) / 2 + s * TAIL_STRIPE_H + wob, + width, + TAIL_STRIPE_H, + ); + } + } + + // Pillars — neon arcade columns with lipped caps at the gap. + const g = gap(); + for (const pillar of pillars.current) { + const gapTop = pillar.gapY - g / 2; + const gapBottom = pillar.gapY + g / 2; + ctx.fillStyle = "#150b2e"; + ctx.fillRect(pillar.x, 0, PILLAR_W, gapTop); + ctx.fillRect(pillar.x, gapBottom, PILLAR_W, GROUND_Y - gapBottom); + ctx.strokeStyle = "#7cdae1"; + ctx.lineWidth = 3; + ctx.strokeRect(pillar.x + 1.5, -4, PILLAR_W - 3, gapTop + 2.5); + ctx.strokeRect(pillar.x + 1.5, gapBottom + 1.5, PILLAR_W - 3, GROUND_Y - gapBottom + 4); + ctx.fillStyle = "#7cdae1"; + ctx.fillRect(pillar.x - 4, gapTop - 8, PILLAR_W + 8, 8); + ctx.fillRect(pillar.x - 4, gapBottom, PILLAR_W + 8, 8); + } + + ctx.fillStyle = "#7cdae1"; + ctx.fillRect(0, GROUND_Y, W, 2); + + // Cat, tilted by vertical velocity like any self-respecting flappy hero. + const angle = Math.max(-0.4, Math.min(1.25, c.vy / 480)); + ctx.save(); + ctx.translate(CAT_X, c.y + CAT_H / 2); + ctx.rotate(dying.current > 0 ? Math.min(1.6, angle + 0.6) : angle); + const px = CAT_W / CAT_SPRITE[0].length; + const py = CAT_H / CAT_SPRITE.length; + for (let r = 0; r < CAT_SPRITE.length; r++) { + for (let col = 0; col < CAT_SPRITE[r].length; col++) { + const color = CAT_PALETTE[CAT_SPRITE[r][col]]; + if (!color) continue; + ctx.fillStyle = color; + ctx.fillRect(-CAT_W / 2 + col * px, -CAT_H / 2 + r * py, px + 0.5, py + 0.5); + } + } + ctx.restore(); + }, [gap]); + + useEffect(() => { + reset(); + }, [reset]); + + // Bank the running score if the player closes the overlay mid-game — + // death already reports via the dying countdown, so only cover quit here. + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + useEffect(() => { + if (phase !== "playing") { + draw(); + return; + } + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + tick(dt); + draw(); + if (phaseRef.current === "playing") { + raf = requestAnimationFrame(frame); + } + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [phase, tick, draw]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const currentPhase = phaseRef.current; + + if (key === " " || key === "arrowup" || key === "w") { + event.preventDefault(); + if (event.repeat) return; + if (currentPhase === "ready") start(); + else if (currentPhase === "playing") flap(); + else if (currentPhase === "over") reset(); + return; + } + + if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + return; + } + + if (key === "enter") { + event.preventDefault(); + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [start, flap, reset, changePhase]); + + const onPointer = useCallback(() => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") start(); + else if (currentPhase === "playing") flap(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + }, [start, flap, reset, changePhase]); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return ( +
+
+ + SCORE {formatScore(score)} + + + HI {formatScore(Math.max(hiScore, score))} + +
+
+ { + event.preventDefault(); + onPointer(); + }} + /> + {phase !== "playing" && ( +
+ {phase === "ready" && ( + <> + READY? + Tap, click, or press Space to flap + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {isNewBest && ★ NEW HI-SCORE ★} + Press Space or tap to fly again + + )} +
+ )} +
+
+

SPACE / TAP FLAP — P PAUSE

+
+ ); +} diff --git a/apps/site/src/app/arcade/games.ts b/apps/site/src/app/arcade/games.ts index 2de3221fd4..a680504aac 100644 --- a/apps/site/src/app/arcade/games.ts +++ b/apps/site/src/app/arcade/games.ts @@ -160,4 +160,35 @@ export const GAMES: ArcadeGame[] = [ ], }, }, + { + id: "comet", + title: "COMET CAT", + tagline: "Flap. Drift. Leave a trail.", + blurb: "One cat, endless pillars, and a brand-new comet tail. How far can you fly?", + color: "#7cdae1", + hiScore: 0, + status: "playable", + sprite: { + palette: { + T: "#7cdae1", + Y: "#edcd5f", + R: "#e37780", + G: "#9ca3af", + D: "#4b5563", + K: "#1f2937", + P: "#f2a0ac", + }, + rows: [ + "............", + "......DD.DD.", + "......DGDGD.", + "TTTTTDGGGGGD", + "YYYYYDGKGKGD", + "RRRRRDGGPGGD", + "......DGGGD.", + ".......DDD..", + "............", + ], + }, + }, ]; From d27030a5770305b5a895f457d130054c1a50be1b Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:14:30 +0200 Subject: [PATCH 3/5] feat(site): polish Prisma Arcade into a merge-ready page Restyle /arcade on the Eclipse design system with the standard site nav and footer, feature Comet Cat in the hero next to a top-ten leaderboard with initials entry and the $500-Prisma-credits prize callout, and move the other five games into a compact dialog grid. Extract the shared game scaffolding (phase machine, score banking, clamped rAF loop, held keys, HUD/overlay shell) into game-kit.tsx and fix the input, cleanup, and timing bugs found in review. Scores stay in localStorage until the global leaderboard backend lands. Co-Authored-By: Claude Fable 5 --- apps/site/.gitignore | 1 - .../arcade/_components/arcade-experience.tsx | 232 ++++++ .../app/arcade/_components/arcade-screen.tsx | 289 ------- .../app/arcade/_components/arcade.module.css | 737 +++--------------- .../app/arcade/_components/comet-cat-game.tsx | 341 ++++---- .../src/app/arcade/_components/game-kit.tsx | 217 ++++++ .../app/arcade/_components/invaders-game.tsx | 473 +++++------ .../app/arcade/_components/leaderboard.tsx | 189 +++++ .../app/arcade/_components/meteors-game.tsx | 607 +++++++-------- .../app/arcade/_components/muncher-game.tsx | 363 ++++----- .../src/app/arcade/_components/reveal.tsx | 33 + .../src/app/arcade/_components/snake-game.tsx | 195 ++--- .../app/arcade/_components/stacker-game.tsx | 279 +++---- apps/site/src/app/arcade/games.ts | 85 +- apps/site/src/app/arcade/page.tsx | 25 +- .../src/components/navigation-wrapper.tsx | 9 - 16 files changed, 1754 insertions(+), 2321 deletions(-) delete mode 100644 apps/site/.gitignore create mode 100644 apps/site/src/app/arcade/_components/arcade-experience.tsx delete mode 100644 apps/site/src/app/arcade/_components/arcade-screen.tsx create mode 100644 apps/site/src/app/arcade/_components/game-kit.tsx create mode 100644 apps/site/src/app/arcade/_components/leaderboard.tsx create mode 100644 apps/site/src/app/arcade/_components/reveal.tsx diff --git a/apps/site/.gitignore b/apps/site/.gitignore deleted file mode 100644 index 62a2372fe7..0000000000 --- a/apps/site/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.prisma/ diff --git a/apps/site/src/app/arcade/_components/arcade-experience.tsx b/apps/site/src/app/arcade/_components/arcade-experience.tsx new file mode 100644 index 0000000000..cf46730080 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade-experience.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@prisma/eclipse"; +import Link from "next/link"; +import { useCallback, useEffect, useState, type ComponentType } from "react"; +import { GAMES, type ArcadeGame, type ArcadeGameId } from "../games"; +import { CometCatGame } from "./comet-cat-game"; +import { formatScore, type GameProps } from "./game-kit"; +import { InvadersGame } from "./invaders-game"; +import { + Leaderboard, + loadLeaderboard, + MAX_ENTRIES, + qualifies, + saveLeaderboard, + type LeaderboardEntry, +} from "./leaderboard"; +import { MeteorsGame } from "./meteors-game"; +import { MuncherGame } from "./muncher-game"; +import { PixelSprite } from "./pixel-sprite"; +import { Reveal } from "./reveal"; +import { SnakeGame } from "./snake-game"; +import { StackerGame } from "./stacker-game"; +import styles from "./arcade.module.css"; + +/** Per-game personal bests, kept per browser like the leaderboard. */ +const HI_SCORE_STORAGE_KEY = "prisma-arcade-hiscores"; +/** The featured game's key in the hi-score record. */ +const COMET_ID = "comet"; + +const GAME_COMPONENTS: Record> = { + snake: SnakeGame, + invaders: InvadersGame, + stacker: StackerGame, + muncher: MuncherGame, + meteors: MeteorsGame, +}; + +const CARD_SURFACE = + "rounded-square-high border border-stroke-neutral bg-[linear-gradient(180deg,var(--color-background-default)_0%,var(--color-background-ppg)_262.5%)]"; + +export function ArcadeExperience({ + /** next/font variable class providing --font-arcade; also applied to the + * play dialog, which portals outside this subtree. */ + fontClass, +}: { + fontClass: string; +}) { + const [hiScores, setHiScores] = useState>({}); + const [activeGame, setActiveGame] = useState(null); + const [entries, setEntries] = useState([]); + const [pendingScore, setPendingScore] = useState(null); + const [lastClaimedAt, setLastClaimedAt] = useState(null); + + useEffect(() => { + try { + const stored = localStorage.getItem(HI_SCORE_STORAGE_KEY); + if (stored) setHiScores(JSON.parse(stored)); + } catch { + // Corrupt or unavailable storage — start from zero. + } + setEntries(loadLeaderboard()); + }, []); + + useEffect(() => { + if (Object.keys(hiScores).length === 0) return; + try { + localStorage.setItem(HI_SCORE_STORAGE_KEY, JSON.stringify(hiScores)); + } catch { + // Storage unavailable — scores still show for this session. + } + }, [hiScores]); + + const reportScore = useCallback((gameId: string, score: number) => { + setHiScores((prev) => (score <= (prev[gameId] ?? 0) ? prev : { ...prev, [gameId]: score })); + }, []); + + const onCometGameOver = useCallback( + (score: number) => { + reportScore(COMET_ID, score); + if (!qualifies(entries, score)) return; + // Keep the best unclaimed score if the player dies again before typing + // their initials. + setPendingScore((prev) => (prev !== null && prev >= score ? prev : score)); + }, + [reportScore, entries], + ); + + const claimScore = useCallback( + (initials: string) => { + if (pendingScore === null) return; + const at = Date.now(); + const next = [...entries, { initials, score: pendingScore, at }] + .sort((a, b) => b.score - a.score || a.at - b.at) + .slice(0, MAX_ENTRIES); + setEntries(next); + saveLeaderboard(next); + setPendingScore(null); + setLastClaimedAt(at); + }, + [pendingScore, entries], + ); + + const ActiveGame = activeGame ? GAME_COMPONENTS[activeGame.id] : null; + + return ( +
+ {/* ===== 1. HERO + FEATURED GAME + LEADERBOARD ===== */} +
+
+
+
+
+ + + Prisma Arcade + +

Take a break. Set a record.

+

+ Six tiny games built by the Prisma team. Fly Comet Cat, climb the leaderboard, and + keep an eye on the $500 Prisma-credits high-score contest. +

+
+ +
+
+
+

Comet Cat

+

+ Flap. Drift. Leave a trail. +

+
+ +
+ + +
+
+
+ + {/* ===== 2. MORE GAMES ===== */} +
+
+ + Free play +

+ The back row +

+

+ Five more machines, no quarters required. Personal bests live in your browser. +

+
+ + +
+ {GAMES.map((game) => ( + + ))} +
+
+
+
+ + {/* ===== 3. CLOSING ===== */} +
+ +

+ Shipped between deploys. When you're done playing,{" "} + + see what we build the rest of the time + + . +

+
+
+ + !open && setActiveGame(null)}> + + {activeGame && ActiveGame && ( + <> + + {activeGame.title} + + {activeGame.tagline} {activeGame.controls}. + + + reportScore(activeGame.id, score)} + /> + + )} + + +
+ ); +} diff --git a/apps/site/src/app/arcade/_components/arcade-screen.tsx b/apps/site/src/app/arcade/_components/arcade-screen.tsx deleted file mode 100644 index 95dbfe74d2..0000000000 --- a/apps/site/src/app/arcade/_components/arcade-screen.tsx +++ /dev/null @@ -1,289 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { GAMES, type ArcadeGame } from "../games"; -import { PixelSprite } from "./pixel-sprite"; -import { SnakeGame } from "./snake-game"; -import { InvadersGame } from "./invaders-game"; -import { StackerGame } from "./stacker-game"; -import { MuncherGame } from "./muncher-game"; -import { MeteorsGame } from "./meteors-game"; -import { CometCatGame } from "./comet-cat-game"; -import styles from "./arcade.module.css"; - -const HI_SCORE_STORAGE_KEY = "prisma-arcade-hiscores"; - -type GameProps = { hiScore: number; onGameOver: (score: number) => void }; - -const GAME_COMPONENTS: Record> = { - snake: SnakeGame, - invaders: InvadersGame, - stacker: StackerGame, - muncher: MuncherGame, - meteors: MeteorsGame, - comet: CometCatGame, -}; - -const KONAMI = [ - "ArrowUp", - "ArrowUp", - "ArrowDown", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "ArrowLeft", - "ArrowRight", - "b", - "a", -]; - -const TICKER_ITEMS = [ - "★ WELCOME TO THE PRISMA ARCADE ★", - "6 GAMES ★ FREE PLAY", - "GLOBAL HIGH SCORES COMING SOON", - "NO QUARTERS REQUIRED", - "TYPE-SAFE SINCE 2016", - "WINNERS DON'T USE RAW SQL... USUALLY", -]; - -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - -/** Chunky 8-bit coin blip via WebAudio — no assets needed. */ -function playCoinSound() { - try { - const ctx = new AudioContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "square"; - osc.frequency.setValueAtTime(988, ctx.currentTime); - osc.frequency.setValueAtTime(1319, ctx.currentTime + 0.08); - gain.gain.setValueAtTime(0.08, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35); - osc.connect(gain).connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.35); - osc.onended = () => ctx.close(); - } catch { - // Autoplay policy or no AudioContext — the arcade stays silent. - } -} - -export function ArcadeScreen() { - const [credits, setCredits] = useState(0); - const [activeGame, setActiveGame] = useState(null); - const [shakingId, setShakingId] = useState(null); - const [cheatFlash, setCheatFlash] = useState(0); - // Local hi-scores until the global leaderboard backend lands. - const [hiScores, setHiScores] = useState>({}); - const konamiProgress = useRef(0); - - useEffect(() => { - try { - const stored = localStorage.getItem(HI_SCORE_STORAGE_KEY); - if (stored) setHiScores(JSON.parse(stored)); - } catch { - // Corrupt or unavailable storage — start from zero. - } - }, []); - - useEffect(() => { - if (Object.keys(hiScores).length === 0) return; - try { - localStorage.setItem(HI_SCORE_STORAGE_KEY, JSON.stringify(hiScores)); - } catch { - // Storage unavailable — scores still show for this session. - } - }, [hiScores]); - - const reportScore = useCallback((gameId: string, score: number) => { - setHiScores((prev) => (score <= (prev[gameId] ?? 0) ? prev : { ...prev, [gameId]: score })); - }, []); - - const insertCoin = useCallback(() => { - playCoinSound(); - setCredits((c) => c + 1); - }, []); - - const openGame = useCallback( - (game: ArcadeGame) => { - if (credits <= 0) { - setShakingId(game.id); - window.setTimeout(() => setShakingId(null), 350); - return; - } - setCredits((c) => c - 1); - setActiveGame(game); - }, - [credits], - ); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setActiveGame(null); - } - - const expected = KONAMI[konamiProgress.current]; - if (event.key === expected || event.key.toLowerCase() === expected) { - konamiProgress.current += 1; - if (konamiProgress.current === KONAMI.length) { - konamiProgress.current = 0; - setCredits((c) => c + 30); - setCheatFlash((n) => n + 1); - } - } else { - konamiProgress.current = event.key === KONAMI[0] ? 1 : 0; - } - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - const tickerText = [...TICKER_ITEMS, ...TICKER_ITEMS]; - - return ( -
-
-
-
- -
-
-

PRISMA PRESENTS

-

- PRISMA -
- ARCADE -

-

INSERT COIN TO PLAY

-
- -
- -

- CREDITS {formatScore(credits)} -

-
- -
- {GAMES.map((game) => ( - - ))} -
- -
-

★ HALL OF FAME ★

- {[ - ["1ST", "???", "AWAITING CHALLENGER", 0], - ["2ND", "???", "AWAITING CHALLENGER", 0], - ["3RD", "???", "AWAITING CHALLENGER", 0], - ].map(([rank, initials, note, score]) => ( -
- {rank} - {initials} - {note} - {formatScore(score as number)} -
- ))} -

- Global leaderboards go live when the games do. Practice your initials. -

-
- - - ◀ EXIT TO PRISMA.IO - -
- - {activeGame && ( -
{ - // Click-away quit would be brutal mid-game; only for placeholders. - if (activeGame.status === "coming-soon") setActiveGame(null); - }} - > -
event.stopPropagation()} - > -
-

{activeGame.title}

- {activeGame.status === "playable" && GAME_COMPONENTS[activeGame.id] ? ( - (() => { - const Game = GAME_COMPONENTS[activeGame.id]; - return ( - reportScore(activeGame.id, score)} - /> - ); - })() - ) : ( - <> -

COMING SOON

-

{activeGame.blurb}

-
- TODAY'S BEST — NOBODY YET - ALL-TIME BEST — COULD BE YOU -
- - )} - -
-
- )} - - {cheatFlash > 0 && ( -

- CHEAT ACTIVATED! +30 CREDITS -

- )} - -
-
- {tickerText.map((item, i) => ( - {item} - ))} -
-
- -
-
-
- ); -} diff --git a/apps/site/src/app/arcade/_components/arcade.module.css b/apps/site/src/app/arcade/_components/arcade.module.css index fe03c69838..0729418991 100644 --- a/apps/site/src/app/arcade/_components/arcade.module.css +++ b/apps/site/src/app/arcade/_components/arcade.module.css @@ -1,605 +1,47 @@ /* ========================================================================== - PRISMA ARCADE — deliberately off-brand. CRT glow, scanlines, pixel type. + PRISMA ARCADE + Page chrome (hero, cards, leaderboard) is Tailwind + @prisma/eclipse + tokens in the components. This module holds only what utilities can't + express: the game-screen treatment and its keyframes. + + Everything INSIDE .gameScreen / .cardScreen is a lit CRT: it stays dark in + both site themes on purpose, so those colors are fixed. Everything outside + the screen uses semantic tokens and themes normally. ========================================================================== */ -.arcade { - --arcade-bg: #08010f; - --arcade-magenta: #f472b6; - --arcade-cyan: #22d3ee; - --arcade-yellow: #facc15; - --arcade-text: #e2e8f0; - position: relative; - min-height: 100svh; - overflow: hidden; - background: - radial-gradient(ellipse 120% 80% at 50% -20%, #2b0a4e 0%, transparent 60%), var(--arcade-bg); - color: var(--arcade-text); - font-family: var(--font-arcade), "Courier New", monospace; - image-rendering: pixelated; - cursor: crosshair; -} - -.arcade *::selection { - background: var(--arcade-magenta); - color: #08010f; -} - -/* --- background layers ------------------------------------------------- */ - -.stars, -.starsFar { - position: absolute; - inset: 0; - pointer-events: none; -} - -.stars { - background-image: - radial-gradient(1px 1px at 20% 30%, #fff 100%, transparent), - radial-gradient(2px 2px at 60% 70%, var(--arcade-cyan) 100%, transparent), - radial-gradient(1px 1px at 50% 50%, #fff 100%, transparent), - radial-gradient(2px 2px at 80% 10%, var(--arcade-magenta) 100%, transparent), - radial-gradient(1px 1px at 90% 60%, #fff 100%, transparent), - radial-gradient(1px 1px at 33% 80%, #fff 100%, transparent), - radial-gradient(2px 2px at 15% 65%, #fff 100%, transparent); - background-size: 550px 550px; - animation: twinkle 4s steps(2) infinite; -} - -.starsFar { - background-image: - radial-gradient(1px 1px at 10% 10%, #ffffffaa 100%, transparent), - radial-gradient(1px 1px at 40% 60%, #ffffff88 100%, transparent), - radial-gradient(1px 1px at 70% 40%, #ffffffaa 100%, transparent), - radial-gradient(1px 1px at 95% 85%, #ffffff88 100%, transparent); - background-size: 350px 350px; - animation: twinkle 3s steps(2) infinite reverse; -} - -@keyframes twinkle { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.6; - } -} - -.gridFloor { - position: absolute; - left: -25%; - right: -25%; - bottom: -2%; - height: 42%; - pointer-events: none; - background-image: - linear-gradient(to top, rgba(244, 114, 182, 0.45) 2px, transparent 2px), - linear-gradient(to right, rgba(34, 211, 238, 0.35) 2px, transparent 2px); - background-size: 64px 64px; - transform: perspective(320px) rotateX(62deg); - transform-origin: center top; - animation: floorScroll 1.4s linear infinite; - mask-image: linear-gradient(to bottom, transparent, black 30%); -} - -@keyframes floorScroll { - from { - background-position: - 0 0, - 0 0; - } - to { - background-position: - 0 64px, - 0 0; - } -} - -/* --- CRT overlay -------------------------------------------------------- */ - -.crt { - position: fixed; - inset: 0; - z-index: 50; - pointer-events: none; - background: repeating-linear-gradient( - to bottom, - transparent 0px, - transparent 2px, - rgba(0, 0, 0, 0.22) 3px, - rgba(0, 0, 0, 0.22) 4px - ); - animation: flicker 0.12s steps(2) infinite; -} - -.vignette { - position: fixed; - inset: 0; - z-index: 51; - pointer-events: none; - background: radial-gradient( - ellipse 90% 90% at 50% 50%, - transparent 55%, - rgba(0, 0, 0, 0.55) 100% - ); -} - -@keyframes flicker { - 0%, - 100% { - opacity: 0.9; - } - 50% { - opacity: 1; - } -} - -/* --- header -------------------------------------------------------------- */ - -.content { - position: relative; - z-index: 10; - display: flex; - flex-direction: column; - align-items: center; - gap: 3rem; - padding: 4rem 1.5rem 6rem; - max-width: 72rem; - margin: 0 auto; -} - -.pretitle { - font-size: 0.75rem; - letter-spacing: 0.35em; - color: var(--arcade-cyan); - text-shadow: 0 0 8px var(--arcade-cyan); -} - -.title { - font-size: clamp(1.75rem, 6vw, 4rem); - text-align: center; - line-height: 1.2; - color: #fff; - text-shadow: - 3px 3px 0 var(--arcade-magenta), - -3px -3px 0 var(--arcade-cyan), - 0 0 24px rgba(244, 114, 182, 0.8), - 0 0 64px rgba(34, 211, 238, 0.5); - animation: titlePulse 2.4s ease-in-out infinite; -} - -@keyframes titlePulse { - 0%, - 100% { - text-shadow: - 3px 3px 0 var(--arcade-magenta), - -3px -3px 0 var(--arcade-cyan), - 0 0 24px rgba(244, 114, 182, 0.8), - 0 0 64px rgba(34, 211, 238, 0.5); - } - 50% { - text-shadow: - 3px 3px 0 var(--arcade-magenta), - -3px -3px 0 var(--arcade-cyan), - 0 0 40px rgba(244, 114, 182, 1), - 0 0 96px rgba(34, 211, 238, 0.8); - } -} - -.blink { - animation: blink 1.1s steps(2, start) infinite; -} - -@keyframes blink { - to { - visibility: hidden; - } -} - -.insertCoin { - font-size: clamp(0.7rem, 2vw, 1rem); - letter-spacing: 0.2em; - color: var(--arcade-yellow); - text-shadow: 0 0 12px var(--arcade-yellow); -} - -/* --- credits / coin slot ------------------------------------------------- */ - -.coinRow { - display: flex; - align-items: center; - gap: 1.5rem; - flex-wrap: wrap; - justify-content: center; -} - -.coinSlot { - font-family: inherit; - font-size: 0.7rem; - letter-spacing: 0.15em; - color: #08010f; - background: var(--arcade-yellow); - border: none; - padding: 0.9rem 1.4rem; - cursor: pointer; - clip-path: polygon( - 0 8px, - 8px 8px, - 8px 0, - calc(100% - 8px) 0, - calc(100% - 8px) 8px, - 100% 8px, - 100% calc(100% - 8px), - calc(100% - 8px) calc(100% - 8px), - calc(100% - 8px) 100%, - 8px 100%, - 8px calc(100% - 8px), - 0 calc(100% - 8px) - ); - box-shadow: 0 0 20px rgba(250, 204, 21, 0.5); - transition: transform 0.05s steps(1); -} - -.coinSlot:active { - transform: translateY(3px); -} - -.credits { - font-size: 0.75rem; - letter-spacing: 0.2em; - color: var(--arcade-text); -} - -.creditsCount { - color: var(--arcade-yellow); - text-shadow: 0 0 10px var(--arcade-yellow); -} - -/* --- cabinets ------------------------------------------------------------ */ - -.cabinets { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); - gap: 2rem; - width: 100%; -} - -.cabinet { - --game-color: #fff; - position: relative; - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1.5rem 1.25rem 1.75rem; - background: linear-gradient(180deg, #140525 0%, #0b0217 100%); - border: 3px solid var(--game-color); - clip-path: polygon( - 0 12px, - 12px 12px, - 12px 0, - calc(100% - 12px) 0, - calc(100% - 12px) 12px, - 100% 12px, - 100% calc(100% - 12px), - calc(100% - 12px) calc(100% - 12px), - calc(100% - 12px) 100%, - 12px 100%, - 12px calc(100% - 12px), - 0 calc(100% - 12px) - ); - cursor: pointer; - text-align: center; - font-family: inherit; - color: inherit; - transition: transform 0.1s steps(2); -} - -.cabinet:hover, -.cabinet:focus-visible { - transform: translateY(-6px); - filter: drop-shadow(0 0 18px var(--game-color)); - outline: none; -} - -.cabinetMarquee { - font-size: 0.8rem; - line-height: 1.5; - color: var(--game-color); - text-shadow: 0 0 12px var(--game-color); - letter-spacing: 0.08em; -} - -.cabinetScreen { - position: relative; - display: flex; - align-items: center; - justify-content: center; - aspect-ratio: 4 / 3; - background: - repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.35) 2px 4px), - radial-gradient(ellipse at 50% 40%, #1e0b38 0%, #05010a 80%); - border: 3px solid #2c1b45; - overflow: hidden; -} - -.cabinetScreen svg { - width: 55%; - height: auto; - filter: drop-shadow(0 0 10px var(--game-color)); - animation: spriteBob 1.2s steps(2) infinite; -} - -@keyframes spriteBob { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-6px); - } -} - -.comingSoon { - position: absolute; - bottom: 0.6rem; - left: 0; - right: 0; - font-size: 0.55rem; - letter-spacing: 0.3em; - color: var(--arcade-yellow); - text-shadow: 0 0 8px var(--arcade-yellow); -} - -.tagline { - font-family: var(--font-arcade-alt), monospace; - font-size: 1.15rem; - line-height: 1.3; - color: #94a3b8; -} - -.hiScore { - font-size: 0.6rem; - letter-spacing: 0.2em; - color: var(--arcade-cyan); -} - -.hiScoreValue { - color: #fff; - text-shadow: 0 0 8px var(--arcade-cyan); -} - -.startHint { - font-size: 0.6rem; - letter-spacing: 0.25em; - color: var(--game-color); -} - -.shake { - animation: shake 0.3s steps(6); -} - -@keyframes shake { - 0%, - 100% { - transform: translateX(0); - } - 25% { - transform: translateX(-8px); - } - 50% { - transform: translateX(8px); - } - 75% { - transform: translateX(-4px); - } -} - -/* --- hall of fame --------------------------------------------------------- */ - -.hallOfFame { - width: 100%; - max-width: 40rem; - border: 3px solid var(--arcade-magenta); - padding: 1.5rem 1.25rem; - background: rgba(20, 5, 37, 0.8); - box-shadow: 0 0 24px rgba(244, 114, 182, 0.3); -} - -.hallTitle { - font-size: 0.85rem; - letter-spacing: 0.25em; - text-align: center; - color: var(--arcade-magenta); - text-shadow: 0 0 12px var(--arcade-magenta); - margin-bottom: 1.25rem; -} - -.hallRow { - display: grid; - grid-template-columns: 3rem 4rem 1fr auto; - gap: 0.75rem; - align-items: baseline; - font-size: 0.65rem; - letter-spacing: 0.12em; - padding: 0.5rem 0; - color: #94a3b8; -} - -.hallRow:first-of-type { - color: var(--arcade-yellow); - text-shadow: 0 0 8px var(--arcade-yellow); -} - -.hallNote { - margin-top: 1.25rem; - text-align: center; - font-family: var(--font-arcade-alt), monospace; - font-size: 1rem; - color: #64748b; -} - -/* --- ticker ---------------------------------------------------------------- */ - -.ticker { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 40; - overflow: hidden; - border-top: 3px solid var(--arcade-cyan); - background: rgba(5, 1, 10, 0.92); - padding: 0.65rem 0; -} - -.tickerTrack { - display: flex; - width: max-content; - gap: 3rem; - white-space: nowrap; - font-size: 0.65rem; - letter-spacing: 0.25em; - color: var(--arcade-cyan); - animation: tickerScroll 22s linear infinite; -} - -@keyframes tickerScroll { - from { - transform: translateX(0); - } - to { - transform: translateX(-50%); - } -} - -/* --- game overlay ----------------------------------------------------------- */ - -.overlay { - position: fixed; - inset: 0; - z-index: 60; - display: flex; - align-items: center; - justify-content: center; - background: rgba(2, 0, 5, 0.92); - padding: 1.5rem; - overflow-y: auto; -} - -.overlayScreen { - position: relative; - margin: auto; - width: min(100%, 34rem); - border: 4px solid var(--game-color, #fff); - background: - repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.3) 2px 4px), #0a0118; - padding: 2.5rem 1.75rem; - text-align: center; - display: flex; - flex-direction: column; - gap: 1.5rem; - overflow: hidden; - box-shadow: 0 0 40px var(--game-color, #fff); -} - -.rollBar { - position: absolute; - left: 0; - right: 0; - height: 5rem; - background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.08), transparent); - animation: rollBar 3s linear infinite; - pointer-events: none; -} - -@keyframes rollBar { - from { - top: -6rem; - } - to { - top: 110%; - } -} - -.overlayTitle { - font-size: 1.1rem; - color: var(--game-color, #fff); - text-shadow: 0 0 16px var(--game-color, #fff); - letter-spacing: 0.1em; - line-height: 1.5; -} - -.overlayComingSoon { - font-size: 1.5rem; - color: #fff; - letter-spacing: 0.15em; - text-shadow: - 2px 2px 0 var(--arcade-magenta), - -2px -2px 0 var(--arcade-cyan); -} - -.overlayBlurb { - font-family: var(--font-arcade-alt), monospace; - font-size: 1.2rem; - line-height: 1.4; - color: #94a3b8; -} - -.overlayScores { - display: flex; - flex-direction: column; - gap: 0.5rem; - font-size: 0.6rem; - letter-spacing: 0.2em; - color: var(--arcade-cyan); -} - -.backBtn { - font-family: inherit; - font-size: 0.65rem; - letter-spacing: 0.2em; - align-self: center; - color: var(--arcade-yellow); - background: transparent; - border: 3px solid var(--arcade-yellow); - padding: 0.8rem 1.5rem; - cursor: pointer; -} - -.backBtn:hover { - background: var(--arcade-yellow); - color: #08010f; - box-shadow: 0 0 20px var(--arcade-yellow); -} - -/* --- playable game ----------------------------------------------------------- */ - .gameWrap { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; width: 100%; + font-family: var(--font-arcade), "Courier New", monospace; } .gameHud { display: flex; justify-content: space-between; + gap: 1rem; font-size: 0.65rem; letter-spacing: 0.15em; - color: var(--arcade-cyan); + color: var(--color-foreground-neutral-weak); } .gameHud b { font-weight: 400; - color: #fff; - text-shadow: 0 0 8px var(--arcade-cyan); + color: var(--color-foreground-neutral); +} + +.gameLives { + color: var(--color-foreground-success); + letter-spacing: 0.3em; } .gameScreen { position: relative; - border: 3px solid var(--game-color, #4ade80); + border: 1px solid var(--color-stroke-neutral); + border-radius: var(--radius-square); overflow: hidden; + background: #060210; } .gameCanvas { @@ -633,35 +75,28 @@ line-height: 1.6; text-align: center; color: #fff; - text-shadow: 0 0 14px var(--game-color, #4ade80); padding: 1rem; } .gameMsgSub { - font-family: var(--font-arcade-alt), monospace; - font-size: 1.1rem; - letter-spacing: 0.05em; + font-family: var(--font-mono, monospace); + font-size: 0.85rem; + letter-spacing: 0.02em; + text-transform: none; color: #94a3b8; - text-shadow: none; } .newBest { - color: var(--arcade-yellow); - text-shadow: 0 0 14px var(--arcade-yellow); + color: #facc15; animation: blink 0.6s steps(2, start) infinite; } .gameControls { + margin: 0; font-size: 0.55rem; letter-spacing: 0.2em; text-align: center; - color: #64748b; -} - -.gameLives { - color: #4ade80; - letter-spacing: 0.3em; - text-shadow: 0 0 8px #4ade80; + color: var(--color-foreground-neutral-weaker); } .waveBanner { @@ -672,73 +107,95 @@ align-items: center; justify-content: center; pointer-events: none; + font-family: var(--font-arcade), monospace; font-size: 1.1rem; letter-spacing: 0.3em; - color: var(--game-color, #22d3ee); - text-shadow: 0 0 18px var(--game-color, #22d3ee); + color: #7cdae1; + text-shadow: 0 0 18px #7cdae1; animation: blink 0.7s steps(2, start) infinite; } -/* --- misc -------------------------------------------------------------------- */ +.blink { + animation: blink 1.1s steps(2, start) infinite; +} -.exitLink { - font-size: 0.6rem; - letter-spacing: 0.25em; - color: #64748b; - text-decoration: none; - border-bottom: 2px dotted #64748b; - padding-bottom: 0.2rem; +@keyframes blink { + to { + visibility: hidden; + } +} + +/* Focusable hero game: the whole screen is the keyboard target. */ +.focusScreen { + border-radius: var(--radius-square); + outline: none; } -.exitLink:hover { - color: var(--arcade-cyan); - border-color: var(--arcade-cyan); - text-shadow: 0 0 8px var(--arcade-cyan); +.focusScreen:focus-visible { + outline: 2px solid var(--color-stroke-ppg); + outline-offset: 3px; } -.cheatFlash { - position: fixed; - inset: 0; - z-index: 70; +/* --- secondary game cards -------------------------------------------------- */ + +/* Mini "attract mode" screen on the game cards; dark in both themes. */ +.cardScreen { + position: relative; display: flex; align-items: center; justify-content: center; - pointer-events: none; - font-size: clamp(1.25rem, 4vw, 2.5rem); - color: var(--arcade-yellow); - text-shadow: - 3px 3px 0 var(--arcade-magenta), - 0 0 40px var(--arcade-yellow); - animation: cheatFlash 2s steps(8) forwards; + aspect-ratio: 16 / 9; + border-radius: var(--radius-square); + border: 1px solid var(--color-stroke-neutral); + overflow: hidden; + background: + repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.35) 2px 4px), + radial-gradient(ellipse at 50% 40%, #17103a 0%, #060210 80%); } -@keyframes cheatFlash { - 0% { - opacity: 0; - transform: scale(0.5); - } - 10% { - opacity: 1; - transform: scale(1.1); - } - 20% { - transform: scale(1); - } - 80% { - opacity: 1; - } +.cardScreen svg { + width: 34%; + height: auto; + filter: drop-shadow(0 0 10px var(--game-color, #7cdae1)); + animation: spriteBob 1.2s steps(2) infinite; +} + +@keyframes spriteBob { + 0%, 100% { - opacity: 0; + transform: translateY(0); + } + 50% { + transform: translateY(-4px); } } +/* --- scroll reveal ---------------------------------------------------------- */ + +.reveal { + opacity: 0; + transform: translateY(14px); +} + +.reveal.in { + opacity: 1; + transform: none; + transition: + opacity 0.7s ease, + transform 0.7s ease; +} + @media (prefers-reduced-motion: reduce) { - .arcade *, - .crt, - .stars, - .starsFar, - .gridFloor, - .tickerTrack { + .blink, + .newBest, + .waveBanner, + .cardScreen svg { animation: none !important; } + + .reveal { + opacity: 1; + transform: none; + transition: none; + } } diff --git a/apps/site/src/app/arcade/_components/comet-cat-game.tsx b/apps/site/src/app/arcade/_components/comet-cat-game.tsx index f8ec98ca3a..354b85e034 100644 --- a/apps/site/src/app/arcade/_components/comet-cat-game.tsx +++ b/apps/site/src/app/arcade/_components/comet-cat-game.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { beep } from "./arcade-audio"; +import { GameShell, PhaseOverlay, useGameCore, useGameLoop, type GameProps } from "./game-kit"; import styles from "./arcade.module.css"; const W = 420; @@ -21,11 +22,10 @@ const PILLAR_SPACING = 230; const BASE_GAP = 150; const BASE_SPEED = 145; -// The new brand stripes, top to bottom — the comet tail. +// The comet tail stripes, top to bottom. const TAIL_COLORS = ["#7cdae1", "#edcd5f", "#e37780"]; const TAIL_STRIPE_H = 7; -type Phase = "ready" | "playing" | "paused" | "over"; type Pillar = { x: number; gapY: number; passed: boolean }; type TrailPoint = { x: number; y: number }; type Star = { x: number; y: number; speed: number; size: number }; @@ -52,18 +52,26 @@ const CAT_PALETTE: Record = { P: "#f2a0ac", }; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - -export function CometCatGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +/** + * The featured game. Unlike the dialog games, it is embedded directly in the + * page, so keyboard input is scoped to the focusable screen element instead of + * `window` — the page keeps scrolling normally until the player clicks in. + */ +export function CometCatGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const screenRef = useRef(null); + + const { + phase, + phaseRef, + changePhase, + score, + scoreRef, + addScore, + startRound, + endGame, + isNewBest, + } = useGameCore({ hiScore, onGameOver }); const cat = useRef({ y: H / 2, vy: 0 }); const pillars = useRef([]); @@ -73,24 +81,8 @@ export function CometCatGame({ // Countdown for the post-collision tumble before the GAME OVER screen. const dying = useRef(0); - const scoreRef = useRef(0); - const phaseRef = useRef("ready"); - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); - - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); - - const speed = useCallback(() => Math.min(220, BASE_SPEED + scoreRef.current * 1.5), []); - const gap = useCallback(() => Math.max(120, BASE_GAP - scoreRef.current * 0.5), []); + const speed = () => Math.min(220, BASE_SPEED + scoreRef.current * 1.5); + const gap = () => Math.max(120, BASE_GAP - scoreRef.current * 0.5); const spawnPillar = useCallback((x: number) => { const margin = 90; @@ -107,9 +99,7 @@ export function CometCatGame({ trail.current = []; worldX.current = 0; dying.current = 0; - scoreRef.current = 0; - setScore(0); - bestAtRoundStart.current = hiScoreRef.current; + startRound(); spawnPillar(W + 120); stars.current = Array.from({ length: 40 }, () => ({ x: Math.random() * W, @@ -118,7 +108,7 @@ export function CometCatGame({ size: Math.random() < 0.25 ? 2 : 1, })); changePhase("ready"); - }, [spawnPillar, changePhase]); + }, [spawnPillar, changePhase, startRound]); const flap = useCallback(() => { if (dying.current > 0) return; @@ -137,86 +127,81 @@ export function CometCatGame({ beep(300, 60, 0.5, 0.08, "sawtooth"); }, []); - const tick = useCallback( - (dt: number) => { - const dts = dt / 1000; - const c = cat.current; - - if (dying.current > 0) { - // Tumble off screen, then call it. - dying.current -= dt; - c.vy = Math.min(MAX_FALL, c.vy + GRAVITY * dts); - c.y += c.vy * dts; - if (dying.current <= 0 || c.y > H + 60) { - changePhase("over"); - onGameOverRef.current(scoreRef.current); - } - return; - } - - const v = speed(); - worldX.current += v * dts; + const tick = (dt: number) => { + const dts = dt / 1000; + const c = cat.current; + if (dying.current > 0) { + // Tumble off screen, then call it. + dying.current -= dt; c.vy = Math.min(MAX_FALL, c.vy + GRAVITY * dts); c.y += c.vy * dts; - if (c.y < 4) { - c.y = 4; - c.vy = 0; + if (dying.current <= 0 || c.y > H + 60) { + endGame(); } + return; + } - // Trail follows the cat's path and scrolls with the world. - for (const p of trail.current) p.x -= v * dts; - trail.current.push({ x: CAT_X - 6, y: c.y + CAT_H / 2 }); - while (trail.current.length > 0 && trail.current[0].x < -30) { - trail.current.shift(); - } + const v = speed(); + worldX.current += v * dts; - for (const star of stars.current) { - star.x -= star.speed * dts; - if (star.x < 0) { - star.x += W; - star.y = Math.random() * H; - } - } + c.vy = Math.min(MAX_FALL, c.vy + GRAVITY * dts); + c.y += c.vy * dts; + if (c.y < 4) { + c.y = 4; + c.vy = 0; + } - const g = gap(); - for (const pillar of pillars.current) { - pillar.x -= v * dts; - if (!pillar.passed && pillar.x + PILLAR_W < CAT_X) { - pillar.passed = true; - scoreRef.current += 1; - setScore(scoreRef.current); - beep(880, 1320, 0.08, 0.05); - } - } - if (pillars.current[0] && pillars.current[0].x < -PILLAR_W) { - pillars.current.shift(); + // Trail follows the cat's path and scrolls with the world. + for (const p of trail.current) p.x -= v * dts; + trail.current.push({ x: CAT_X - 6, y: c.y + CAT_H / 2 }); + while (trail.current.length > 0 && trail.current[0].x < -30) { + trail.current.shift(); + } + + for (const star of stars.current) { + star.x -= star.speed * dts; + if (star.x < 0) { + star.x += W; + star.y = Math.random() * H; } - const last = pillars.current[pillars.current.length - 1]; - if (!last || last.x < W - PILLAR_SPACING) { - spawnPillar(W + PILLAR_W); + } + + const g = gap(); + for (const pillar of pillars.current) { + pillar.x -= v * dts; + if (!pillar.passed && pillar.x + PILLAR_W < CAT_X) { + pillar.passed = true; + addScore(1); + beep(880, 1320, 0.08, 0.05); } + } + if (pillars.current[0] && pillars.current[0].x < -PILLAR_W) { + pillars.current.shift(); + } + const last = pillars.current[pillars.current.length - 1]; + if (!last || last.x < W - PILLAR_SPACING) { + spawnPillar(W + PILLAR_W); + } - // Collisions: ground, then pillars. - if (c.y + CAT_H >= GROUND_Y) { - c.y = GROUND_Y - CAT_H; + // Collisions: ground, then pillars. + if (c.y + CAT_H >= GROUND_Y) { + c.y = GROUND_Y - CAT_H; + die(); + return; + } + const catLeft = CAT_X - CAT_W / 2 + 3; + const catRight = CAT_X + CAT_W / 2 - 3; + for (const pillar of pillars.current) { + if (catRight < pillar.x || catLeft > pillar.x + PILLAR_W) continue; + const gapTop = pillar.gapY - g / 2; + const gapBottom = pillar.gapY + g / 2; + if (c.y + 3 < gapTop || c.y + CAT_H - 3 > gapBottom) { die(); return; } - const catLeft = CAT_X - CAT_W / 2 + 3; - const catRight = CAT_X + CAT_W / 2 - 3; - for (const pillar of pillars.current) { - if (catRight < pillar.x || catLeft > pillar.x + PILLAR_W) continue; - const gapTop = pillar.gapY - g / 2; - const gapBottom = pillar.gapY + g / 2; - if (c.y + 3 < gapTop || c.y + CAT_H - 3 > gapBottom) { - die(); - return; - } - } - }, - [speed, gap, spawnPillar, die, changePhase], - ); + } + }; const draw = useCallback(() => { const ctx = canvasRef.current?.getContext("2d"); @@ -231,8 +216,8 @@ export function CometCatGame({ ctx.fillRect(star.x, star.y, star.size, star.size); } - // Comet tail: three brand stripes tracing the flight path, with the - // classic chunky zigzag — segments alternate a 2px offset in 12px blocks. + // Comet tail: three stripes tracing the flight path, with the classic + // chunky zigzag — segments alternate a 2px offset in 12px blocks. const points = trail.current; for (let i = 0; i < points.length - 1; i++) { const a = points[i]; @@ -286,128 +271,84 @@ export function CometCatGame({ } } ctx.restore(); - }, [gap]); + }, []); useEffect(() => { reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — - // death already reports via the dying countdown, so only cover quit here. - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); useEffect(() => { - if (phase !== "playing") { - draw(); - return; - } - let raf = 0; - let last = performance.now(); - const frame = (now: number) => { - const dt = Math.min(50, now - last); - last = now; - tick(dt); - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, tick, draw]); + if (phase !== "playing") draw(); + }, [phase, draw]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { + const advance = useCallback(() => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") start(); + else if (currentPhase === "playing") flap(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + }, [phaseRef, start, flap, reset, changePhase]); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { const key = event.key.toLowerCase(); - const currentPhase = phaseRef.current; - if (key === " " || key === "arrowup" || key === "w") { + if (key === " " || key === "arrowup" || key === "w" || key === "enter") { event.preventDefault(); if (event.repeat) return; - if (currentPhase === "ready") start(); - else if (currentPhase === "playing") flap(); - else if (currentPhase === "over") reset(); + advance(); return; } - if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { - changePhase(currentPhase === "playing" ? "paused" : "playing"); - return; - } - - if (key === "enter") { - event.preventDefault(); - if (currentPhase === "ready") start(); - else if (currentPhase === "over") reset(); - else if (currentPhase === "paused") changePhase("playing"); + if (key === "p") { + const currentPhase = phaseRef.current; + if (currentPhase === "playing" || currentPhase === "paused") { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + } } - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [start, flap, reset, changePhase]); + }, + [advance, phaseRef, changePhase], + ); const onPointer = useCallback(() => { - const currentPhase = phaseRef.current; - if (currentPhase === "ready") start(); - else if (currentPhase === "playing") flap(); - else if (currentPhase === "over") reset(); - else if (currentPhase === "paused") changePhase("playing"); - }, [start, flap, reset, changePhase]); - - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + screenRef.current?.focus(); + advance(); + }, [advance]); return ( -
-
- - SCORE {formatScore(score)} - - - HI {formatScore(Math.max(hiScore, score))} - -
-
+ +
{ - event.preventDefault(); - onPointer(); - }} + onTouchStart={onPointer} + /> + - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - Tap, click, or press Space to flap - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Space or tap to fly again - - )} -
- )} -
-

SPACE / TAP FLAP — P PAUSE

-
+
); } diff --git a/apps/site/src/app/arcade/_components/game-kit.tsx b/apps/site/src/app/arcade/_components/game-kit.tsx new file mode 100644 index 0000000000..831e04632c --- /dev/null +++ b/apps/site/src/app/arcade/_components/game-kit.tsx @@ -0,0 +1,217 @@ +"use client"; + +/** + * Shared scaffolding for every arcade game: the phase state machine, score + * bookkeeping, the requestAnimationFrame loop, and the HUD / overlay chrome. + * Game files keep only their own simulation, drawing, and input logic. + */ + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import styles from "./arcade.module.css"; + +/** Props every arcade game component accepts. */ +export type GameProps = { + hiScore: number; + onGameOver: (score: number) => void; +}; + +export type Phase = "ready" | "playing" | "paused" | "over"; + +export function formatScore(score: number) { + return score.toString().padStart(6, "0"); +} + +/** + * Phase + score state, mirrored into refs so the game loop can read them + * without re-subscribing, plus hi-score tracking and score banking: if the + * player quits mid-run (unmount before "over"), the running score is still + * reported. `onGameOver` and `hiScore` are read through refs, so parents may + * pass fresh identities every render without restarting the game. + */ +export function useGameCore({ hiScore, onGameOver }: GameProps) { + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const phaseRef = useRef("ready"); + const scoreRef = useRef(0); + const bestAtRoundStart = useRef(0); + + const hiScoreRef = useRef(hiScore); + hiScoreRef.current = hiScore; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; + + const changePhase = useCallback((next: Phase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const setScoreValue = useCallback((value: number) => { + scoreRef.current = value; + setScore(value); + }, []); + + const addScore = useCallback( + (points: number) => { + setScoreValue(scoreRef.current + points); + }, + [setScoreValue], + ); + + /** Call from reset(): zeroes the score and snapshots the hi-score to beat. */ + const startRound = useCallback(() => { + bestAtRoundStart.current = hiScoreRef.current; + setScoreValue(0); + }, [setScoreValue]); + + /** Call when the run ends: flips to "over" and reports the final score. */ + const endGame = useCallback(() => { + changePhase("over"); + onGameOverRef.current(scoreRef.current); + }, [changePhase]); + + useEffect( + () => () => { + if (phaseRef.current !== "over" && scoreRef.current > 0) { + onGameOverRef.current(scoreRef.current); + } + }, + [], + ); + + const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + + return { + phase, + phaseRef, + changePhase, + score, + scoreRef, + setScoreValue, + addScore, + startRound, + endGame, + isNewBest, + }; +} + +/** + * The rAF loop. `frame` receives a dt clamped to 50ms so a throttled or + * backgrounded tab can never fast-forward the simulation. Reads `frame` + * through a ref, so callers may pass a fresh closure every render. + */ +export function useGameLoop(running: boolean, frame: (dt: number) => void) { + const frameRef = useRef(frame); + frameRef.current = frame; + + useEffect(() => { + if (!running) return; + let raf = 0; + let last = performance.now(); + const loop = (now: number) => { + const dt = Math.min(50, now - last); + last = now; + frameRef.current(dt); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, [running]); +} + +/** + * A Set of currently-held keys (lower-cased). Handles keyup and clears on + * window blur, so alt-tabbing away can never leave a key stuck down. Games add + * keys in their own keydown handlers and may clear() on reset/game-over. + */ +export function useHeldKeys() { + const keys = useRef(new Set()); + + useEffect(() => { + const onKeyUp = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase()); + const onBlur = () => keys.current.clear(); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + }; + }, []); + + return keys; +} + +/** HUD + screen frame + controls hint shared by every game. */ +export function GameShell({ + score, + hiScore, + hudExtra, + controls, + children, +}: { + score: number; + hiScore: number; + /** Extra HUD readout between score and hi-score, e.g. lives or level. */ + hudExtra?: ReactNode; + controls: string; + children: ReactNode; +}) { + return ( +
+
+ + SCORE {formatScore(score)} + + {hudExtra} + + HI {formatScore(hiScore)} + +
+
+ {children} +
+
+

{controls}

+
+ ); +} + +/** Ready / paused / game-over message layer, rendered inside the screen. */ +export function PhaseOverlay({ + phase, + score, + isNewBest, + readyTitle = "READY?", + readyHint, + overExtra, +}: { + phase: Phase; + score: number; + isNewBest: boolean; + readyTitle?: string; + readyHint: string; + /** Extra line on the game-over screen, e.g. the wave reached. */ + overExtra?: ReactNode; +}) { + if (phase === "playing") return null; + + return ( +
+ {phase === "ready" && ( + <> + {readyTitle} + {readyHint} + + )} + {phase === "paused" && PAUSED} + {phase === "over" && ( + <> + GAME OVER + SCORE {formatScore(score)} + {overExtra} + {isNewBest && ★ NEW HI-SCORE ★} + Press Space or tap to play again + + )} +
+ ); +} diff --git a/apps/site/src/app/arcade/_components/invaders-game.tsx b/apps/site/src/app/arcade/_components/invaders-game.tsx index bf307f9382..feac5cd32e 100644 --- a/apps/site/src/app/arcade/_components/invaders-game.tsx +++ b/apps/site/src/app/arcade/_components/invaders-game.tsx @@ -2,6 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { beep } from "./arcade-audio"; +import { + GameShell, + PhaseOverlay, + useGameCore, + useGameLoop, + useHeldKeys, + type GameProps, +} from "./game-kit"; import styles from "./arcade.module.css"; const W = 480; @@ -37,8 +45,7 @@ const MARCH_NOTES = [110, 98, 87, 78]; const INVASION_Y = H - 96; type Vec = { x: number; y: number }; -type Phase = "ready" | "playing" | "paused" | "over"; -type Explosion = { x: number; y: number; ttl: number }; +type Explosion = { x: number; y: number; ttl: number; ttl0: number }; type Ufo = { x: number; dir: number; points: number }; type Bunker = { x: number; y: number; cells: boolean[][] }; @@ -169,10 +176,6 @@ const BUNKER_SHAPE = [ "XXX.....XXX", ]; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - function makeBunkers(): Bunker[] { const width = BUNKER_SHAPE[0].length * BUNKER_CELL; return [96, 192, 288, 384].map((center) => ({ @@ -199,17 +202,14 @@ function drawSprite( } } -export function InvadersGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +export function InvadersGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const { phase, phaseRef, changePhase, score, addScore, startRound, endGame, isNewBest } = + useGameCore({ hiScore, onGameOver }); + const playerX = useRef(W / 2); - const keys = useRef(new Set()); + const keys = useHeldKeys(); const touchTargetX = useRef(null); const playerBullet = useRef(null); const enemyBullets = useRef([]); @@ -226,28 +226,14 @@ export function InvadersGame({ const freeze = useRef(0); const pendingWave = useRef(false); - const scoreRef = useRef(0); const livesRef = useRef(3); const waveRef = useRef(1); - const phaseRef = useRef("ready"); - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); const [wave, setWave] = useState(1); const [waveBanner, setWaveBanner] = useState(null); const bannerTimeout = useRef(undefined); - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); - const showWaveBanner = useCallback((n: number) => { setWaveBanner(`WAVE ${n.toString().padStart(2, "0")}`); window.clearTimeout(bannerTimeout.current); @@ -270,13 +256,12 @@ export function InvadersGame({ }, []); const reset = useCallback(() => { - scoreRef.current = 0; livesRef.current = 3; waveRef.current = 1; - setScore(0); setLives(3); setWave(1); - bestAtRoundStart.current = hiScoreRef.current; + startRound(); + keys.current.clear(); playerX.current = W / 2; playerBullet.current = null; enemyBullets.current = []; @@ -290,13 +275,12 @@ export function InvadersGame({ bunkers.current = makeBunkers(); resetGrid(1); changePhase("ready"); - }, [resetGrid, changePhase]); + }, [startRound, keys, resetGrid, changePhase]); const gameOver = useCallback(() => { beep(300, 40, 0.7, 0.09, "sawtooth"); - changePhase("over"); - onGameOverRef.current(scoreRef.current); - }, [changePhase]); + endGame(); + }, [endGame]); const invaderRect = useCallback((row: number, col: number) => { const type = ROW_TYPES[row]; @@ -338,7 +322,7 @@ export function InvadersGame({ ); const playerHit = useCallback(() => { - explosions.current.push({ x: playerX.current, y: PLAYER_Y + 8, ttl: 400 }); + explosions.current.push({ x: playerX.current, y: PLAYER_Y + 8, ttl: 400, ttl0: 400 }); enemyBullets.current = []; playerBullet.current = null; livesRef.current -= 1; @@ -415,55 +399,55 @@ export function InvadersGame({ beep(900, 300, 0.07, 0.04); }, []); - const tick = useCallback( - (dt: number) => { - const g = grid.current; + const tick = (dt: number) => { + const g = grid.current; - if (freeze.current > 0) { - freeze.current -= dt; - if (freeze.current <= 0 && pendingWave.current) { - pendingWave.current = false; - resetGrid(waveRef.current); - } - return; + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && pendingWave.current) { + pendingWave.current = false; + resetGrid(waveRef.current); } + return; + } - // Player movement — keyboard or touch drag. - const k = keys.current; - let vx = 0; - if (k.has("arrowleft") || k.has("a")) vx -= 1; - if (k.has("arrowright") || k.has("d")) vx += 1; - if (vx !== 0) { - touchTargetX.current = null; - playerX.current += vx * PLAYER_SPEED * (dt / 1000); - } else if (touchTargetX.current !== null) { - const delta = touchTargetX.current - playerX.current; - const step = PLAYER_SPEED * 1.4 * (dt / 1000); - playerX.current += Math.abs(delta) <= step ? delta : Math.sign(delta) * step; - } - playerX.current = Math.min( - W - EDGE - PLAYER_HALF_W, - Math.max(EDGE + PLAYER_HALF_W, playerX.current), - ); - - if (k.has(" ") || k.has("arrowup") || k.has("w")) tryFire(); - - // March. - const alive = aliveCount(); - marchAcc.current += dt; - const interval = Math.max(60, (70 + alive * 13) * Math.pow(0.95, waveRef.current - 1)); - if (marchAcc.current >= interval) { - marchAcc.current = 0; - marchStep(); - if (phaseRef.current === "over") return; - } + // Player movement — keyboard or touch drag. + const k = keys.current; + let vx = 0; + if (k.has("arrowleft") || k.has("a")) vx -= 1; + if (k.has("arrowright") || k.has("d")) vx += 1; + if (vx !== 0) { + touchTargetX.current = null; + playerX.current += vx * PLAYER_SPEED * (dt / 1000); + } else if (touchTargetX.current !== null) { + const delta = touchTargetX.current - playerX.current; + const step = PLAYER_SPEED * 1.4 * (dt / 1000); + playerX.current += Math.abs(delta) <= step ? delta : Math.sign(delta) * step; + } + playerX.current = Math.min( + W - EDGE - PLAYER_HALF_W, + Math.max(EDGE + PLAYER_HALF_W, playerX.current), + ); + + if (k.has(" ") || k.has("arrowup") || k.has("w")) tryFire(); + + // March. + const alive = aliveCount(); + marchAcc.current += dt; + const interval = Math.max(60, (70 + alive * 13) * Math.pow(0.95, waveRef.current - 1)); + if (marchAcc.current >= interval) { + marchAcc.current = 0; + marchStep(); + if (phaseRef.current === "over") return; + } - // Enemy fire. - fireAcc.current += dt; - if ( - fireAcc.current >= nextFireIn.current && - enemyBullets.current.length < MAX_ENEMY_BULLETS - ) { + // Enemy fire. + fireAcc.current += dt; + if (fireAcc.current >= nextFireIn.current) { + if (enemyBullets.current.length >= MAX_ENEMY_BULLETS) { + // Cap reached — restart the timer so a freed slot doesn't fire instantly. + fireAcc.current = 0; + } else { fireAcc.current = 0; nextFireIn.current = (500 + Math.random() * 800) * Math.pow(0.93, waveRef.current - 1); const columns: number[] = []; @@ -485,113 +469,111 @@ export function InvadersGame({ } } } + } - // UFO. - if (ufo.current) { - ufo.current.x += ufo.current.dir * UFO_SPEED * (dt / 1000); - if (ufo.current.x < -40 || ufo.current.x > W + 40) ufo.current = null; - } else { - ufoTimer.current -= dt; - if (ufoTimer.current <= 0) { - const dir = Math.random() < 0.5 ? 1 : -1; - ufo.current = { - x: dir === 1 ? -32 : W + 32, - dir, - points: [50, 100, 150][Math.floor(Math.random() * 3)], - }; - ufoTimer.current = 14000 + Math.random() * 10000; - beep(600, 900, 0.25, 0.03, "triangle"); - } + // UFO. + if (ufo.current) { + ufo.current.x += ufo.current.dir * UFO_SPEED * (dt / 1000); + if (ufo.current.x < -40 || ufo.current.x > W + 40) ufo.current = null; + } else { + ufoTimer.current -= dt; + if (ufoTimer.current <= 0) { + const dir = Math.random() < 0.5 ? 1 : -1; + ufo.current = { + x: dir === 1 ? -32 : W + 32, + dir, + points: [50, 100, 150][Math.floor(Math.random() * 3)], + }; + ufoTimer.current = 14000 + Math.random() * 10000; + beep(600, 900, 0.25, 0.03, "triangle"); } + } - // Player bullet. - const pb = playerBullet.current; - if (pb) { - pb.y -= BULLET_SPEED * (dt / 1000); - if (pb.y < 30) { - playerBullet.current = null; - } else if (hitBunker(pb.x, pb.y, 2.4)) { + // Player bullet. + const pb = playerBullet.current; + if (pb) { + pb.y -= BULLET_SPEED * (dt / 1000); + if (pb.y < 30) { + playerBullet.current = null; + } else if (hitBunker(pb.x, pb.y, 2.4)) { + playerBullet.current = null; + } else { + const u = ufo.current; + if (u && pb.x > u.x - 16 && pb.x < u.x + 16 && pb.y > UFO_Y && pb.y < UFO_Y + 14) { + addScore(u.points); + explosions.current.push({ x: u.x, y: UFO_Y + 7, ttl: 300, ttl0: 300 }); + ufo.current = null; playerBullet.current = null; + beep(1200, 200, 0.3, 0.07); } else { - const u = ufo.current; - if (u && pb.x > u.x - 16 && pb.x < u.x + 16 && pb.y > UFO_Y && pb.y < UFO_Y + 14) { - scoreRef.current += u.points; - setScore(scoreRef.current); - explosions.current.push({ x: u.x, y: UFO_Y + 7, ttl: 300 }); - ufo.current = null; - playerBullet.current = null; - beep(1200, 200, 0.3, 0.07); - } else { - outer: for (let r = 0; r < ROWS; r++) { - for (let c = 0; c < COLS; c++) { - if (!g.alive[r][c]) continue; - const rect = invaderRect(r, c); - if ( - pb.x > rect.x && - pb.x < rect.x + rect.w && - pb.y > rect.y && - pb.y < rect.y + rect.h - ) { - g.alive[r][c] = false; - scoreRef.current += ROW_TYPES[r].points; - setScore(scoreRef.current); - explosions.current.push({ - x: rect.x + rect.w / 2, - y: rect.y + rect.h / 2, - ttl: 250, - }); - playerBullet.current = null; - beep(200, 40, 0.15, 0.07); - break outer; - } + outer: for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (!g.alive[r][c]) continue; + const rect = invaderRect(r, c); + if ( + pb.x > rect.x && + pb.x < rect.x + rect.w && + pb.y > rect.y && + pb.y < rect.y + rect.h + ) { + g.alive[r][c] = false; + addScore(ROW_TYPES[r].points); + explosions.current.push({ + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2, + ttl: 250, + ttl0: 250, + }); + playerBullet.current = null; + beep(200, 40, 0.15, 0.07); + break outer; } } } } } + } - // Enemy bullets. - const remaining: Vec[] = []; - for (const bullet of enemyBullets.current) { - bullet.y += ENEMY_BULLET_SPEED * (dt / 1000); - const pBullet = playerBullet.current; - if (pBullet && Math.abs(bullet.x - pBullet.x) < 5 && Math.abs(bullet.y - pBullet.y) < 10) { - explosions.current.push({ x: bullet.x, y: bullet.y, ttl: 200 }); - playerBullet.current = null; - continue; - } - if (bullet.y > H - 24) continue; - if (hitBunker(bullet.x, bullet.y + 8, 1.8)) continue; - if ( - bullet.x > playerX.current - PLAYER_HALF_W && - bullet.x < playerX.current + PLAYER_HALF_W && - bullet.y + 8 > PLAYER_Y && - bullet.y < PLAYER_Y + PLAYER_H - ) { - playerHit(); - return; - } - remaining.push(bullet); - } - enemyBullets.current = remaining; - - // Explosions decay. - explosions.current = explosions.current.filter((e) => (e.ttl -= dt) > 0); - - // Wave cleared. - if (aliveCount() === 0 && !pendingWave.current) { - waveRef.current += 1; - setWave(waveRef.current); - showWaveBanner(waveRef.current); + // Enemy bullets. + const remaining: Vec[] = []; + for (const bullet of enemyBullets.current) { + bullet.y += ENEMY_BULLET_SPEED * (dt / 1000); + const pBullet = playerBullet.current; + if (pBullet && Math.abs(bullet.x - pBullet.x) < 5 && Math.abs(bullet.y - pBullet.y) < 10) { + explosions.current.push({ x: bullet.x, y: bullet.y, ttl: 200, ttl0: 200 }); playerBullet.current = null; - enemyBullets.current = []; - pendingWave.current = true; - freeze.current = 1400; - beep(440, 1320, 0.4, 0.06); + continue; } - }, - [aliveCount, marchStep, invaderRect, hitBunker, playerHit, tryFire, resetGrid, showWaveBanner], - ); + if (bullet.y > H - 24) continue; + if (hitBunker(bullet.x, bullet.y + 8, 1.8)) continue; + if ( + bullet.x > playerX.current - PLAYER_HALF_W && + bullet.x < playerX.current + PLAYER_HALF_W && + bullet.y + 8 > PLAYER_Y && + bullet.y < PLAYER_Y + PLAYER_H + ) { + playerHit(); + return; + } + remaining.push(bullet); + } + enemyBullets.current = remaining; + + // Explosions decay. + explosions.current = explosions.current.filter((e) => (e.ttl -= dt) > 0); + + // Wave cleared. + if (aliveCount() === 0 && !pendingWave.current) { + waveRef.current += 1; + setWave(waveRef.current); + showWaveBanner(waveRef.current); + playerBullet.current = null; + enemyBullets.current = []; + pendingWave.current = true; + freeze.current = 1400; + beep(440, 1320, 0.4, 0.06); + } + }; const draw = useCallback(() => { const ctx = canvasRef.current?.getContext("2d"); @@ -655,7 +637,7 @@ export function InvadersGame({ // Explosions — expanding pixel bursts. for (const e of explosions.current) { - const progress = 1 - e.ttl / 300; + const progress = 1 - e.ttl / e.ttl0; const radius = 4 + progress * 10; ctx.fillStyle = progress < 0.5 ? "#facc15" : "#f87171"; for (let i = 0; i < 8; i++) { @@ -668,42 +650,20 @@ export function InvadersGame({ ); } } - }, [invaderRect]); + }, [invaderRect, phaseRef]); useEffect(() => { reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — - // death already reports via gameOver(), so only cover the quit path here. - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); useEffect(() => { - if (phase !== "playing") { - draw(); - return; - } - let raf = 0; - let last = performance.now(); - const frame = (now: number) => { - const dt = Math.min(50, now - last); - last = now; - tick(dt); - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, tick, draw]); + if (phase !== "playing") draw(); + }, [phase, draw]); const start = useCallback(() => { beep(440, 880, 0.12); @@ -716,10 +676,13 @@ export function InvadersGame({ const key = event.key.toLowerCase(); const currentPhase = phaseRef.current; - if (["arrowleft", "arrowright", "arrowup", "a", "d", "w", " "].includes(key)) { + if ( + ["arrowleft", "arrowright", "arrowup", "arrowdown", "a", "d", "w", "s", " "].includes(key) + ) { event.preventDefault(); keys.current.add(key); if (currentPhase === "ready") start(); + else if (currentPhase === "over" && key === " ") reset(); return; } @@ -735,17 +698,10 @@ export function InvadersGame({ else if (currentPhase === "paused") changePhase("playing"); } }; - const onKeyUp = (event: KeyboardEvent) => { - keys.current.delete(event.key.toLowerCase()); - }; window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - }; - }, [start, changePhase, reset]); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phaseRef, keys, start, changePhase, reset]); const canvasX = useCallback((clientX: number) => { const canvas = canvasRef.current; @@ -763,7 +719,7 @@ export function InvadersGame({ else if (currentPhase === "playing") tryFire(); touchTargetX.current = canvasX(event.touches[0].clientX); }, - [start, reset, changePhase, tryFire, canvasX], + [phaseRef, start, reset, changePhase, tryFire, canvasX], ); const onTouchMove = useCallback( @@ -773,56 +729,41 @@ export function InvadersGame({ [canvasX], ); - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; + const onTouchStop = useCallback(() => { + touchTargetX.current = null; + }, []); return ( -
-
- - SCORE {formatScore(score)} - - - WAVE {wave.toString().padStart(2, "0")} - - {"▲".repeat(Math.max(0, lives))} - - HI {formatScore(Math.max(hiScore, score))} - -
-
- - {waveBanner && phase === "playing" &&
{waveBanner}
} - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - - Press any key to defend the planet — or tap - - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Enter or tap to play again - - )} -
- )} -
-
-

◀ ▶ MOVE — SPACE FIRE — P PAUSE

-
+ + + WAVE {wave.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + } + controls="◀ ▶ MOVE — SPACE FIRE — P PAUSE" + > + + {waveBanner && phase === "playing" &&
{waveBanner}
} + +
); } diff --git a/apps/site/src/app/arcade/_components/leaderboard.tsx b/apps/site/src/app/arcade/_components/leaderboard.tsx new file mode 100644 index 0000000000..a5b1540f2f --- /dev/null +++ b/apps/site/src/app/arcade/_components/leaderboard.tsx @@ -0,0 +1,189 @@ +"use client"; + +/** + * The Comet Cat leaderboard. Scores are stored in localStorage for now — the + * site has no database, and the global leaderboard is planned to land together + * with the prize contest. The panel is written so only the storage helpers + * need to change when a backend arrives: the UI already deals in ranked + * {initials, score} entries. + */ + +import { Button } from "@prisma/eclipse"; +import { useCallback, useState, type FormEvent } from "react"; +import { formatScore } from "./game-kit"; + +const LEADERBOARD_KEY = "prisma-arcade-comet-leaderboard"; +const INITIALS_KEY = "prisma-arcade-initials"; +export const MAX_ENTRIES = 10; + +export type LeaderboardEntry = { + initials: string; + score: number; + /** Insertion timestamp — tiebreaker and row identity. */ + at: number; +}; + +export function loadLeaderboard(): LeaderboardEntry[] { + try { + const raw = localStorage.getItem(LEADERBOARD_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (entry): entry is LeaderboardEntry => + typeof entry === "object" && + entry !== null && + typeof (entry as LeaderboardEntry).initials === "string" && + typeof (entry as LeaderboardEntry).score === "number" && + typeof (entry as LeaderboardEntry).at === "number", + ) + .sort((a, b) => b.score - a.score || a.at - b.at) + .slice(0, MAX_ENTRIES); + } catch { + return []; + } +} + +export function saveLeaderboard(entries: LeaderboardEntry[]) { + try { + localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(entries)); + } catch { + // Storage unavailable — the board still works for this session. + } +} + +export function loadInitials(): string { + try { + return localStorage.getItem(INITIALS_KEY) ?? ""; + } catch { + return ""; + } +} + +export function saveInitials(initials: string) { + try { + localStorage.setItem(INITIALS_KEY, initials); + } catch { + // Fine — the player just types them again next time. + } +} + +/** Whether a score would earn a spot on the board. */ +export function qualifies(entries: LeaderboardEntry[], score: number) { + if (score <= 0) return false; + if (entries.length < MAX_ENTRIES) return true; + return score > entries[entries.length - 1].score; +} + +function sanitizeInitials(value: string) { + return value + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 3); +} + +export function Leaderboard({ + entries, + pendingScore, + lastClaimedAt, + onClaim, +}: { + entries: LeaderboardEntry[]; + /** A fresh Comet Cat score awaiting initials, or null. */ + pendingScore: number | null; + /** Timestamp of the most recently claimed entry, for the row highlight. */ + lastClaimedAt: number | null; + onClaim: (initials: string) => void; +}) { + const [initials, setInitials] = useState(loadInitials); + + const submit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + const clean = sanitizeInitials(initials); + if (clean.length === 0) return; + saveInitials(clean); + onClaim(clean); + }, + [initials, onClaim], + ); + + const rows = Array.from({ length: MAX_ENTRIES }, (_, i) => entries[i] ?? null); + + return ( + + ); +} diff --git a/apps/site/src/app/arcade/_components/meteors-game.tsx b/apps/site/src/app/arcade/_components/meteors-game.tsx index c5d399bfd7..618947b292 100644 --- a/apps/site/src/app/arcade/_components/meteors-game.tsx +++ b/apps/site/src/app/arcade/_components/meteors-game.tsx @@ -2,6 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { beep } from "./arcade-audio"; +import { + GameShell, + PhaseOverlay, + useGameCore, + useGameLoop, + useHeldKeys, + type GameProps, +} from "./game-kit"; import styles from "./arcade.module.css"; const W = 480; @@ -20,6 +28,7 @@ const SHIP_RADIUS = 11; const INVULN_TIME = 2000; // ms of blinking immunity after respawn const DEATH_FREEZE = 1500; // ms the ship stays shattered before it can respawn const SAFE_RADIUS = 90; // center must be clear of rocks within this to respawn +const RESPAWN_FORCE_MS = 2500; // stop waiting for a clear center after this long // The classic bracket ship, pointing along +x at angle 0. const SHIP_SHAPE: [number, number][] = [ @@ -50,7 +59,6 @@ const SAUCER_BULLET_LIFE = 1400; const BIG_SAUCER = { r: 16, points: 200, fireEvery: 1200 }; const SMALL_SAUCER = { r: 10, points: 1000, fireEvery: 1000 }; -type Phase = "ready" | "playing" | "paused" | "over"; type RockSize = "large" | "medium" | "small"; const ROCK_SPECS: Record< @@ -115,10 +123,6 @@ type Debris = { by: number; }; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - const wrap = (v: number, max: number) => ((v % max) + max) % max; /** Squared distance on the toroidal (wrapping) playfield. */ @@ -191,15 +195,21 @@ function makeRock(size: RockSize, x: number, y: number): Rock { }; } -export function MeteorsGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +export function MeteorsGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const { + phase, + phaseRef, + changePhase, + score, + scoreRef, + addScore: coreAddScore, + startRound, + endGame, + isNewBest, + } = useGameCore({ hiScore, onGameOver }); + const ship = useRef({ x: W / 2, y: H / 2, @@ -216,13 +226,14 @@ export function MeteorsGame({ const saucerBullets = useRef([]); const debris = useRef([]); - const keys = useRef(new Set()); + const keys = useHeldKeys(); const touch = useRef({ left: false, right: false, thrust: false }); const tapInfo = useRef<{ t: number; x: number; y: number; moved: boolean } | null>(null); const fireCooldown = useRef(0); const hyperCooldown = useRef(0); const deathTimer = useRef(0); + const respawnWait = useRef(0); const waveDelay = useRef(0); const nextExtraLife = useRef(EXTRA_LIFE_EVERY); const saucerTimer = useRef(SAUCER_MIN_DELAY); @@ -231,28 +242,14 @@ export function MeteorsGame({ const beatTimer = useRef(0); const beatHigh = useRef(false); - const scoreRef = useRef(0); const livesRef = useRef(3); const waveRef = useRef(1); - const phaseRef = useRef("ready"); - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); const [wave, setWave] = useState(1); const [banner, setBanner] = useState(null); const bannerTimeout = useRef(undefined); - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); - const showBanner = useCallback((text: string) => { setBanner(text); window.clearTimeout(bannerTimeout.current); @@ -279,13 +276,12 @@ export function MeteorsGame({ }, []); const reset = useCallback(() => { - scoreRef.current = 0; + startRound(); livesRef.current = 3; waveRef.current = 1; - setScore(0); setLives(3); setWave(1); - bestAtRoundStart.current = hiScoreRef.current; + keys.current.clear(); ship.current = { x: W / 2, y: H / 2, @@ -303,6 +299,7 @@ export function MeteorsGame({ fireCooldown.current = 0; hyperCooldown.current = 0; deathTimer.current = 0; + respawnWait.current = 0; waveDelay.current = 0; nextExtraLife.current = EXTRA_LIFE_EVERY; saucerTimer.current = SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); @@ -312,18 +309,16 @@ export function MeteorsGame({ beatHigh.current = false; spawnWave(1); changePhase("ready"); - }, [spawnWave, changePhase]); + }, [spawnWave, changePhase, startRound, keys]); const gameOver = useCallback(() => { beep(300, 40, 0.7, 0.09, "sawtooth"); - changePhase("over"); - onGameOverRef.current(scoreRef.current); - }, [changePhase]); + endGame(); + }, [endGame]); const addScore = useCallback( (points: number) => { - scoreRef.current += points; - setScore(scoreRef.current); + coreAddScore(points); while (scoreRef.current >= nextExtraLife.current) { nextExtraLife.current += EXTRA_LIFE_EVERY; livesRef.current += 1; @@ -332,7 +327,7 @@ export function MeteorsGame({ beep(784, 1568, 0.25, 0.07, "triangle"); } }, - [showBanner], + [coreAddScore, scoreRef, showBanner], ); const tryFire = useCallback(() => { @@ -394,11 +389,15 @@ export function MeteorsGame({ s.alive = false; s.thrusting = false; touch.current = { left: false, right: false, thrust: false }; + // Drop held keys so e.g. a Space held through the death can't auto-fire + // the moment the ship respawns. + keys.current.clear(); deathTimer.current = DEATH_FREEZE; + respawnWait.current = 0; livesRef.current -= 1; setLives(livesRef.current); beep(400, 40, 0.6, 0.09, "sawtooth"); - }, [createDebris]); + }, [createDebris, keys]); const respawn = useCallback((invuln: number) => { const s = ship.current; @@ -455,7 +454,7 @@ export function MeteorsGame({ crossed: 0, }; beep(500, 900, 0.3, 0.04, "sine"); - }, []); + }, [scoreRef]); const saucerFire = useCallback(() => { const u = saucer.current; @@ -510,239 +509,234 @@ export function MeteorsGame({ return true; }, []); - const tick = useCallback( - (dt: number) => { - const dts = dt / 1000; - const s = ship.current; + const tick = (dt: number) => { + const dts = dt / 1000; + const s = ship.current; - fireCooldown.current = Math.max(0, fireCooldown.current - dt); - hyperCooldown.current = Math.max(0, hyperCooldown.current - dt); - - // --- ship control / physics --- - if (s.alive) { - if (s.invuln > 0) s.invuln = Math.max(0, s.invuln - dt); - const k = keys.current; - const left = k.has("arrowleft") || k.has("a") || touch.current.left; - const right = k.has("arrowright") || k.has("d") || touch.current.right; - if (left) s.angle -= SHIP_ROT * dts; - if (right) s.angle += SHIP_ROT * dts; - - s.thrusting = k.has("arrowup") || k.has("w") || touch.current.thrust; - if (s.thrusting) { - s.vx += Math.cos(s.angle) * SHIP_THRUST * dts; - s.vy += Math.sin(s.angle) * SHIP_THRUST * dts; - thrustSound.current -= dt; - if (thrustSound.current <= 0) { - thrustSound.current = 150; - beep(70, 55, 0.12, 0.04, "sawtooth"); - } + fireCooldown.current = Math.max(0, fireCooldown.current - dt); + hyperCooldown.current = Math.max(0, hyperCooldown.current - dt); + + // --- ship control / physics --- + if (s.alive) { + if (s.invuln > 0) s.invuln = Math.max(0, s.invuln - dt); + const k = keys.current; + const left = k.has("arrowleft") || k.has("a") || touch.current.left; + const right = k.has("arrowright") || k.has("d") || touch.current.right; + if (left) s.angle -= SHIP_ROT * dts; + if (right) s.angle += SHIP_ROT * dts; + + s.thrusting = k.has("arrowup") || k.has("w") || touch.current.thrust; + if (s.thrusting) { + s.vx += Math.cos(s.angle) * SHIP_THRUST * dts; + s.vy += Math.sin(s.angle) * SHIP_THRUST * dts; + thrustSound.current -= dt; + if (thrustSound.current <= 0) { + thrustSound.current = 150; + beep(70, 55, 0.12, 0.04, "sawtooth"); } - // Exponential drift damping, then hard speed cap. - const damp = Math.exp(-SHIP_FRICTION * dts); - s.vx *= damp; - s.vy *= damp; - const sp = Math.hypot(s.vx, s.vy); - if (sp > SHIP_MAX_SPEED) { - s.vx = (s.vx / sp) * SHIP_MAX_SPEED; - s.vy = (s.vy / sp) * SHIP_MAX_SPEED; - } - s.x = wrap(s.x + s.vx * dts, W); - s.y = wrap(s.y + s.vy * dts, H); + } + // Exponential drift damping, then hard speed cap. + const damp = Math.exp(-SHIP_FRICTION * dts); + s.vx *= damp; + s.vy *= damp; + const sp = Math.hypot(s.vx, s.vy); + if (sp > SHIP_MAX_SPEED) { + s.vx = (s.vx / sp) * SHIP_MAX_SPEED; + s.vy = (s.vy / sp) * SHIP_MAX_SPEED; + } + s.x = wrap(s.x + s.vx * dts, W); + s.y = wrap(s.y + s.vy * dts, H); - if (k.has(" ")) tryFire(); + if (k.has(" ")) tryFire(); + } else { + // Shattered — count down, then wait for a clear center to respawn. + if (deathTimer.current > 0) { + deathTimer.current -= dt; + } else if (livesRef.current <= 0) { + gameOver(); + return; } else { - // Shattered — count down, then wait for a clear center to respawn. - if (deathTimer.current > 0) { - deathTimer.current -= dt; - } else if (livesRef.current <= 0) { - gameOver(); - return; - } else if (centerClear()) { + // Wait for a clear center, but never forever — a rock orbiting the + // middle would otherwise stall the respawn indefinitely. + respawnWait.current += dt; + if (centerClear() || respawnWait.current >= RESPAWN_FORCE_MS) { respawn(INVULN_TIME); } } + } - // --- debris --- - if (debris.current.length > 0) { - for (const d of debris.current) { - d.x = wrap(d.x + d.vx * dts, W); - d.y = wrap(d.y + d.vy * dts, H); - d.angle += d.spin * dts; - d.ttl -= dt; - } - debris.current = debris.current.filter((d) => d.ttl > 0); + // --- debris --- + if (debris.current.length > 0) { + for (const d of debris.current) { + d.x = wrap(d.x + d.vx * dts, W); + d.y = wrap(d.y + d.vy * dts, H); + d.angle += d.spin * dts; + d.ttl -= dt; } + debris.current = debris.current.filter((d) => d.ttl > 0); + } - // --- bullets --- - for (const b of bullets.current) { - b.x = wrap(b.x + b.vx * dts, W); - b.y = wrap(b.y + b.vy * dts, H); - b.ttl -= dt; - } - bullets.current = bullets.current.filter((b) => b.ttl > 0); + // --- bullets --- + for (const b of bullets.current) { + b.x = wrap(b.x + b.vx * dts, W); + b.y = wrap(b.y + b.vy * dts, H); + b.ttl -= dt; + } + bullets.current = bullets.current.filter((b) => b.ttl > 0); - // --- rocks --- - for (const rock of rocks.current) { - rock.x = wrap(rock.x + rock.vx * dts, W); - rock.y = wrap(rock.y + rock.vy * dts, H); - rock.angle += rock.spin * dts; - } + // --- rocks --- + for (const rock of rocks.current) { + rock.x = wrap(rock.x + rock.vx * dts, W); + rock.y = wrap(rock.y + rock.vy * dts, H); + rock.angle += rock.spin * dts; + } - // --- saucer --- - if (saucer.current) { - const u = saucer.current; - u.x += u.vx * dts; - u.crossed += Math.abs(u.vx) * dts; - u.y = wrap(u.y + u.vy * dts, H); - warbleSound.current -= dt; - if (warbleSound.current <= 0) { - warbleSound.current = 550; - beep(u.big ? 440 : 620, u.big ? 620 : 440, 0.14, 0.035, "sine"); - } - u.fireTimer -= dt; - if (u.fireTimer <= 0) { - u.fireTimer = u.fireEvery * (0.7 + Math.random() * 0.6); - saucerFire(); - } - // Leave once it has travelled the full width plus a margin. - if (u.crossed > W + u.r * 2) { - saucer.current = null; - saucerTimer.current = - SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); - } - } else { - saucerTimer.current -= dt; - if (saucerTimer.current <= 0) spawnSaucer(); + // --- saucer --- + if (saucer.current) { + const u = saucer.current; + u.x += u.vx * dts; + u.crossed += Math.abs(u.vx) * dts; + u.y = wrap(u.y + u.vy * dts, H); + warbleSound.current -= dt; + if (warbleSound.current <= 0) { + warbleSound.current = 550; + beep(u.big ? 440 : 620, u.big ? 620 : 440, 0.14, 0.035, "sine"); } - - // --- saucer bullets --- - for (const b of saucerBullets.current) { - b.x = wrap(b.x + b.vx * dts, W); - b.y = wrap(b.y + b.vy * dts, H); - b.ttl -= dt; + u.fireTimer -= dt; + if (u.fireTimer <= 0) { + u.fireTimer = u.fireEvery * (0.7 + Math.random() * 0.6); + saucerFire(); } - saucerBullets.current = saucerBullets.current.filter((b) => b.ttl > 0); - - // --- collisions: player bullets vs rocks / saucer --- - const survivingRocks: Rock[] = []; - const spentBullets = new Set(); - for (const rock of rocks.current) { - let hit = false; - for (const b of bullets.current) { - if (spentBullets.has(b)) continue; - if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, rock.x, rock.y) < rock.radius ** 2) { - hit = true; - spentBullets.add(b); - survivingRocks.push(...splitRock(rock, true)); - break; - } - } - if (!hit) survivingRocks.push(rock); + // Leave once it has travelled the full width plus a margin. + if (u.crossed > W + u.r * 2) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); } - rocks.current = survivingRocks; - - if (saucer.current) { - const u = saucer.current; - for (const b of bullets.current) { - if (spentBullets.has(b)) continue; - if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, u.x, u.y) < u.r ** 2) { - spentBullets.add(b); - addScore(u.points); - beep(900, 120, 0.35, 0.08, "sawtooth"); - saucer.current = null; - saucerTimer.current = - SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); - break; - } + } else { + saucerTimer.current -= dt; + if (saucerTimer.current <= 0) spawnSaucer(); + } + + // --- saucer bullets --- + for (const b of saucerBullets.current) { + b.x = wrap(b.x + b.vx * dts, W); + b.y = wrap(b.y + b.vy * dts, H); + b.ttl -= dt; + } + saucerBullets.current = saucerBullets.current.filter((b) => b.ttl > 0); + + // --- collisions: player bullets vs rocks / saucer --- + const survivingRocks: Rock[] = []; + const spentBullets = new Set(); + for (const rock of rocks.current) { + let hit = false; + for (const b of bullets.current) { + if (spentBullets.has(b)) continue; + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, rock.x, rock.y) < rock.radius ** 2) { + hit = true; + spentBullets.add(b); + survivingRocks.push(...splitRock(rock, true)); + break; } } - if (spentBullets.size > 0) { - bullets.current = bullets.current.filter((b) => !spentBullets.has(b)); - } + if (!hit) survivingRocks.push(rock); + } + rocks.current = survivingRocks; - // --- collisions: saucer vs rocks (splits, no points) --- - if (saucer.current) { - const u = saucer.current; - const kept: Rock[] = []; - let smashed = false; - for (const rock of rocks.current) { - if (!smashed && torDist2(u.x, u.y, rock.x, rock.y) < (u.r + rock.radius) ** 2) { - smashed = true; - kept.push(...splitRock(rock, false)); - } else { - kept.push(rock); - } - } - rocks.current = kept; - if (smashed) { + if (saucer.current) { + const u = saucer.current; + for (const b of bullets.current) { + if (spentBullets.has(b)) continue; + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, u.x, u.y) < u.r ** 2) { + spentBullets.add(b); + addScore(u.points); + beep(900, 120, 0.35, 0.08, "sawtooth"); saucer.current = null; saucerTimer.current = SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + break; } } + } + if (spentBullets.size > 0) { + bullets.current = bullets.current.filter((b) => !spentBullets.has(b)); + } - // --- collisions vs ship --- - if (s.alive && s.invuln <= 0) { - for (const rock of rocks.current) { - if (torDist2(s.x, s.y, rock.x, rock.y) < (rock.radius + SHIP_RADIUS) ** 2) { - killShip(); - break; - } + // --- collisions: saucer vs rocks (splits, no points) --- + if (saucer.current) { + const u = saucer.current; + const kept: Rock[] = []; + let smashed = false; + for (const rock of rocks.current) { + if (!smashed && torDist2(u.x, u.y, rock.x, rock.y) < (u.r + rock.radius) ** 2) { + smashed = true; + kept.push(...splitRock(rock, false)); + } else { + kept.push(rock); } } - if (s.alive && s.invuln <= 0) { - for (const b of saucerBullets.current) { - if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, s.x, s.y) < (SHIP_RADIUS + 3) ** 2) { - saucerBullets.current = saucerBullets.current.filter((x) => x !== b); - killShip(); - break; - } + rocks.current = kept; + if (smashed) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + } + } + + // --- collisions vs ship --- + if (s.alive && s.invuln <= 0) { + for (const rock of rocks.current) { + if (torDist2(s.x, s.y, rock.x, rock.y) < (rock.radius + SHIP_RADIUS) ** 2) { + killShip(); + break; } } - if (s.alive && s.invuln <= 0 && saucer.current) { - const u = saucer.current; - if (torDist2(s.x, s.y, u.x, u.y) < (u.r + SHIP_RADIUS) ** 2) { - saucer.current = null; - saucerTimer.current = - SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + } + if (s.alive && s.invuln <= 0) { + const spentSaucerBullets = new Set(); + for (const b of saucerBullets.current) { + if (sweptDist2(b.x, b.y, b.vx, b.vy, dts, s.x, s.y) < (SHIP_RADIUS + 3) ** 2) { + spentSaucerBullets.add(b); killShip(); + break; } } + if (spentSaucerBullets.size > 0) { + saucerBullets.current = saucerBullets.current.filter((b) => !spentSaucerBullets.has(b)); + } + } + if (s.alive && s.invuln <= 0 && saucer.current) { + const u = saucer.current; + if (torDist2(s.x, s.y, u.x, u.y) < (u.r + SHIP_RADIUS) ** 2) { + saucer.current = null; + saucerTimer.current = + SAUCER_MIN_DELAY + Math.random() * (SAUCER_MAX_DELAY - SAUCER_MIN_DELAY); + killShip(); + } + } - // --- wave progression --- - if (rocks.current.length === 0) { - if (waveDelay.current <= 0) waveDelay.current = 1600; - else { - waveDelay.current -= dt; - if (waveDelay.current <= 0) { - waveDelay.current = 0; - nextWave(); - } + // --- wave progression --- + if (rocks.current.length === 0) { + if (waveDelay.current <= 0) waveDelay.current = 1600; + else { + waveDelay.current -= dt; + if (waveDelay.current <= 0) { + waveDelay.current = 0; + nextWave(); } } + } - // --- two-tone heartbeat, faster as the field thins out --- - const mass = rocks.current.reduce((sum, r) => sum + ROCK_SPECS[r.size].mass, 0); - beatTimer.current -= dt; - if (beatTimer.current <= 0 && mass > 0 && phaseRef.current === "playing") { - beatTimer.current = Math.max(240, 200 + mass * 28); - beatHigh.current = !beatHigh.current; - beep(beatHigh.current ? 60 : 44, beatHigh.current ? 60 : 44, 0.12, 0.05, "triangle"); - } - }, - [ - tryFire, - gameOver, - centerClear, - respawn, - splitRock, - addScore, - spawnSaucer, - saucerFire, - killShip, - nextWave, - ], - ); + // --- two-tone heartbeat, faster as the field thins out --- + const mass = rocks.current.reduce((sum, r) => sum + ROCK_SPECS[r.size].mass, 0); + beatTimer.current -= dt; + if (beatTimer.current <= 0 && mass > 0 && phaseRef.current === "playing") { + beatTimer.current = Math.max(240, 200 + mass * 28); + beatHigh.current = !beatHigh.current; + beep(beatHigh.current ? 60 : 44, beatHigh.current ? 60 : 44, 0.12, 0.05, "triangle"); + } + }; // --- drawing ------------------------------------------------------------ @@ -900,36 +894,14 @@ export function MeteorsGame({ reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — a death - // reports through gameOver(), so this only covers the quit path. - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); useEffect(() => { - if (phase !== "playing") { - draw(); - return; - } - let raf = 0; - let last = performance.now(); - const frame = (now: number) => { - const dt = Math.min(50, now - last); - last = now; - tick(dt); - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, tick, draw]); + if (phase !== "playing") draw(); + }, [phase, draw]); const start = useCallback(() => { beep(440, 880, 0.12); @@ -960,6 +932,10 @@ export function MeteorsGame({ start(); return; } + if (currentPhase === "over") { + if (key === " " && !event.repeat) reset(); + return; + } if (currentPhase === "playing") { if (key === " " && !event.repeat) tryFire(); if ((key === "shift" || key === "arrowdown") && !event.repeat) doHyperspace(); @@ -979,17 +955,9 @@ export function MeteorsGame({ else if (currentPhase === "paused") changePhase("playing"); } }; - const onKeyUp = (event: KeyboardEvent) => { - keys.current.delete(event.key.toLowerCase()); - }; - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - }; - }, [start, changePhase, reset, tryFire, doHyperspace]); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phaseRef, keys, start, changePhase, reset, tryFire, doHyperspace]); const canvasX = useCallback((clientX: number) => { const canvas = canvasRef.current; @@ -1048,7 +1016,7 @@ export function MeteorsGame({ tapInfo.current = { t: performance.now(), x: t.clientX, y: t.clientY, moved: false }; applyZones(event.touches); }, - [start, reset, changePhase, doHyperspace, applyZones], + [phaseRef, start, reset, changePhase, doHyperspace, applyZones], ); const onTouchMove = useCallback( @@ -1062,7 +1030,7 @@ export function MeteorsGame({ } if (phaseRef.current === "playing") applyZones(event.touches); }, - [applyZones], + [phaseRef, applyZones], ); const onTouchEnd = useCallback( @@ -1084,59 +1052,40 @@ export function MeteorsGame({ applyZones(event.touches); } }, - [tryFire, applyZones], + [phaseRef, tryFire, applyZones], ); - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; - return ( -
-
- - SCORE {formatScore(score)} - - - WAVE {wave.toString().padStart(2, "0")} - - {"▲".repeat(Math.max(0, lives))} - - HI {formatScore(Math.max(hiScore, score))} - -
-
- - {banner && phase === "playing" &&
{banner}
} - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - Press any key to launch — or tap - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Enter or tap to play again - - )} -
- )} -
-
-

◀ ▶ TURN — ▲ THRUST — SPACE FIRE — ⇧ JUMP — P PAUSE

-
+ + + WAVE {wave.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + } + controls="◀ ▶ TURN — ▲ THRUST — SPACE FIRE — ⇧ JUMP — P PAUSE" + > + + {banner && phase === "playing" &&
{banner}
} + +
); } diff --git a/apps/site/src/app/arcade/_components/muncher-game.tsx b/apps/site/src/app/arcade/_components/muncher-game.tsx index de7581fe95..81e9b2fabc 100644 --- a/apps/site/src/app/arcade/_components/muncher-game.tsx +++ b/apps/site/src/app/arcade/_components/muncher-game.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { beep } from "./arcade-audio"; +import { GameShell, PhaseOverlay, useGameCore, useGameLoop, type GameProps } from "./game-kit"; import styles from "./arcade.module.css"; const TILE = 24; @@ -55,7 +56,6 @@ const FRIGHT_SPEED = 4.5; const EYES_SPEED = 13; const DEN_SPEED = 5; -type Phase = "ready" | "playing" | "paused" | "over"; type DirName = "up" | "down" | "left" | "right"; type EnemyKind = "chaser" | "ambusher" | "wanderer" | "patroller"; type EnemyMode = "chase" | "frightened" | "eyes"; @@ -184,10 +184,6 @@ const ENEMY_DEFS: Omit[] = [ }, ]; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - function makeEnemy(def: (typeof ENEMY_DEFS)[number]): Enemy { return { ...def, @@ -208,15 +204,12 @@ function frightenedDuration(level: number) { return Math.max(2000, 6000 - 500 * (level - 1)); } -export function MuncherGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +export function MuncherGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const { phase, phaseRef, changePhase, score, addScore, startRound, endGame, isNewBest } = + useGameCore({ hiScore, onGameOver }); + const player = useRef({ ...PLAYER_SPAWN, prog: 0, dir: "left", desired: "left" }); const enemies = useRef(ENEMY_DEFS.map(makeEnemy)); const dots = useRef([]); @@ -233,28 +226,14 @@ export function MuncherGame({ const pending = useRef<"death" | null>(null); const anim = useRef(0); - const scoreRef = useRef(0); const livesRef = useRef(3); const levelRef = useRef(1); - const phaseRef = useRef("ready"); - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); const [level, setLevel] = useState(1); const [banner, setBanner] = useState(null); const bannerTimeout = useRef(undefined); - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); - const showBanner = useCallback((text: string) => { setBanner(text); window.clearTimeout(bannerTimeout.current); @@ -297,13 +276,11 @@ export function MuncherGame({ }, []); const reset = useCallback(() => { - scoreRef.current = 0; livesRef.current = 3; levelRef.current = 1; - setScore(0); setLives(3); setLevel(1); - bestAtRoundStart.current = hiScoreRef.current; + startRound(); buildDots(); fruit.current = null; fruitStage.current = 0; @@ -312,13 +289,12 @@ export function MuncherGame({ anim.current = 0; placeActors(); changePhase("ready"); - }, [buildDots, placeActors, changePhase]); + }, [startRound, buildDots, placeActors, changePhase]); const gameOver = useCallback(() => { beep(300, 40, 0.7, 0.09, "sawtooth"); - changePhase("over"); - onGameOverRef.current(scoreRef.current); - }, [changePhase]); + endGame(); + }, [endGame]); const start = useCallback(() => { beep(440, 880, 0.12); @@ -329,43 +305,45 @@ export function MuncherGame({ // Alternating two-note "waka" as dots are eaten. const wakaHigh = useRef(false); - const eatAt = useCallback((c: number, r: number) => { - const cell = dots.current[r]?.[c]; - if (!cell) return; - if (cell === 1) { - scoreRef.current += 10; - wakaHigh.current = !wakaHigh.current; - beep(wakaHigh.current ? 320 : 240, wakaHigh.current ? 260 : 200, 0.05, 0.04, "square"); - } else { - scoreRef.current += 50; - // Power pellet — frighten every active enemy and reverse it. - frightTimer.current = frightenedDuration(levelRef.current); - eatValue.current = 200; - for (const e of enemies.current) { - if (e.state === "out" && e.mode !== "eyes") { - e.mode = "frightened"; - reverseActor(e); + const eatAt = useCallback( + (c: number, r: number) => { + const cell = dots.current[r]?.[c]; + if (!cell) return; + if (cell === 1) { + addScore(10); + wakaHigh.current = !wakaHigh.current; + beep(wakaHigh.current ? 320 : 240, wakaHigh.current ? 260 : 200, 0.05, 0.04, "square"); + } else { + addScore(50); + // Power pellet — frighten every active enemy and reverse it. + frightTimer.current = frightenedDuration(levelRef.current); + eatValue.current = 200; + for (const e of enemies.current) { + if (e.state === "out" && e.mode !== "eyes") { + e.mode = "frightened"; + reverseActor(e); + } } + beep(180, 520, 0.3, 0.07, "square"); } - beep(180, 520, 0.3, 0.07, "square"); - } - dots.current[r][c] = 0; - dotCount.current -= 1; - setScore(scoreRef.current); - - // Fruit surfaces twice per level, at roughly a third and two thirds eaten. - const eaten = TOTAL_DOTS - dotCount.current; - const thresholds = [Math.floor(TOTAL_DOTS * 0.32), Math.floor(TOTAL_DOTS * 0.66)]; - if (fruitStage.current < 2 && eaten >= thresholds[fruitStage.current] && !fruit.current) { - fruit.current = { - c: DEN_C, - r: 13, - ttl: 9000, - value: 100 + 100 * levelRef.current, - }; - fruitStage.current += 1; - } - }, []); + dots.current[r][c] = 0; + dotCount.current -= 1; + + // Fruit surfaces twice per level, at roughly a third and two thirds eaten. + const eaten = TOTAL_DOTS - dotCount.current; + const thresholds = [Math.floor(TOTAL_DOTS * 0.32), Math.floor(TOTAL_DOTS * 0.66)]; + if (fruitStage.current < 2 && eaten >= thresholds[fruitStage.current] && !fruit.current) { + fruit.current = { + c: DEN_C, + r: 13, + ttl: 9000, + value: 100 + 100 * levelRef.current, + }; + fruitStage.current += 1; + } + }, + [addScore], + ); // --- movement ----------------------------------------------------------- @@ -409,8 +387,7 @@ export function MuncherGame({ const f = fruit.current; eatAt(pl.c, pl.r); if (f && f === fruit.current && f.c === pl.c && f.r === pl.r) { - scoreRef.current += f.value; - setScore(scoreRef.current); + addScore(f.value); fruit.current = null; beep(700, 1200, 0.25, 0.07, "triangle"); } @@ -418,7 +395,7 @@ export function MuncherGame({ }, ); }, - [advance, eatAt], + [advance, eatAt, addScore], ); const chooseEnemyDir = useCallback((e: Enemy) => { @@ -547,8 +524,7 @@ export function MuncherGame({ const dx = Math.min(rawDx, W - rawDx); if (dx > TILE * 0.55 || Math.abs(pp.y - ep.y) > TILE * 0.55) continue; if (e.mode === "frightened") { - scoreRef.current += eatValue.current; - setScore(scoreRef.current); + addScore(eatValue.current); eatValue.current = Math.min(1600, eatValue.current * 2); e.mode = "eyes"; beep(1000, 1600, 0.18, 0.07, "square"); @@ -557,73 +533,60 @@ export function MuncherGame({ return; } } - }, [actorPixel, killPlayer]); - - const tick = useCallback( - (dt: number) => { - if (freeze.current > 0) { - freeze.current -= dt; - if (freeze.current <= 0 && pending.current === "death") { - pending.current = null; - if (livesRef.current <= 0) { - gameOver(); - return; - } - placeActors(); + }, [actorPixel, killPlayer, addScore]); + + const tick = (dt: number) => { + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && pending.current === "death") { + pending.current = null; + if (livesRef.current <= 0) { + gameOver(); + return; } - return; + placeActors(); } + return; + } - anim.current += dt; - denClock.current += dt; + anim.current += dt; + denClock.current += dt; - patrolTimer.current += dt; - if (patrolTimer.current >= 8000) { - patrolTimer.current -= 8000; - patrolChase.current = !patrolChase.current; - } + patrolTimer.current += dt; + if (patrolTimer.current >= 8000) { + patrolTimer.current -= 8000; + patrolChase.current = !patrolChase.current; + } - if (frightTimer.current > 0) { - frightTimer.current -= dt; - if (frightTimer.current <= 0) { - for (const e of enemies.current) { - if (e.mode === "frightened") e.mode = "chase"; - } + if (frightTimer.current > 0) { + frightTimer.current -= dt; + if (frightTimer.current <= 0) { + for (const e of enemies.current) { + if (e.mode === "frightened") e.mode = "chase"; } } + } - // Staggered release from the den. - for (const e of enemies.current) { - if (e.state === "den" && denClock.current >= e.release) e.state = "leaving"; - } + // Staggered release from the den. + for (const e of enemies.current) { + if (e.state === "den" && denClock.current >= e.release) e.state = "leaving"; + } - updatePlayer(dt); - for (const e of enemies.current) { - advance(e, enemySpeed(e), dt, enemyAllowDen, (a) => onEnemyArrive(a as Enemy)); - } + updatePlayer(dt); + for (const e of enemies.current) { + advance(e, enemySpeed(e), dt, enemyAllowDen, (a) => onEnemyArrive(a as Enemy)); + } - checkCollisions(); - if (freeze.current > 0) return; // a death was just triggered + checkCollisions(); + if (freeze.current > 0) return; // a death was just triggered - if (fruit.current) { - fruit.current.ttl -= dt; - if (fruit.current.ttl <= 0) fruit.current = null; - } + if (fruit.current) { + fruit.current.ttl -= dt; + if (fruit.current.ttl <= 0) fruit.current = null; + } - if (dotCount.current <= 0) nextLevel(); - }, - [ - updatePlayer, - advance, - enemySpeed, - enemyAllowDen, - onEnemyArrive, - checkCollisions, - gameOver, - placeActors, - nextLevel, - ], - ); + if (dotCount.current <= 0) nextLevel(); + }; // --- drawing ------------------------------------------------------------ @@ -820,36 +783,14 @@ export function MuncherGame({ reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — a death - // reports through gameOver(), so this only covers the quit path. - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); useEffect(() => { - if (phase !== "playing") { - draw(); - return; - } - let raf = 0; - let last = performance.now(); - const frame = (now: number) => { - const dt = Math.min(50, now - last); - last = now; - tick(dt); - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, tick, draw]); + if (phase !== "playing") draw(); + }, [phase, draw]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -881,7 +822,7 @@ export function MuncherGame({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [start, changePhase, reset]); + }, [phaseRef, start, changePhase, reset]); const touchStart = useRef<{ x: number; y: number } | null>(null); @@ -903,74 +844,58 @@ export function MuncherGame({ const t = event.touches[0]; touchStart.current = { x: t.clientX, y: t.clientY }; }, - [start, reset, changePhase], + [phaseRef, start, reset, changePhase], ); - const onTouchMove = useCallback((event: React.TouchEvent) => { - const s = touchStart.current; - if (!s || phaseRef.current !== "playing") return; - const t = event.touches[0]; - const dx = t.clientX - s.x; - const dy = t.clientY - s.y; - if (Math.abs(dx) < 14 && Math.abs(dy) < 14) return; - player.current.desired = - Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up"; - touchStart.current = { x: t.clientX, y: t.clientY }; - }, []); + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + const s = touchStart.current; + if (!s || phaseRef.current !== "playing") return; + const t = event.touches[0]; + const dx = t.clientX - s.x; + const dy = t.clientY - s.y; + if (Math.abs(dx) < 14 && Math.abs(dy) < 14) return; + player.current.desired = + Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up"; + touchStart.current = { x: t.clientX, y: t.clientY }; + }, + [phaseRef], + ); const onTouchEnd = useCallback(() => { touchStart.current = null; }, []); - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; - return ( -
-
- - SCORE {formatScore(score)} - - - LV {level.toString().padStart(2, "0")} - - {"▲".repeat(Math.max(0, lives))} - - HI {formatScore(Math.max(hiScore, score))} - -
-
- - {banner && phase === "playing" &&
{banner}
} - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - Press any key to munch — or tap - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Enter or tap to play again - - )} -
- )} -
-
-

◀ ▶ ▲ ▼ MOVE — P PAUSE

-
+ + + LV {level.toString().padStart(2, "0")} + + {"▲".repeat(Math.max(0, lives))} + + } + controls="◀ ▶ ▲ ▼ MOVE — P PAUSE" + > + + {banner && phase === "playing" &&
{banner}
} + +
); } diff --git a/apps/site/src/app/arcade/_components/reveal.tsx b/apps/site/src/app/arcade/_components/reveal.tsx new file mode 100644 index 0000000000..cb83ca03b0 --- /dev/null +++ b/apps/site/src/app/arcade/_components/reveal.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; +import styles from "./arcade.module.css"; + +/** Fades + lifts its children into view the first time they intersect. */ +export function Reveal({ children, className }: { children: ReactNode; className?: string }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add(styles.in); + io.unobserve(entry.target); + } + }); + }, + { threshold: 0.12 }, + ); + io.observe(el); + return () => io.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/site/src/app/arcade/_components/snake-game.tsx b/apps/site/src/app/arcade/_components/snake-game.tsx index ef8b040498..c0e39c3b41 100644 --- a/apps/site/src/app/arcade/_components/snake-game.tsx +++ b/apps/site/src/app/arcade/_components/snake-game.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { beep } from "./arcade-audio"; +import { GameShell, PhaseOverlay, useGameCore, useGameLoop, type GameProps } from "./game-kit"; import styles from "./arcade.module.css"; const COLS = 21; @@ -13,7 +14,6 @@ const SPEEDUP_MS = 3; const POINTS_PER_APPLE = 10; type Vec = { x: number; y: number }; -type Phase = "ready" | "playing" | "paused" | "over"; const KEY_DIRS: Record = { arrowup: { x: 0, y: -1 }, @@ -26,19 +26,12 @@ const KEY_DIRS: Record = { d: { x: 1, y: 0 }, }; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - -export function SnakeGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +export function SnakeGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const { phase, phaseRef, changePhase, score, addScore, startRound, endGame, isNewBest } = + useGameCore({ hiScore, onGameOver }); + // Game state lives in refs — the rAF loop mutates it every tick without // paying for a React render. Only phase/score cross into React state. const snakeRef = useRef([]); @@ -46,30 +39,22 @@ export function SnakeGame({ const queueRef = useRef([]); const foodRef = useRef({ x: 0, y: 0 }); const tickRef = useRef(START_TICK_MS); - const scoreRef = useRef(0); - const phaseRef = useRef("ready"); + const accRef = useRef(0); const touchStart = useRef<{ x: number; y: number } | null>(null); - // Snapshot of the hi-score when the round began — the live prop updates - // as soon as onGameOver fires, so it can't be used to detect a new best. - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); - - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); + /** Moves the food to a random free cell; false when the snake fills the board. */ const placeFood = useCallback(() => { const snake = snakeRef.current; - let food: Vec; - do { - food = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; - } while (snake.some((cell) => cell.x === food.x && cell.y === food.y)); - foodRef.current = food; + const occupied = new Set(snake.map((cell) => cell.y * COLS + cell.x)); + const free: Vec[] = []; + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + if (!occupied.has(y * COLS + x)) free.push({ x, y }); + } + } + if (free.length === 0) return false; + foodRef.current = free[Math.floor(Math.random() * free.length)]; + return true; }, []); const draw = useCallback(() => { @@ -139,12 +124,11 @@ export function SnakeGame({ dirRef.current = { x: 1, y: 0 }; queueRef.current = []; tickRef.current = START_TICK_MS; - scoreRef.current = 0; - setScore(0); - bestAtRoundStart.current = hiScoreRef.current; + accRef.current = 0; + startRound(); placeFood(); changePhase("ready"); - }, [placeFood, changePhase]); + }, [placeFood, changePhase, startRound]); const queueDirection = useCallback((dir: Vec) => { const queue = queueRef.current; @@ -173,27 +157,28 @@ export function SnakeGame({ if (hitWall || hitSelf) { beep(220, 55, 0.5, 0.08); - changePhase("over"); - onGameOver(scoreRef.current); + endGame(); return; } snake.unshift(head); if (eating) { - scoreRef.current += POINTS_PER_APPLE; - setScore(scoreRef.current); + addScore(POINTS_PER_APPLE); tickRef.current = Math.max(MIN_TICK_MS, tickRef.current - SPEEDUP_MS); - placeFood(); beep(660, 990, 0.09); + if (!placeFood()) { + // The snake fills the whole board — nothing left to eat. + endGame(); + } } else { snake.pop(); } - }, [changePhase, onGameOver, placeFood]); + }, [addScore, endGame, placeFood]); const start = useCallback( (dir?: Vec) => { - if (dir) { - dirRef.current = dir.x + dirRef.current.x === 0 ? dirRef.current : dir; + if (dir && !(dir.x === -dirRef.current.x && dir.y === -dirRef.current.y)) { + dirRef.current = dir; } beep(440, 880, 0.12); changePhase("playing"); @@ -205,43 +190,18 @@ export function SnakeGame({ reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — - // death already reports via step(), so only cover the quit path here. - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); - - useEffect(() => { - if (phase !== "playing") { - draw(); - return; + useGameLoop(phase === "playing", (dt) => { + accRef.current += dt; + while (accRef.current >= tickRef.current && phaseRef.current === "playing") { + accRef.current -= tickRef.current; + step(); } + draw(); + }); - let raf = 0; - let last = performance.now(); - let acc = 0; - const frame = (now: number) => { - acc += now - last; - last = now; - while (acc >= tickRef.current && phaseRef.current === "playing") { - acc -= tickRef.current; - step(); - } - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, step, draw]); + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, draw]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -271,7 +231,14 @@ export function SnakeGame({ window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [start, queueDirection, changePhase, reset]); + }, [phaseRef, start, queueDirection, changePhase, reset]); + + const onMouseDown = useCallback(() => { + const currentPhase = phaseRef.current; + if (currentPhase === "ready") start(); + else if (currentPhase === "over") reset(); + else if (currentPhase === "paused") changePhase("playing"); + }, [phaseRef, start, reset, changePhase]); const onTouchStart = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; @@ -305,52 +272,30 @@ export function SnakeGame({ if (currentPhase === "ready") start(dir); else if (currentPhase === "playing") queueDirection(dir); }, - [start, queueDirection, changePhase, reset], + [phaseRef, start, queueDirection, changePhase, reset], ); - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; - return ( -
-
- - SCORE {formatScore(score)} - - - HI {formatScore(Math.max(hiScore, score))} - -
-
- - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - Press an arrow key or swipe to move - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Enter or tap to play again - - )} -
- )} -
-
-

ARROWS / WASD MOVE — P PAUSE

-
+ + + + ); } diff --git a/apps/site/src/app/arcade/_components/stacker-game.tsx b/apps/site/src/app/arcade/_components/stacker-game.tsx index 0d7d3a14b7..a6fc7c6399 100644 --- a/apps/site/src/app/arcade/_components/stacker-game.tsx +++ b/apps/site/src/app/arcade/_components/stacker-game.tsx @@ -2,6 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { beep } from "./arcade-audio"; +import { + GameShell, + PhaseOverlay, + useGameCore, + useGameLoop, + useHeldKeys, + type GameProps, +} from "./game-kit"; import styles from "./arcade.module.css"; const COLS = 10; @@ -17,7 +25,6 @@ const LINE_POINTS = [0, 100, 300, 500, 800]; const LINES_PER_LEVEL = 10; const CLEAR_FLASH_MS = 260; -type Phase = "ready" | "playing" | "paused" | "over"; type PieceType = "I" | "O" | "T" | "S" | "Z" | "J" | "L"; type Cell = string | null; type Active = { type: PieceType; rot: number; x: number; y: number }; @@ -98,6 +105,10 @@ const PIECE_DEFS: Record = Object.fromEntries( PIECE_TYPES.map((type) => { const { size, cells } = PIECE_DEFS[type]; @@ -111,55 +122,34 @@ const ROTATIONS: Record = Object.fromEntries( const KICKS = [0, -1, 1, -2, 2]; -function formatScore(score: number) { - return score.toString().padStart(6, "0"); -} - function emptyBoard(): Cell[][] { return Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => null)); } -export function StackerGame({ - hiScore, - onGameOver, -}: { - hiScore: number; - onGameOver: (score: number) => void; -}) { +export function StackerGame({ hiScore, onGameOver }: GameProps) { const canvasRef = useRef(null); + const { phase, phaseRef, changePhase, score, addScore, startRound, endGame, isNewBest } = + useGameCore({ hiScore, onGameOver }); + const keys = useHeldKeys(); + const board = useRef(emptyBoard()); const active = useRef(null); const bag = useRef([]); const nextPiece = useRef("T"); - const keys = useRef(new Set()); const gravityAcc = useRef(0); const clearing = useRef([]); const freeze = useRef(0); const touchState = useRef<{ x: number; y: number; t: number; moved: number } | null>(null); - const scoreRef = useRef(0); const linesRef = useRef(0); const levelRef = useRef(1); - const phaseRef = useRef("ready"); - const bestAtRoundStart = useRef(0); - const hiScoreRef = useRef(hiScore); - hiScoreRef.current = hiScore; - const onGameOverRef = useRef(onGameOver); - onGameOverRef.current = onGameOver; - - const [phase, setPhase] = useState("ready"); - const [score, setScore] = useState(0); + const [lines, setLines] = useState(0); const [level, setLevel] = useState(1); const [banner, setBanner] = useState(null); const bannerTimeout = useRef(undefined); - const changePhase = useCallback((next: Phase) => { - phaseRef.current = next; - setPhase(next); - }, []); - const showBanner = useCallback((text: string) => { setBanner(text); window.clearTimeout(bannerTimeout.current); @@ -192,10 +182,10 @@ export function StackerGame({ const gameOver = useCallback(() => { active.current = null; + keys.current.clear(); beep(300, 40, 0.7, 0.09, "sawtooth"); - changePhase("over"); - onGameOverRef.current(scoreRef.current); - }, [changePhase]); + endGame(); + }, [keys, endGame]); const spawn = useCallback(() => { const type = nextPiece.current; @@ -208,11 +198,6 @@ export function StackerGame({ active.current = piece; }, [drawFromBag, collides, gameOver]); - const addScore = useCallback((points: number) => { - scoreRef.current += points; - setScore(scoreRef.current); - }, []); - const lock = useCallback(() => { const piece = active.current; if (!piece) return; @@ -281,6 +266,9 @@ export function StackerGame({ (dir: 1 | -1) => { const piece = active.current; if (!piece || freeze.current > 0) return; + // The O piece is rotation-invariant, but its naive rotation states + // aren't identical — kicks would make it drift sideways. Skip it. + if (piece.type === "O") return; const newRot = (piece.rot + dir + 4) % 4; for (const kick of KICKS) { if (!collides(piece.type, newRot, piece.x + kick, piece.y)) { @@ -320,20 +308,19 @@ export function StackerGame({ const reset = useCallback(() => { board.current = emptyBoard(); bag.current = []; - scoreRef.current = 0; linesRef.current = 0; levelRef.current = 1; - setScore(0); setLines(0); setLevel(1); - bestAtRoundStart.current = hiScoreRef.current; clearing.current = []; freeze.current = 0; gravityAcc.current = 0; + keys.current.clear(); nextPiece.current = drawFromBag(); active.current = null; + startRound(); changePhase("ready"); - }, [drawFromBag, changePhase]); + }, [drawFromBag, keys, startRound, changePhase]); const start = useCallback(() => { beep(440, 880, 0.12); @@ -341,32 +328,29 @@ export function StackerGame({ changePhase("playing"); }, [spawn, changePhase]); - const tick = useCallback( - (dt: number) => { - if (freeze.current > 0) { - freeze.current -= dt; - if (freeze.current <= 0 && clearing.current.length > 0) { - finishClear(); - } - return; + const tick = (dt: number) => { + if (freeze.current > 0) { + freeze.current -= dt; + if (freeze.current <= 0 && clearing.current.length > 0) { + finishClear(); } - if (!active.current) return; - - const softDropping = keys.current.has("arrowdown") || keys.current.has("s"); - const interval = softDropping ? 40 : Math.max(70, 800 * Math.pow(0.82, levelRef.current - 1)); - gravityAcc.current += dt; - while (gravityAcc.current >= interval) { - gravityAcc.current -= interval; - const before = active.current?.y ?? 0; - softStep(); - if (softDropping && active.current && active.current.y > before) { - addScore(1); - } - if (!active.current || freeze.current > 0) break; + return; + } + if (!active.current) return; + + const softDropping = keys.current.has("arrowdown") || keys.current.has("s"); + const interval = softDropping ? 40 : Math.max(70, 800 * Math.pow(0.82, levelRef.current - 1)); + gravityAcc.current += dt; + while (gravityAcc.current >= interval) { + gravityAcc.current -= interval; + const before = active.current?.y ?? 0; + softStep(); + if (softDropping && active.current && active.current.y > before) { + addScore(1); } - }, - [softStep, finishClear, addScore], - ); + if (!active.current || freeze.current > 0) break; + } + }; const drawCell = useCallback( (ctx: CanvasRenderingContext2D, px: number, py: number, color: string, size = CELL) => { @@ -470,41 +454,40 @@ export function StackerGame({ reset(); }, [reset]); - // Bank the running score if the player closes the overlay mid-game — - // death already reports via gameOver(), so only cover the quit path here. - useEffect( - () => () => { - if (phaseRef.current !== "over" && scoreRef.current > 0) { - onGameOverRef.current(scoreRef.current); - } - }, - [], - ); + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); useEffect(() => { - if (phase !== "playing") { - draw(); - return; - } - let raf = 0; - let last = performance.now(); - const frame = (now: number) => { - const dt = Math.min(50, now - last); - last = now; - tick(dt); - draw(); - if (phaseRef.current === "playing") { - raf = requestAnimationFrame(frame); - } - }; - raf = requestAnimationFrame(frame); - return () => cancelAnimationFrame(raf); - }, [phase, tick, draw]); + if (phase !== "playing") draw(); + }, [phase, draw]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); const currentPhase = phaseRef.current; + + // Restart/start must win over Space's in-game meaning (hard drop), + // or the game-keys branch below would swallow Space on the over screen. + if (key === " " || key === "enter") { + if (currentPhase === "over") { + event.preventDefault(); + reset(); + return; + } + if (currentPhase === "ready") { + event.preventDefault(); + start(); + return; + } + if (currentPhase === "paused" && key === "enter") { + event.preventDefault(); + changePhase("playing"); + return; + } + } + const gameKeys = [ "arrowleft", "arrowright", @@ -539,27 +522,12 @@ export function StackerGame({ if (key === "p" && (currentPhase === "playing" || currentPhase === "paused")) { changePhase(currentPhase === "playing" ? "paused" : "playing"); - return; } - - if (key === "enter") { - event.preventDefault(); - if (currentPhase === "ready") start(); - else if (currentPhase === "over") reset(); - else if (currentPhase === "paused") changePhase("playing"); - } - }; - const onKeyUp = (event: KeyboardEvent) => { - keys.current.delete(event.key.toLowerCase()); }; window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - }; - }, [start, move, rotate, hardDrop, changePhase, reset]); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phaseRef, keys, start, move, rotate, hardDrop, changePhase, reset]); const onTouchStart = useCallback( (event: React.TouchEvent) => { @@ -579,7 +547,7 @@ export function StackerGame({ const touch = event.touches[0]; touchState.current = { x: touch.clientX, y: touch.clientY, t: performance.now(), moved: 0 }; }, - [start, reset, changePhase], + [phaseRef, start, reset, changePhase], ); const onTouchMove = useCallback( @@ -589,7 +557,9 @@ export function StackerGame({ const touch = event.touches[0]; const canvas = canvasRef.current; const scale = canvas ? canvas.getBoundingClientRect().width / W : 1; - const threshold = CELL * scale; + // Floor at 1px: a zero-width canvas (display: none, mid-layout) would + // make a zero threshold spin the while-loops forever. + const threshold = Math.max(1, CELL * scale); // Drag sideways to slide the piece, one column per cell-width. while (touch.clientX - state.x > threshold) { move(1); @@ -608,7 +578,7 @@ export function StackerGame({ state.moved++; } }, - [move, softStep], + [phaseRef, move, softStep], ); const onTouchEnd = useCallback( @@ -623,60 +593,41 @@ export function StackerGame({ if (dt < 300 && dy > 60) hardDrop(); else if (dt < 250 && state.moved === 0) rotate(1); }, - [hardDrop, rotate], + [phaseRef, hardDrop, rotate], ); - const isNewBest = phase === "over" && score > 0 && score > bestAtRoundStart.current; - return ( -
-
- - SCORE {formatScore(score)} - - - LINES {lines.toString().padStart(3, "0")} - - - LV {level.toString().padStart(2, "0")} - - - HI {formatScore(Math.max(hiScore, score))} - -
-
- - {banner && phase === "playing" &&
{banner}
} - {phase !== "playing" && ( -
- {phase === "ready" && ( - <> - READY? - Press any key to start — or tap - - )} - {phase === "paused" && PAUSED} - {phase === "over" && ( - <> - GAME OVER - SCORE {formatScore(score)} - {isNewBest && ★ NEW HI-SCORE ★} - Press Enter or tap to play again - - )} -
- )} -
-
-

◀ ▶ MOVE — ▲ ROTATE — ▼ SOFT — SPACE DROP — P PAUSE

-
+ + + LINES {lines.toString().padStart(3, "0")} + + + LV {level.toString().padStart(2, "0")} + + + } + controls="◀ ▶ MOVE — ▲ ROTATE — ▼ SOFT — SPACE DROP — P PAUSE" + > + + {banner && phase === "playing" &&
{banner}
} + +
); } diff --git a/apps/site/src/app/arcade/games.ts b/apps/site/src/app/arcade/games.ts index a680504aac..ed7b68a7b9 100644 --- a/apps/site/src/app/arcade/games.ts +++ b/apps/site/src/app/arcade/games.ts @@ -1,10 +1,10 @@ /** - * The Prisma Arcade game registry. + * The Prisma Arcade game registry for the secondary "free play" grid. + * Comet Cat is the featured game and lives directly in the page hero. * - * Each game will get its own playable canvas implementation; for now each - * entry is a placeholder cabinet. Sprites are tiny pixel-art grids rendered - * by — one character per pixel, "." is transparent, every - * other character is looked up in the sprite's palette. + * Sprites are tiny pixel-art grids rendered by — one character + * per pixel, "." is transparent, every other character is looked up in the + * sprite's palette. */ export type PixelGrid = { @@ -12,28 +12,26 @@ export type PixelGrid = { palette: Record; }; +export type ArcadeGameId = "snake" | "invaders" | "stacker" | "muncher" | "meteors"; + export type ArcadeGame = { - id: string; + id: ArcadeGameId; title: string; tagline: string; - /** Accent color used for the cabinet glow, per-game. */ + /** Accent color used for the sprite glow on the card screen. */ color: string; - /** Placeholder until global persistence lands. */ - hiScore: number; - status: "playable" | "coming-soon"; + /** One-line control summary shown on the card and in the play dialog. */ + controls: string; sprite: PixelGrid; - blurb: string; }; export const GAMES: ArcadeGame[] = [ { id: "snake", - title: "SNAKE", - tagline: "The one from your childhood.", - blurb: "Eat the apples. Grow the tail. Don't hit the walls — and don't bite yourself.", + title: "Snake", + tagline: "Eat the apples. Don't bite yourself.", + controls: "Arrow keys or swipe to steer", color: "#4ade80", - hiScore: 0, - status: "playable", sprite: { palette: { G: "#4ade80", D: "#16a34a", R: "#f87171", W: "#f8fafc" }, rows: [ @@ -54,12 +52,10 @@ export const GAMES: ArcadeGame[] = [ }, { id: "invaders", - title: "INVADERS", + title: "Invaders", tagline: "Defend the planet. Again.", - blurb: "Wave after wave of aliens descend. Shoot them down before they reach the ground.", + controls: "Arrows move, Space fires", color: "#22d3ee", - hiScore: 0, - status: "playable", sprite: { palette: { M: "#22d3ee", E: "#0f172a" }, rows: [ @@ -78,12 +74,10 @@ export const GAMES: ArcadeGame[] = [ }, { id: "stacker", - title: "STACKER", + title: "Stacker", tagline: "The falling blocks. You know the ones.", - blurb: "Stack the falling pieces, clear the lines, chase the elusive four-at-once.", + controls: "Arrows move, Up rotates, Space drops", color: "#c084fc", - hiScore: 0, - status: "playable", sprite: { palette: { P: "#c084fc", @@ -110,12 +104,10 @@ export const GAMES: ArcadeGame[] = [ }, { id: "muncher", - title: "MUNCHER", + title: "Muncher", tagline: "Chomp the maze. Dodge the critters.", - blurb: "Gobble every dot, grab a power pellet, and turn the tables on the bugs chasing you.", + controls: "Arrow keys or swipe to steer", color: "#facc15", - hiScore: 0, - status: "playable", sprite: { palette: { Y: "#facc15", W: "#fde68a" }, rows: [ @@ -136,12 +128,10 @@ export const GAMES: ArcadeGame[] = [ }, { id: "meteors", - title: "METEORS", + title: "Meteors", tagline: "Drift, spin, shoot the rocks.", - blurb: "Blast the tumbling rocks to bits, dodge the flying saucer, and don't get boxed in.", + controls: "Arrows steer, Space fires, H hyperspace", color: "#f8fafc", - hiScore: 0, - status: "playable", sprite: { palette: { W: "#f8fafc", D: "#94a3b8" }, rows: [ @@ -160,35 +150,4 @@ export const GAMES: ArcadeGame[] = [ ], }, }, - { - id: "comet", - title: "COMET CAT", - tagline: "Flap. Drift. Leave a trail.", - blurb: "One cat, endless pillars, and a brand-new comet tail. How far can you fly?", - color: "#7cdae1", - hiScore: 0, - status: "playable", - sprite: { - palette: { - T: "#7cdae1", - Y: "#edcd5f", - R: "#e37780", - G: "#9ca3af", - D: "#4b5563", - K: "#1f2937", - P: "#f2a0ac", - }, - rows: [ - "............", - "......DD.DD.", - "......DGDGD.", - "TTTTTDGGGGGD", - "YYYYYDGKGKGD", - "RRRRRDGGPGGD", - "......DGGGD.", - ".......DDD..", - "............", - ], - }, - }, ]; diff --git a/apps/site/src/app/arcade/page.tsx b/apps/site/src/app/arcade/page.tsx index 954b3acd86..b4221e0c1d 100644 --- a/apps/site/src/app/arcade/page.tsx +++ b/apps/site/src/app/arcade/page.tsx @@ -1,7 +1,9 @@ import { createPageMetadata } from "@/lib/page-metadata"; -import { Press_Start_2P, VT323 } from "next/font/google"; -import { ArcadeScreen } from "./_components/arcade-screen"; +import { Press_Start_2P } from "next/font/google"; +import { ArcadeExperience } from "./_components/arcade-experience"; +// Pixel display face for the game HUDs and screens only — page chrome uses the +// standard site typography. const pressStart = Press_Start_2P({ weight: "400", subsets: ["latin"], @@ -9,24 +11,15 @@ const pressStart = Press_Start_2P({ display: "swap", }); -const vt323 = VT323({ - weight: "400", - subsets: ["latin"], - variable: "--font-arcade-alt", - display: "swap", -}); - export const metadata = createPageMetadata({ - title: "Prisma Arcade | Insert Coin to Play", + title: "Prisma Arcade", description: - "Step into the Prisma Arcade — three retro games, global high scores, and zero quarters required. Schema Snake, Query Invaders, and Migration Breakout are coming soon.", + "Six tiny games built by the Prisma team. Fly Comet Cat, climb the high-score leaderboard, and warm up for the $500 Prisma-credits contest — no quarters required.", path: "/arcade", }); export default function ArcadePage() { - return ( -
- -
- ); + // The font class is passed down because the play dialog renders in a portal + // outside this subtree and still needs the --font-arcade variable. + return ; } diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 63b06e99b4..08df0c2833 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -49,11 +49,6 @@ export function NavigationWrapper({ links }: NavigationWrapperProps) { setMounted(true); }, []); - // /arcade is a full-screen takeover experience with no site chrome - if (pathname.startsWith("/arcade")) { - return null; - } - const currentUtmParams: UtmParams = mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {}; @@ -81,10 +76,6 @@ export function NavigationWrapper({ links }: NavigationWrapperProps) { export function FooterWrapper() { const pathname = usePathname(); - if (pathname.startsWith("/arcade")) { - return null; - } - // Determine button variant based on pathname const getButtonVariant = (): ColorType => { if (orm.includes(pathname.split("?")[0])) { From 8757bd2daa4647a61076b44894445949b198a627 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:17:39 +0200 Subject: [PATCH 4/5] feat(site): restore the retro CRT takeover styling for the arcade Bring back the original neon takeover look (starfield, grid floor, CRT scanlines, pixel type, ticker, no site chrome) on top of the polished structure: Comet Cat stays the featured game beside the hall-of-fame leaderboard with initials entry and the $500-Prisma-credits prize callout, and the other games remain in the cabinet grid opening into retro-styled dialogs. The game kit and gameplay fixes are unchanged. Co-Authored-By: Claude Fable 5 --- .../arcade/_components/arcade-experience.tsx | 173 ++--- .../app/arcade/_components/arcade.module.css | 702 ++++++++++++++++-- .../app/arcade/_components/leaderboard.tsx | 83 +-- apps/site/src/app/arcade/page.tsx | 23 +- .../src/components/navigation-wrapper.tsx | 9 + 5 files changed, 780 insertions(+), 210 deletions(-) diff --git a/apps/site/src/app/arcade/_components/arcade-experience.tsx b/apps/site/src/app/arcade/_components/arcade-experience.tsx index cf46730080..352f08790e 100644 --- a/apps/site/src/app/arcade/_components/arcade-experience.tsx +++ b/apps/site/src/app/arcade/_components/arcade-experience.tsx @@ -33,6 +33,7 @@ import styles from "./arcade.module.css"; const HI_SCORE_STORAGE_KEY = "prisma-arcade-hiscores"; /** The featured game's key in the hi-score record. */ const COMET_ID = "comet"; +const COMET_COLOR = "#7cdae1"; const GAME_COMPONENTS: Record> = { snake: SnakeGame, @@ -42,12 +43,18 @@ const GAME_COMPONENTS: Record> = { meteors: MeteorsGame, }; -const CARD_SURFACE = - "rounded-square-high border border-stroke-neutral bg-[linear-gradient(180deg,var(--color-background-default)_0%,var(--color-background-ppg)_262.5%)]"; +const TICKER_ITEMS = [ + "★ WELCOME TO THE PRISMA ARCADE ★", + "6 GAMES ★ FREE PLAY", + "TOP PILOT WINS $500 IN PRISMA CREDITS", + "NO QUARTERS REQUIRED", + "TYPE-SAFE SINCE 2016", + "WINNERS DON'T USE RAW SQL... USUALLY", +]; export function ArcadeExperience({ - /** next/font variable class providing --font-arcade; also applied to the - * play dialog, which portals outside this subtree. */ + /** next/font variable classes providing --font-arcade / --font-arcade-alt; + * also applied to the play dialog, which portals outside this subtree. */ fontClass, }: { fontClass: string; @@ -108,114 +115,108 @@ export function ArcadeExperience({ ); const ActiveGame = activeGame ? GAME_COMPONENTS[activeGame.id] : null; + const tickerText = [...TICKER_ITEMS, ...TICKER_ITEMS]; return ( -
- {/* ===== 1. HERO + FEATURED GAME + LEADERBOARD ===== */} -
-
-
-
-
- - - Prisma Arcade - -

Take a break. Set a record.

-

- Six tiny games built by the Prisma team. Fly Comet Cat, climb the leaderboard, and - keep an eye on the $500 Prisma-credits high-score contest. -

-
- -
-
-
-

Comet Cat

-

- Flap. Drift. Leave a trail. -

-
- +
+
+
+
+ +
+
+

PRISMA PRESENTS

+

+ PRISMA +
+ ARCADE +

+

FREE PLAY ★ NO QUARTERS REQUIRED

+

+ Fly Comet Cat, climb the leaderboard, and warm up for the high-score contest: $500 in + Prisma credits. +

+
+ +
+
+
+

COMET CAT

+

Flap. Drift. Leave a trail.

- - -
+ +
+ +
- - - {/* ===== 2. MORE GAMES ===== */} -
-
- - Free play -

- The back row -

-

- Five more machines, no quarters required. Personal bests live in your browser. -

-
- -
+
+

★ THE BACK ROW ★

+ +
{GAMES.map((game) => ( ))}
+
+ + + ◀ EXIT TO PRISMA.IO + +
+ +
+
+ {tickerText.map((item, i) => ( + {item} + ))}
-
- - {/* ===== 3. CLOSING ===== */} -
- -

- Shipped between deploys. When you're done playing,{" "} - - see what we build the rest of the time - - . -

-
-
+
+ +
+
!open && setActiveGame(null)}> - + {activeGame && ActiveGame && ( <> - {activeGame.title} - + + {activeGame.title.toUpperCase()} + + {activeGame.tagline} {activeGame.controls}. diff --git a/apps/site/src/app/arcade/_components/arcade.module.css b/apps/site/src/app/arcade/_components/arcade.module.css index 0729418991..545112fdf1 100644 --- a/apps/site/src/app/arcade/_components/arcade.module.css +++ b/apps/site/src/app/arcade/_components/arcade.module.css @@ -1,18 +1,607 @@ /* ========================================================================== - PRISMA ARCADE - Page chrome (hero, cards, leaderboard) is Tailwind + @prisma/eclipse - tokens in the components. This module holds only what utilities can't - express: the game-screen treatment and its keyframes. - - Everything INSIDE .gameScreen / .cardScreen is a lit CRT: it stays dark in - both site themes on purpose, so those colors are fixed. Everything outside - the screen uses semantic tokens and themes normally. + PRISMA ARCADE — deliberately off-brand. CRT glow, scanlines, pixel type. + The page is a full-screen takeover: always dark, no site chrome, and every + color is fixed rather than themed. Structure lives in the components; this + module owns the whole retro treatment. ========================================================================== */ -.gameWrap { +.arcade { + --arcade-bg: #08010f; + --arcade-magenta: #f472b6; + --arcade-cyan: #22d3ee; + --arcade-yellow: #facc15; + --arcade-text: #e2e8f0; + position: relative; + min-height: 100svh; + overflow: hidden; + background: + radial-gradient(ellipse 120% 80% at 50% -20%, #2b0a4e 0%, transparent 60%), var(--arcade-bg); + color: var(--arcade-text); + font-family: var(--font-arcade), "Courier New", monospace; + image-rendering: pixelated; + cursor: crosshair; +} + +.arcade *::selection { + background: var(--arcade-magenta); + color: #08010f; +} + +/* --- background layers ------------------------------------------------- */ + +.stars, +.starsFar { + position: absolute; + inset: 0; + pointer-events: none; +} + +.stars { + background-image: + radial-gradient(1px 1px at 20% 30%, #fff 100%, transparent), + radial-gradient(2px 2px at 60% 70%, var(--arcade-cyan) 100%, transparent), + radial-gradient(1px 1px at 50% 50%, #fff 100%, transparent), + radial-gradient(2px 2px at 80% 10%, var(--arcade-magenta) 100%, transparent), + radial-gradient(1px 1px at 90% 60%, #fff 100%, transparent), + radial-gradient(1px 1px at 33% 80%, #fff 100%, transparent), + radial-gradient(2px 2px at 15% 65%, #fff 100%, transparent); + background-size: 550px 550px; + animation: twinkle 4s steps(2) infinite; +} + +.starsFar { + background-image: + radial-gradient(1px 1px at 10% 10%, #ffffffaa 100%, transparent), + radial-gradient(1px 1px at 40% 60%, #ffffff88 100%, transparent), + radial-gradient(1px 1px at 70% 40%, #ffffffaa 100%, transparent), + radial-gradient(1px 1px at 95% 85%, #ffffff88 100%, transparent); + background-size: 350px 350px; + animation: twinkle 3s steps(2) infinite reverse; +} + +@keyframes twinkle { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +.gridFloor { + position: absolute; + left: -25%; + right: -25%; + bottom: -2%; + height: 42%; + pointer-events: none; + background-image: + linear-gradient(to top, rgba(244, 114, 182, 0.45) 2px, transparent 2px), + linear-gradient(to right, rgba(34, 211, 238, 0.35) 2px, transparent 2px); + background-size: 64px 64px; + transform: perspective(320px) rotateX(62deg); + transform-origin: center top; + animation: floorScroll 1.4s linear infinite; + mask-image: linear-gradient(to bottom, transparent, black 30%); +} + +@keyframes floorScroll { + from { + background-position: + 0 0, + 0 0; + } + to { + background-position: + 0 64px, + 0 0; + } +} + +/* --- CRT overlay -------------------------------------------------------- */ + +.crt { + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 2px, + rgba(0, 0, 0, 0.22) 3px, + rgba(0, 0, 0, 0.22) 4px + ); + animation: flicker 0.12s steps(2) infinite; +} + +.vignette { + position: fixed; + inset: 0; + z-index: 51; + pointer-events: none; + background: radial-gradient( + ellipse 90% 90% at 50% 50%, + transparent 55%, + rgba(0, 0, 0, 0.55) 100% + ); +} + +@keyframes flicker { + 0%, + 100% { + opacity: 0.9; + } + 50% { + opacity: 1; + } +} + +/* --- header -------------------------------------------------------------- */ + +.content { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + gap: 3rem; + padding: 4rem 1.5rem 6rem; + max-width: 76rem; + margin: 0 auto; +} + +.pretitle { + font-size: 0.75rem; + letter-spacing: 0.35em; + color: var(--arcade-cyan); + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.title { + font-size: clamp(1.75rem, 6vw, 4rem); + text-align: center; + line-height: 1.2; + color: #fff; + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 24px rgba(244, 114, 182, 0.8), + 0 0 64px rgba(34, 211, 238, 0.5); + animation: titlePulse 2.4s ease-in-out infinite; +} + +@keyframes titlePulse { + 0%, + 100% { + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 24px rgba(244, 114, 182, 0.8), + 0 0 64px rgba(34, 211, 238, 0.5); + } + 50% { + text-shadow: + 3px 3px 0 var(--arcade-magenta), + -3px -3px 0 var(--arcade-cyan), + 0 0 40px rgba(244, 114, 182, 1), + 0 0 96px rgba(34, 211, 238, 0.8); + } +} + +.blink { + animation: blink 1.1s steps(2, start) infinite; +} + +@keyframes blink { + to { + visibility: hidden; + } +} + +.freePlay { + font-size: clamp(0.7rem, 2vw, 1rem); + letter-spacing: 0.2em; + color: var(--arcade-yellow); + text-shadow: 0 0 12px var(--arcade-yellow); +} + +.heroCopy { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.25rem; + line-height: 1.4; + text-align: center; + max-width: 34rem; + color: #94a3b8; +} + +/* --- featured game + leaderboard ------------------------------------------ */ + +.featuredGrid { + display: grid; + gap: 2rem; + width: 100%; + align-items: stretch; +} + +@media (min-width: 64rem) { + .featuredGrid { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + } +} + +.featuredPanel { display: flex; flex-direction: column; + gap: 1rem; + padding: 1.5rem 1.25rem; + border: 3px solid var(--game-color, var(--arcade-cyan)); + background: rgba(10, 1, 24, 0.85); + box-shadow: 0 0 24px rgba(34, 211, 238, 0.25); +} + +.panelHead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.panelTitle { + font-size: 1rem; + letter-spacing: 0.1em; + color: var(--game-color, var(--arcade-cyan)); + text-shadow: 0 0 12px var(--game-color, var(--arcade-cyan)); +} + +.panelTagline { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.1rem; + color: #94a3b8; +} + +/* --- hall of fame (leaderboard) -------------------------------------------- */ + +.hallOfFame { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; + border: 3px solid var(--arcade-magenta); + padding: 1.5rem 1.25rem; + background: rgba(20, 5, 37, 0.8); + box-shadow: 0 0 24px rgba(244, 114, 182, 0.3); +} + +.hallHead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.hallTitle { + font-size: 0.85rem; + letter-spacing: 0.25em; + color: var(--arcade-magenta); + text-shadow: 0 0 12px var(--arcade-magenta); +} + +.hallGame { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: #64748b; +} + +.prizeBanner { + display: flex; + gap: 0.75rem; + align-items: baseline; + border: 2px dashed var(--arcade-yellow); + padding: 0.85rem 1rem; + font-size: 0.6rem; + letter-spacing: 0.12em; + line-height: 1.8; + color: var(--arcade-yellow); + text-shadow: 0 0 8px rgba(250, 204, 21, 0.6); +} + +.claimForm { + display: flex; + flex-wrap: wrap; + align-items: center; gap: 0.75rem; + border: 2px solid var(--arcade-cyan); + padding: 0.85rem 1rem; + background: rgba(34, 211, 238, 0.08); +} + +.claimLabel { + flex: 1; + min-width: 12rem; + font-size: 0.6rem; + letter-spacing: 0.12em; + line-height: 1.8; + color: var(--arcade-cyan); +} + +.claimScore { + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.initialsInput { + width: 5rem; + padding: 0.6rem 0.4rem; + font-family: inherit; + font-size: 0.8rem; + letter-spacing: 0.35em; + text-align: center; + text-transform: uppercase; + color: #fff; + background: #08010f; + border: 2px solid var(--arcade-cyan); + outline: none; +} + +.initialsInput:focus-visible { + border-color: var(--arcade-yellow); + box-shadow: 0 0 12px rgba(250, 204, 21, 0.5); +} + +.claimBtn { + font-family: inherit; + font-size: 0.65rem; + letter-spacing: 0.2em; + color: #08010f; + background: var(--arcade-yellow); + border: none; + padding: 0.7rem 1.1rem; + cursor: pointer; + box-shadow: 0 0 16px rgba(250, 204, 21, 0.5); + transition: transform 0.05s steps(1); +} + +.claimBtn:active { + transform: translateY(2px); +} + +.hallRows { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.hallRow { + display: grid; + grid-template-columns: 2.5rem 1fr auto; + gap: 0.75rem; + align-items: baseline; + font-size: 0.65rem; + letter-spacing: 0.12em; + padding: 0.5rem 0; + color: #94a3b8; +} + +.hallRow.empty { + color: #475569; +} + +.hallRow.top { + color: var(--arcade-yellow); + text-shadow: 0 0 8px var(--arcade-yellow); +} + +.hallRow.claimed { + color: var(--arcade-cyan); + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.hallInitials { + letter-spacing: 0.35em; +} + +.hallNote { + margin-top: auto; + font-family: var(--font-arcade-alt), monospace; + font-size: 1rem; + line-height: 1.35; + color: #64748b; +} + +/* --- cabinets (secondary games) --------------------------------------------- */ + +.sectionTitle { + font-size: 0.85rem; + letter-spacing: 0.25em; + text-align: center; + color: var(--arcade-cyan); + text-shadow: 0 0 12px var(--arcade-cyan); +} + +.cabinets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: 2rem; + width: 100%; +} + +.cabinet { + --game-color: #fff; + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem 1.25rem 1.75rem; + background: linear-gradient(180deg, #140525 0%, #0b0217 100%); + border: 3px solid var(--game-color); + clip-path: polygon( + 0 12px, + 12px 12px, + 12px 0, + calc(100% - 12px) 0, + calc(100% - 12px) 12px, + 100% 12px, + 100% calc(100% - 12px), + calc(100% - 12px) calc(100% - 12px), + calc(100% - 12px) 100%, + 12px 100%, + 12px calc(100% - 12px), + 0 calc(100% - 12px) + ); + cursor: pointer; + text-align: center; + font-family: inherit; + color: inherit; + transition: transform 0.1s steps(2); +} + +.cabinet:hover, +.cabinet:focus-visible { + transform: translateY(-6px); + filter: drop-shadow(0 0 18px var(--game-color)); + outline: none; +} + +.cabinetMarquee { + font-size: 0.8rem; + line-height: 1.5; + color: var(--game-color); + text-shadow: 0 0 12px var(--game-color); + letter-spacing: 0.08em; +} + +.cabinetScreen { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 4 / 3; + background: + repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.35) 2px 4px), + radial-gradient(ellipse at 50% 40%, #1e0b38 0%, #05010a 80%); + border: 3px solid #2c1b45; + overflow: hidden; +} + +.cabinetScreen svg { + width: 55%; + height: auto; + filter: drop-shadow(0 0 10px var(--game-color)); + animation: spriteBob 1.2s steps(2) infinite; +} + +@keyframes spriteBob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +.tagline { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.15rem; + line-height: 1.3; + color: #94a3b8; +} + +.hiScore { + font-size: 0.6rem; + letter-spacing: 0.2em; + color: var(--arcade-cyan); +} + +.hiScoreValue { + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.startHint { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: var(--game-color); +} + +/* --- ticker ---------------------------------------------------------------- */ + +.ticker { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + overflow: hidden; + border-top: 3px solid var(--arcade-cyan); + background: rgba(5, 1, 10, 0.92); + padding: 0.65rem 0; +} + +.tickerTrack { + display: flex; + width: max-content; + gap: 3rem; + white-space: nowrap; + font-size: 0.65rem; + letter-spacing: 0.25em; + color: var(--arcade-cyan); + animation: tickerScroll 22s linear infinite; +} + +@keyframes tickerScroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +/* --- play dialog ------------------------------------------------------------ */ + +/* Applied on top of the design-system dialog: same behavior (portal, focus + trap, Esc), retro shell. */ +.retroDialog { + border: 4px solid var(--game-color, #fff); + border-radius: 0; + background: + repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.3) 2px 4px), #0a0118; + box-shadow: 0 0 40px var(--game-color, #fff); + color: var(--arcade-text); + font-family: var(--font-arcade), "Courier New", monospace; + image-rendering: pixelated; +} + +.dialogTitle { + font-family: var(--font-arcade), "Courier New", monospace; + font-size: 1rem; + font-weight: 400; + letter-spacing: 0.1em; + text-align: center; + color: var(--game-color, #fff); + text-shadow: 0 0 16px var(--game-color, #fff); +} + +.dialogTagline { + font-family: var(--font-arcade-alt), monospace; + font-size: 1.1rem; + text-align: center; + color: #94a3b8; +} + +/* --- playable game ----------------------------------------------------------- */ + +.gameWrap { + display: flex; + flex-direction: column; + gap: 1rem; width: 100%; font-family: var(--font-arcade), "Courier New", monospace; } @@ -23,25 +612,25 @@ gap: 1rem; font-size: 0.65rem; letter-spacing: 0.15em; - color: var(--color-foreground-neutral-weak); + color: var(--arcade-cyan, #22d3ee); } .gameHud b { font-weight: 400; - color: var(--color-foreground-neutral); + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan, #22d3ee); } .gameLives { - color: var(--color-foreground-success); + color: #4ade80; letter-spacing: 0.3em; + text-shadow: 0 0 8px #4ade80; } .gameScreen { position: relative; - border: 1px solid var(--color-stroke-neutral); - border-radius: var(--radius-square); + border: 3px solid var(--game-color, #4ade80); overflow: hidden; - background: #060210; } .gameCanvas { @@ -75,19 +664,21 @@ line-height: 1.6; text-align: center; color: #fff; + text-shadow: 0 0 14px var(--game-color, #4ade80); padding: 1rem; } .gameMsgSub { - font-family: var(--font-mono, monospace); - font-size: 0.85rem; - letter-spacing: 0.02em; - text-transform: none; + font-family: var(--font-arcade-alt), monospace; + font-size: 1.1rem; + letter-spacing: 0.05em; color: #94a3b8; + text-shadow: none; } .newBest { - color: #facc15; + color: var(--arcade-yellow, #facc15); + text-shadow: 0 0 14px var(--arcade-yellow, #facc15); animation: blink 0.6s steps(2, start) infinite; } @@ -96,7 +687,7 @@ font-size: 0.55rem; letter-spacing: 0.2em; text-align: center; - color: var(--color-foreground-neutral-weaker); + color: #64748b; } .waveBanner { @@ -107,67 +698,38 @@ align-items: center; justify-content: center; pointer-events: none; - font-family: var(--font-arcade), monospace; font-size: 1.1rem; letter-spacing: 0.3em; - color: #7cdae1; - text-shadow: 0 0 18px #7cdae1; + color: var(--game-color, #22d3ee); + text-shadow: 0 0 18px var(--game-color, #22d3ee); animation: blink 0.7s steps(2, start) infinite; } -.blink { - animation: blink 1.1s steps(2, start) infinite; -} - -@keyframes blink { - to { - visibility: hidden; - } -} - /* Focusable hero game: the whole screen is the keyboard target. */ .focusScreen { - border-radius: var(--radius-square); outline: none; } .focusScreen:focus-visible { - outline: 2px solid var(--color-stroke-ppg); + outline: 3px solid var(--arcade-yellow, #facc15); outline-offset: 3px; } -/* --- secondary game cards -------------------------------------------------- */ +/* --- misc -------------------------------------------------------------------- */ -/* Mini "attract mode" screen on the game cards; dark in both themes. */ -.cardScreen { - position: relative; - display: flex; - align-items: center; - justify-content: center; - aspect-ratio: 16 / 9; - border-radius: var(--radius-square); - border: 1px solid var(--color-stroke-neutral); - overflow: hidden; - background: - repeating-linear-gradient(to bottom, transparent 0 2px, rgba(0, 0, 0, 0.35) 2px 4px), - radial-gradient(ellipse at 50% 40%, #17103a 0%, #060210 80%); -} - -.cardScreen svg { - width: 34%; - height: auto; - filter: drop-shadow(0 0 10px var(--game-color, #7cdae1)); - animation: spriteBob 1.2s steps(2) infinite; +.exitLink { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: #64748b; + text-decoration: none; + border-bottom: 2px dotted #64748b; + padding-bottom: 0.2rem; } -@keyframes spriteBob { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-4px); - } +.exitLink:hover { + color: var(--arcade-cyan); + border-color: var(--arcade-cyan); + text-shadow: 0 0 8px var(--arcade-cyan); } /* --- scroll reveal ---------------------------------------------------------- */ @@ -186,10 +748,12 @@ } @media (prefers-reduced-motion: reduce) { - .blink, - .newBest, - .waveBanner, - .cardScreen svg { + .arcade *, + .crt, + .stars, + .starsFar, + .gridFloor, + .tickerTrack { animation: none !important; } diff --git a/apps/site/src/app/arcade/_components/leaderboard.tsx b/apps/site/src/app/arcade/_components/leaderboard.tsx index a5b1540f2f..c361fde290 100644 --- a/apps/site/src/app/arcade/_components/leaderboard.tsx +++ b/apps/site/src/app/arcade/_components/leaderboard.tsx @@ -1,21 +1,23 @@ "use client"; /** - * The Comet Cat leaderboard. Scores are stored in localStorage for now — the + * The Comet Cat hall of fame. Scores are stored in localStorage for now — the * site has no database, and the global leaderboard is planned to land together * with the prize contest. The panel is written so only the storage helpers * need to change when a backend arrives: the UI already deals in ranked * {initials, score} entries. */ -import { Button } from "@prisma/eclipse"; import { useCallback, useState, type FormEvent } from "react"; import { formatScore } from "./game-kit"; +import styles from "./arcade.module.css"; const LEADERBOARD_KEY = "prisma-arcade-comet-leaderboard"; const INITIALS_KEY = "prisma-arcade-initials"; export const MAX_ENTRIES = 10; +const ORDINALS = ["1ST", "2ND", "3RD", "4TH", "5TH", "6TH", "7TH", "8TH", "9TH", "10TH"]; + export type LeaderboardEntry = { initials: string; score: number; @@ -112,33 +114,24 @@ export function Leaderboard({ const rows = Array.from({ length: MAX_ENTRIES }, (_, i) => entries[i] ?? null); return ( -