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-experience.tsx b/apps/site/src/app/arcade/_components/arcade-experience.tsx new file mode 100644 index 0000000000..86199bedd8 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade-experience.tsx @@ -0,0 +1,240 @@ +"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 { + fetchLeaderboard, + Leaderboard, + qualifies, + submitScore, + 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 COMET_COLOR = "#7cdae1"; + +const GAME_COMPONENTS: Record> = { + snake: SnakeGame, + invaders: InvadersGame, + stacker: StackerGame, + muncher: MuncherGame, + meteors: MeteorsGame, +}; + +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 classes providing --font-arcade / --font-arcade-alt; + * 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. + } + void fetchLeaderboard().then(setEntries); + }, []); + + 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(); + void submitScore({ initials, score: pendingScore, at }).then(setEntries); + setPendingScore(null); + setLastClaimedAt(at); + }, + [pendingScore], + ); + + const ActiveGame = activeGame ? GAME_COMPONENTS[activeGame.id] : null; + const tickerText = [...TICKER_ITEMS, ...TICKER_ITEMS]; + + return ( +
+
+
+
+ +
+
+

+ PRISMA +
+ ARCADE +

+

+ Free play, no quarters required. Fly Comet Cat and chase the high score. +

+
+ +
+
+
+

COMET CAT

+

Flap. Drift. Leave a trail.

+
+ +
+ + +
+ + { + event.preventDefault(); + document.getElementById("back-row")?.scrollIntoView({ behavior: "smooth" }); + }} + > + ▼ 5 MORE GAMES IN THE BACK ROW + + +
+

★ THE BACK ROW ★

+ +
+ {GAMES.map((game) => ( + + ))} +
+
+
+ + + ◀ EXIT TO PRISMA.IO + +
+ +
+
+ {tickerText.map((item, i) => ( + {item} + ))} +
+
+ +
+
+ + !open && setActiveGame(null)}> + + {activeGame && ActiveGame && ( + <> + + + {activeGame.title.toUpperCase()} + + + {activeGame.tagline} {activeGame.controls}. + + + reportScore(activeGame.id, score)} + /> + + )} + + +
+ ); +} 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..0261c0f643 --- /dev/null +++ b/apps/site/src/app/arcade/_components/arcade.module.css @@ -0,0 +1,819 @@ +/* ========================================================================== + 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. + ========================================================================== */ + +.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 8s 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 6s 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.25) 2px, transparent 2px), + linear-gradient(to right, rgba(34, 211, 238, 0.18) 2px, transparent 2px); + background-size: 64px 64px; + transform: perspective(320px) rotateX(62deg); + transform-origin: center top; + animation: floorScroll 4.5s 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 + ); +} + +.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% + ); +} + +/* --- 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; +} + +.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); +} + +.blink { + animation: blink 1.1s steps(2, start) infinite; +} + +@keyframes blink { + to { + visibility: hidden; + } +} + +.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: start; +} + +@media (min-width: 64rem) { + .featuredGrid { + grid-template-columns: minmax(0, 1.4fr) 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) -------------------------------------------- */ + +/* The leaderboard sits beside the featured game but must not compete with + it: thinner border, no glow, quieter magenta. */ +.hallOfFame { + display: flex; + flex-direction: column; + gap: 1.1rem; + width: 100%; + border: 2px solid rgba(244, 114, 182, 0.55); + padding: 1.5rem 1.25rem; + background: rgba(20, 5, 37, 0.8); +} + +.hallHead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.hallTitle { + font-size: 0.8rem; + letter-spacing: 0.25em; + color: var(--arcade-magenta); +} + +.hallGame { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: #64748b; +} + +.prizeLine { + font-size: 0.6rem; + letter-spacing: 0.12em; + line-height: 1.8; + color: var(--arcade-yellow); +} + +.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; +} + +.expandBtn { + font-family: inherit; + font-size: 0.55rem; + letter-spacing: 0.2em; + color: var(--arcade-cyan); + background: transparent; + border: 2px dashed rgba(34, 211, 238, 0.4); + padding: 0.6rem; + cursor: pointer; +} + +.expandBtn:hover, +.expandBtn:focus-visible { + border-style: solid; + outline: none; + text-shadow: 0 0 8px var(--arcade-cyan); +} + +.globalSoon { + font-size: 0.55rem; + letter-spacing: 0.2em; + color: var(--arcade-cyan); +} + +.hallNote { + margin-top: auto; + font-family: var(--font-arcade-alt), monospace; + font-size: 1rem; + line-height: 1.35; + color: #64748b; +} + +/* --- cabinets (secondary games) --------------------------------------------- */ + +.moreCue { + font-size: 0.6rem; + letter-spacing: 0.25em; + color: #94a3b8; + text-decoration: none; + border-bottom: 2px dotted #64748b; + padding-bottom: 0.2rem; +} + +.moreCue:hover { + color: var(--arcade-yellow); + border-color: var(--arcade-yellow); + text-shadow: 0 0 8px var(--arcade-yellow); +} + +.backRow { + scroll-margin-top: 2rem; +} + +.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: rgba(34, 211, 238, 0.55); + animation: tickerScroll 45s 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; +} + +.gameHud { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.65rem; + letter-spacing: 0.15em; + color: var(--arcade-cyan, #22d3ee); +} + +.gameHud b { + font-weight: 400; + color: #fff; + text-shadow: 0 0 8px var(--arcade-cyan, #22d3ee); +} + +.gameLives { + color: #4ade80; + letter-spacing: 0.3em; + text-shadow: 0 0 8px #4ade80; +} + +.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, #facc15); + text-shadow: 0 0 14px var(--arcade-yellow, #facc15); + animation: blink 0.6s steps(2, start) infinite; +} + +.shellBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 1.5rem; +} + +.gameControls { + margin: 0; + font-size: 0.55rem; + letter-spacing: 0.2em; + text-align: center; + color: #64748b; +} + +.fsBtn { + font-family: inherit; + font-size: 0.55rem; + letter-spacing: 0.2em; + color: #64748b; + background: transparent; + border: none; + border-bottom: 2px dotted #64748b; + padding: 0 0 0.2rem; + cursor: pointer; +} + +.fsBtn:hover, +.fsBtn:focus-visible { + color: var(--arcade-cyan, #22d3ee); + border-color: var(--arcade-cyan, #22d3ee); + outline: none; +} + +/* Fullscreen: the shell fills the display, the screen scales to fit while the + canvas keeps its aspect ratio. */ +.gameWrap:fullscreen { + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1.25rem; + background: #08010f; + overflow: auto; +} + +.gameWrap:fullscreen .gameHud, +.gameWrap:fullscreen .shellBar { + width: 100%; + max-width: 52rem; +} + +.gameWrap:fullscreen .gameScreen { + width: fit-content; + max-width: 100%; + margin-inline: auto; +} + +.gameWrap:fullscreen .focusScreen { + height: 100%; +} + +.gameWrap:fullscreen .gameCanvas { + width: auto; + height: calc(100svh - 9rem); + max-width: calc(100vw - 4rem); + margin-inline: auto; +} + +.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; +} + +/* Focusable hero game: the whole screen is the keyboard target. */ +.focusScreen { + outline: none; +} + +.focusScreen:focus-visible { + outline: 3px solid var(--arcade-yellow, #facc15); + outline-offset: 3px; +} + +/* --- 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); +} + +/* --- 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 { + 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 new file mode 100644 index 0000000000..354b85e034 --- /dev/null +++ b/apps/site/src/app/arcade/_components/comet-cat-game.tsx @@ -0,0 +1,354 @@ +"use client"; + +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; +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 comet tail stripes, top to bottom. +const TAIL_COLORS = ["#7cdae1", "#edcd5f", "#e37780"]; +const TAIL_STRIPE_H = 7; + +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", +}; + +/** + * 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([]); + 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 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; + 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; + startRound(); + 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, startRound]); + + 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 = (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) { + endGame(); + } + 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; + 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; + 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 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 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(); + }, []); + + useEffect(() => { + reset(); + }, [reset]); + + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); + + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, draw]); + + 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(); + + if (key === " " || key === "arrowup" || key === "w" || key === "enter") { + event.preventDefault(); + if (event.repeat) return; + advance(); + return; + } + + if (key === "p") { + const currentPhase = phaseRef.current; + if (currentPhase === "playing" || currentPhase === "paused") { + changePhase(currentPhase === "playing" ? "paused" : "playing"); + } + } + }, + [advance, phaseRef, changePhase], + ); + + const onPointer = useCallback(() => { + screenRef.current?.focus(); + advance(); + }, [advance]); + + return ( + +
+ + +
+
+ ); +} 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..ccde695705 --- /dev/null +++ b/apps/site/src/app/arcade/_components/game-kit.tsx @@ -0,0 +1,248 @@ +"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 + fullscreen toggle 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; +}) { + const wrapRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + // Resolved after mount so the server render never guesses at support. + const [fullscreenSupported, setFullscreenSupported] = useState(false); + + useEffect(() => { + setFullscreenSupported(document.fullscreenEnabled ?? false); + const onChange = () => setIsFullscreen(document.fullscreenElement === wrapRef.current); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + const toggleFullscreen = useCallback(() => { + const el = wrapRef.current; + if (!el) return; + if (document.fullscreenElement) { + void document.exitFullscreen(); + } else { + el.requestFullscreen().catch(() => { + // Denied or unsupported — the inline view keeps working. + }); + } + }, []); + + return ( +
+
+ + SCORE {formatScore(score)} + + {hudExtra} + + HI {formatScore(hiScore)} + +
+
+ {children} +
+
+
+

{controls}

+ {fullscreenSupported && ( + + )} +
+
+ ); +} + +/** 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 new file mode 100644 index 0000000000..feac5cd32e --- /dev/null +++ b/apps/site/src/app/arcade/_components/invaders-game.tsx @@ -0,0 +1,769 @@ +"use client"; + +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; +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 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[][] }; + +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 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 }: GameProps) { + const canvasRef = useRef(null); + + const { phase, phaseRef, changePhase, score, addScore, startRound, endGame, isNewBest } = + useGameCore({ hiScore, onGameOver }); + + const playerX = useRef(W / 2); + const keys = useHeldKeys(); + 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 livesRef = useRef(3); + const waveRef = useRef(1); + + const [lives, setLives] = useState(3); + const [wave, setWave] = useState(1); + const [waveBanner, setWaveBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + 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(() => { + livesRef.current = 3; + waveRef.current = 1; + setLives(3); + setWave(1); + startRound(); + keys.current.clear(); + 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"); + }, [startRound, keys, resetGrid, changePhase]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + endGame(); + }, [endGame]); + + 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, ttl0: 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 = (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) { + 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[] = []; + 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) { + 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 { + 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, ttl0: 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); + } + }; + + 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 / e.ttl0; + 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, phaseRef]); + + useEffect(() => { + reset(); + }, [reset]); + + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); + + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, 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", "arrowdown", "a", "d", "w", "s", " "].includes(key) + ) { + event.preventDefault(); + keys.current.add(key); + if (currentPhase === "ready") start(); + else if (currentPhase === "over" && key === " ") 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); + }, [phaseRef, keys, 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); + }, + [phaseRef, start, reset, changePhase, tryFire, canvasX], + ); + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + touchTargetX.current = canvasX(event.touches[0].clientX); + }, + [canvasX], + ); + + const onTouchStop = useCallback(() => { + touchTargetX.current = null; + }, []); + + return ( + + + 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..cd29c75f74 --- /dev/null +++ b/apps/site/src/app/arcade/_components/leaderboard.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * The Comet Cat hall of fame. This is designed as a global leaderboard; until + * the backend lands, the two async storage functions below are backed by + * localStorage and are the exact seam where the API calls will plug in. The + * panel itself is compact (top three) and expands to the full top ten. + */ + +import { useCallback, useEffect, 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 COMPACT_ROWS = 3; + +const ORDINALS = ["1ST", "2ND", "3RD", "4TH", "5TH", "6TH", "7TH", "8TH", "9TH", "10TH"]; + +export type LeaderboardEntry = { + initials: string; + score: number; + /** Insertion timestamp — tiebreaker and row identity. */ + at: number; +}; + +function readEntries(): 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 []; + } +} + +/** Becomes a GET against the leaderboard API when the backend lands. */ +export async function fetchLeaderboard(): Promise { + return readEntries(); +} + +/** Becomes a POST against the leaderboard API when the backend lands. */ +export async function submitScore(entry: LeaderboardEntry): Promise { + const next = [...readEntries(), entry] + .sort((a, b) => b.score - a.score || a.at - b.at) + .slice(0, MAX_ENTRIES); + try { + localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(next)); + } catch { + // Storage unavailable — the board still works for this session. + } + return next; +} + +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 [expanded, setExpanded] = useState(false); + + // If a claim lands below the compact rows, expand so the player sees it. + const claimedIndex = entries.findIndex((entry) => entry.at === lastClaimedAt); + useEffect(() => { + if (claimedIndex >= COMPACT_ROWS) setExpanded(true); + }, [claimedIndex]); + + const submit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + const clean = sanitizeInitials(initials); + if (clean.length === 0) return; + saveInitials(clean); + onClaim(clean); + }, + [initials, onClaim], + ); + + const visibleRows = expanded ? MAX_ENTRIES : COMPACT_ROWS; + const rows = Array.from({ length: visibleRows }, (_, 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 new file mode 100644 index 0000000000..618947b292 --- /dev/null +++ b/apps/site/src/app/arcade/_components/meteors-game.tsx @@ -0,0 +1,1091 @@ +"use client"; + +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; +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 +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][] = [ + [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 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; +}; + +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 }: 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, + 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 = 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); + const thrustSound = useRef(0); + const warbleSound = useRef(0); + const beatTimer = useRef(0); + const beatHigh = useRef(false); + + const livesRef = useRef(3); + const waveRef = useRef(1); + + const [lives, setLives] = useState(3); + const [wave, setWave] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + 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(() => { + startRound(); + livesRef.current = 3; + waveRef.current = 1; + setLives(3); + setWave(1); + keys.current.clear(); + 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; + respawnWait.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, startRound, keys]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + endGame(); + }, [endGame]); + + const addScore = useCallback( + (points: number) => { + coreAddScore(points); + 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"); + } + }, + [coreAddScore, scoreRef, 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 }; + // 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, keys]); + + 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"); + }, [scoreRef]); + + 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 = (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 { + // 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); + } + + // --- 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) { + 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(); + } + } + } + + // --- 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 ------------------------------------------------------------ + + 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]); + + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); + + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, 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 === "over") { + if (key === " " && !event.repeat) reset(); + 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"); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phaseRef, keys, 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); + }, + [phaseRef, 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); + }, + [phaseRef, 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); + } + }, + [phaseRef, tryFire, applyZones], + ); + + return ( + + + 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 new file mode 100644 index 0000000000..81e9b2fabc --- /dev/null +++ b/apps/site/src/app/arcade/_components/muncher-game.tsx @@ -0,0 +1,901 @@ +"use client"; + +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; +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 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 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 }: 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([]); + 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 livesRef = useRef(3); + const levelRef = useRef(1); + + const [lives, setLives] = useState(3); + const [level, setLevel] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + 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(() => { + livesRef.current = 3; + levelRef.current = 1; + setLives(3); + setLevel(1); + startRound(); + buildDots(); + fruit.current = null; + fruitStage.current = 0; + freeze.current = 0; + pending.current = null; + anim.current = 0; + placeActors(); + changePhase("ready"); + }, [startRound, buildDots, placeActors, changePhase]); + + const gameOver = useCallback(() => { + beep(300, 40, 0.7, 0.09, "sawtooth"); + endGame(); + }, [endGame]); + + 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) { + 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"); + } + 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 ----------------------------------------------------------- + + 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) { + addScore(f.value); + 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, addScore], + ); + + 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") { + addScore(eatValue.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, 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; + } + 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(); + }; + + // --- 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]); + + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); + + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, 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); + }, [phaseRef, 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 }; + }, + [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 }; + }, + [phaseRef], + ); + + const onTouchEnd = useCallback(() => { + touchStart.current = null; + }, []); + + return ( + + + 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/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/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 new file mode 100644 index 0000000000..c0e39c3b41 --- /dev/null +++ b/apps/site/src/app/arcade/_components/snake-game.tsx @@ -0,0 +1,301 @@ +"use client"; + +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; +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 }; + +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 }, +}; + +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([]); + const dirRef = useRef({ x: 1, y: 0 }); + const queueRef = useRef([]); + const foodRef = useRef({ x: 0, y: 0 }); + const tickRef = useRef(START_TICK_MS); + const accRef = useRef(0); + const touchStart = useRef<{ x: number; y: number } | null>(null); + + /** Moves the food to a random free cell; false when the snake fills the board. */ + const placeFood = useCallback(() => { + const snake = snakeRef.current; + 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(() => { + 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; + accRef.current = 0; + startRound(); + placeFood(); + changePhase("ready"); + }, [placeFood, changePhase, startRound]); + + 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); + endGame(); + return; + } + + snake.unshift(head); + if (eating) { + addScore(POINTS_PER_APPLE); + tickRef.current = Math.max(MIN_TICK_MS, tickRef.current - SPEEDUP_MS); + beep(660, 990, 0.09); + if (!placeFood()) { + // The snake fills the whole board — nothing left to eat. + endGame(); + } + } else { + snake.pop(); + } + }, [addScore, endGame, placeFood]); + + const start = useCallback( + (dir?: Vec) => { + if (dir && !(dir.x === -dirRef.current.x && dir.y === -dirRef.current.y)) { + dirRef.current = dir; + } + beep(440, 880, 0.12); + changePhase("playing"); + }, + [changePhase], + ); + + useEffect(() => { + reset(); + }, [reset]); + + useGameLoop(phase === "playing", (dt) => { + accRef.current += dt; + while (accRef.current >= tickRef.current && phaseRef.current === "playing") { + accRef.current -= tickRef.current; + step(); + } + draw(); + }); + + useEffect(() => { + if (phase !== "playing") draw(); + }, [phase, 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); + }, [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]; + 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); + }, + [phaseRef, start, queueDirection, changePhase, reset], + ); + + return ( + + + + + ); +} 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..a6fc7c6399 --- /dev/null +++ b/apps/site/src/app/arcade/_components/stacker-game.tsx @@ -0,0 +1,633 @@ +"use client"; + +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; +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 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). +// Deliberately naive matrix rotation, NOT SRS: some pieces (notably S/Z/I) +// shift within their bounding box as they spin, and the KICKS table papers +// over the resulting wall collisions. It plays fine — don't "fix" it by +// swapping in SRS unless you're prepared to retune the feel. +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 emptyBoard(): Cell[][] { + return Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => null)); +} + +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 gravityAcc = useRef(0); + const clearing = useRef([]); + const freeze = useRef(0); + const touchState = useRef<{ x: number; y: number; t: number; moved: number } | null>(null); + + const linesRef = useRef(0); + const levelRef = useRef(1); + + const [lines, setLines] = useState(0); + const [level, setLevel] = useState(1); + const [banner, setBanner] = useState(null); + const bannerTimeout = useRef(undefined); + + 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; + keys.current.clear(); + beep(300, 40, 0.7, 0.09, "sawtooth"); + endGame(); + }, [keys, endGame]); + + 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 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; + // 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)) { + 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 = []; + linesRef.current = 0; + levelRef.current = 1; + setLines(0); + setLevel(1); + clearing.current = []; + freeze.current = 0; + gravityAcc.current = 0; + keys.current.clear(); + nextPiece.current = drawFromBag(); + active.current = null; + startRound(); + changePhase("ready"); + }, [drawFromBag, keys, startRound, changePhase]); + + const start = useCallback(() => { + beep(440, 880, 0.12); + spawn(); + changePhase("playing"); + }, [spawn, changePhase]); + + const tick = (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; + } + }; + + 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]); + + useGameLoop(phase === "playing", (dt) => { + tick(dt); + draw(); + }); + + useEffect(() => { + 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", + "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"); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phaseRef, keys, 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 }; + }, + [phaseRef, 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; + // 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); + 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++; + } + }, + [phaseRef, 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); + }, + [phaseRef, hardDrop, rotate], + ); + + return ( + + + 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 new file mode 100644 index 0000000000..ed7b68a7b9 --- /dev/null +++ b/apps/site/src/app/arcade/games.ts @@ -0,0 +1,153 @@ +/** + * The Prisma Arcade game registry for the secondary "free play" grid. + * Comet Cat is the featured game and lives directly in the page hero. + * + * 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 ArcadeGameId = "snake" | "invaders" | "stacker" | "muncher" | "meteors"; + +export type ArcadeGame = { + id: ArcadeGameId; + title: string; + tagline: string; + /** Accent color used for the sprite glow on the card screen. */ + color: string; + /** One-line control summary shown on the card and in the play dialog. */ + controls: string; + sprite: PixelGrid; +}; + +export const GAMES: ArcadeGame[] = [ + { + id: "snake", + title: "Snake", + tagline: "Eat the apples. Don't bite yourself.", + controls: "Arrow keys or swipe to steer", + color: "#4ade80", + 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.", + controls: "Arrows move, Space fires", + color: "#22d3ee", + 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.", + controls: "Arrows move, Up rotates, Space drops", + color: "#c084fc", + 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.", + controls: "Arrow keys or swipe to steer", + color: "#facc15", + 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.", + controls: "Arrows steer, Space fires, H hyperspace", + color: "#f8fafc", + 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..08279f2e5e --- /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 { ArcadeExperience } from "./_components/arcade-experience"; + +// The arcade is a deliberate retro takeover, so it brings its own display +// faces: Press Start 2P for pixel type, VT323 for terminal-style copy. +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: + "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() { + // The font classes are passed down because the play dialog renders in a + // portal outside this subtree and still needs the font variables. + return ; +} diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 08df0c2833..63b06e99b4 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -49,6 +49,11 @@ 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)) : {}; @@ -76,6 +81,10 @@ 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])) {