Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 27 additions & 84 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/app/components/HUD.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ type Props = {
best: number;
bump: boolean;
alive: boolean;
paused?: boolean;
onRestart: () => void;
};

Expand Down
43 changes: 43 additions & 0 deletions src/app/components/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { LeaderboardEntry } from '@/hooks/useLeaderboard';
import { DIFFICULTY_PRESETS } from '@/constants/game';
import type { GameDifficulty } from '@/constants/game';

type Props = {
entries: LeaderboardEntry[];
};

function formatDate(ts: number) {
const d = new Date(ts);
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}

export default function Leaderboard({ entries }: Props) {
if (entries.length === 0) return null;

return (
<div className='w-full max-w-xs'>
<h3 className='mb-2 text-center font-mono text-xs font-medium uppercase tracking-wider text-zinc-500'>
Top scores
</h3>
<ul className='space-y-1 rounded-lg border border-zinc-700 bg-zinc-900/50 px-3 py-2'>
{entries.slice(0, 5).map((e, i) => (
<li
key={`${e.date}-${e.score}-${i}`}
className='flex items-center justify-between font-mono text-sm text-zinc-300'
>
<span className='text-zinc-500'>#{i + 1}</span>
<span className='text-emerald-400'>{e.score}</span>
<span className='text-xs text-zinc-500'>
{DIFFICULTY_PRESETS[e.difficulty as GameDifficulty]?.label ??
e.difficulty}
</span>
<span className='text-xs text-zinc-600'>{formatDate(e.date)}</span>
</li>
))}
</ul>
</div>
);
}
193 changes: 130 additions & 63 deletions src/app/components/SnakeCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
// src/app/components/SnakeCanvas.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game';
import type { XY, Dir } from '@/types';
import { randomFreeCell, inferDirFromSnake } from '@/utils/logic';
import { inferDirFromSnake } from '@/utils/logic';
import { drawFrame } from '@/utils/canvas';
import { useTicker } from '@/hooks/useTicker';
import { useSnakeGame } from '@/hooks/useSnakeGame';
import { useInput } from '@/hooks/useInput';
import { useCanvas2D } from '@/hooks/useCanvas2D';
import { useBestScore } from '@/hooks/useBestScore';
import { usePauseHotkey } from '@/hooks/usePauseHotkey';
import { useSwipe } from '@/hooks/useSwipe';
import { useLeaderboard } from '@/hooks/useLeaderboard';
import HUD from '@/components/HUD';
import Leaderboard from '@/components/Leaderboard';
import { isOpposite } from '@/utils/logic';

type Phase = 'menu' | 'playing' | 'gameover';

const DIFFICULTIES: GameDifficulty[] = [
'relaxed',
'classic',
'expert',
'blitz',
'endless',
];

function computeDelayMs(
T: (typeof DIFFICULTY_PRESETS)[GameDifficulty],
score: number
): number {
const linear = T.TICK_START_MS - score * T.TICK_STEP_MS;
const curve = T.speedCurve;
let delay = linear;
if (curve === 'ease-in') {
delay = T.TICK_START_MS - score * score * 0.08;
} else if (curve === 'ease-out') {
delay = T.TICK_START_MS - score * T.TICK_STEP_MS * 0.85;
}
return Math.max(T.TICK_MIN_MS, delay);
}

export default function SnakeCanvas() {
const { canvasRef, ctxRef } = useCanvas2D();

const [bump, setBump] = useState(false);
const [paused, setPaused] = useState(false);
const [phase, setPhase] = useState<Phase>('menu');
const [difficulty, setDifficulty] = useState<GameDifficulty>('medium');
const [difficulty, setDifficulty] = useState<GameDifficulty>('classic');

const T = DIFFICULTY_PRESETS[difficulty];
const gameConfig = useMemo(
() => ({
cols: T.COLS,
rows: T.ROWS,
wrap: T.wrap,
obstacleCount: T.obstacleCount,
powerChance: T.powerChance,
}),
[T]
);

// 🔊 preload sounds
const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []);
const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []);
const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []);
Expand All @@ -39,87 +74,93 @@ export default function SnakeCanvas() {
try {
a.currentTime = 0;
void a.play();
} catch {
} catch (err) {
console.error('Audio play exception:', err);
}
}, []);

// random free cell based on current tuning
const pickCell = useCallback(
(snake: XY[]) => randomFreeCell(snake, T.COLS, T.ROWS),
[T]
);

const { alive, score, snakeRef, foodRef, reset, turn, tick } = useSnakeGame(
pickCell,
{
const { alive, score, snakeRef, foodRef, obstaclesRef, reset, turn, tick } =
useSnakeGame(gameConfig, {
onEat: () => play(eatSnd),
onDie: () => play(dieSnd),
isOutOfBounds: (p) =>
p.x < 0 || p.x >= T.COLS || p.y < 0 || p.y >= T.ROWS,
}
);
});

// phase: playing → gameover when you die
useEffect(() => {
if (!alive && phase === 'playing') setPhase('gameover');
}, [alive, phase]);

// derive current dir for opposite-turn guard
const getCurrentDir = useCallback<() => Dir>(
() => inferDirFromSnake(snakeRef.current),
[snakeRef]
);

// drawing (thread tuning into canvas utils)
const draw = useCallback(() => {
const ctx = ctxRef.current;
if (!ctx) return;
drawFrame(ctx, alive, foodRef.current, snakeRef.current, T);
}, [alive, ctxRef, foodRef, snakeRef, T]);
drawFrame(
ctx,
alive,
foodRef.current,
snakeRef.current,
obstaclesRef.current,
T
);
}, [alive, ctxRef, foodRef, snakeRef, obstaclesRef, T]);

// score bump anim
useEffect(() => {
setBump(true);
const id = setTimeout(() => setBump(false), 200);
return () => clearTimeout(id);
}, [score]);

// best score persistence
const best = useBestScore(score);
const { entries, submitScore } = useLeaderboard();
const submittedRef = useRef(false);

useEffect(() => {
if (!alive && phase === 'gameover' && score > 0 && !submittedRef.current) {
submittedRef.current = true;
submitScore(score, difficulty);
}
}, [alive, phase, score, difficulty, submitScore]);

useEffect(() => {
if (phase === 'playing') submittedRef.current = false;
}, [phase]);

useEffect(() => {
if (phase === 'menu') {
reset();
draw();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [difficulty]);

// first paint (menu): draw an empty frame for current tuning
useEffect(() => {
draw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// dynamic speed: faster as you eat
const delayMs = Math.max(
T.TICK_MIN_MS,
T.TICK_START_MS - score * T.TICK_STEP_MS
);
const delayMs = computeDelayMs(T, score);

useTicker(
delayMs,
() => {
if (phase === 'playing' && alive && !paused) {
if (alive && !paused) {
tick();
draw();
}
},
true
phase === 'playing'
);

// start/restart helper
const startGame = useCallback(() => {
setPaused(false);
reset();
draw();
setPhase('playing');
}, [reset, draw]);

// Space to start when not playing
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (phase !== 'playing' && e.code === 'Space') {
Expand All @@ -131,7 +172,6 @@ export default function SnakeCanvas() {
return () => window.removeEventListener('keydown', onKey);
}, [phase, startGame]);

// keyboard for turns + HUD restart
useInput({
alive,
getCurrentDir,
Expand All @@ -140,37 +180,60 @@ export default function SnakeCanvas() {
onMoveKey: () => play(keySnd),
});

const handleSwipe = useCallback(
(d: Dir) => {
const cur = getCurrentDir();
if (!isOpposite(cur, d)) {
turn(d);
play(keySnd);
}
},
[getCurrentDir, turn, keySnd, play]
);

useSwipe({
enabled: phase === 'playing' && alive && !paused,
onSwipe: handleSwipe,
});

usePauseHotkey(alive && phase === 'playing', () => setPaused((p) => !p));

return (
<div className='flex min-h-screen w-full flex-col items-center justify-center gap-3 bg-black'>
{/* Toolbar - only visible in menu or game over */}
<div className='flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-zinc-950 p-4'>
{phase !== 'playing' && (
<div className='flex items-center gap-3'>
<select
className='rounded bg-zinc-800 px-2 py-1 text-sm'
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as GameDifficulty)}
>
{(['easy', 'medium', 'hard'] as const).map((k) => (
<option key={k} value={k}>
{k} — {DIFFICULTY_PRESETS[k].COLS}×{DIFFICULTY_PRESETS[k].ROWS}
</option>
))}
</select>

<button
className='rounded bg-emerald-600 px-3 py-1 text-sm'
onClick={startGame}
>
{phase === 'menu' ? 'Start' : 'Restart'}
</button>
<div className='flex flex-col items-center gap-4'>
<h1 className='font-mono text-2xl font-bold text-emerald-400'>
Snake
</h1>
<div className='flex flex-wrap items-center justify-center gap-3'>
<select
className='rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-white'
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as GameDifficulty)}
>
{DIFFICULTIES.map((k) => (
<option key={k} value={k}>
{DIFFICULTY_PRESETS[k].label} — {DIFFICULTY_PRESETS[k].COLS}×
{DIFFICULTY_PRESETS[k].ROWS}
</option>
))}
</select>
<button
className='rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500'
onClick={startGame}
>
{phase === 'menu' ? 'Start' : 'Restart'}
</button>
</div>
<p className='max-w-sm text-center text-xs text-zinc-500'>
{T.description}
</p>
<Leaderboard entries={entries} />
</div>
)}

{/* Game canvas */}
<div
className='relative inline-block align-top'
className='relative inline-block overflow-hidden rounded-xl shadow-2xl ring-2 ring-zinc-700'
style={{
width: T.COLS * T.CELL,
height: T.ROWS * T.CELL,
Expand All @@ -183,9 +246,13 @@ export default function SnakeCanvas() {
height={T.ROWS * T.CELL}
className='block'
/>
{paused && phase === 'playing' && (
<div className='absolute inset-0 flex items-center justify-center bg-black/60'>
<span className='font-mono text-xl text-white'>Paused (P)</span>
</div>
)}
</div>

{/* HUD + stats */}
<HUD
score={score}
best={best}
Expand All @@ -194,8 +261,8 @@ export default function SnakeCanvas() {
onRestart={startGame}
/>

<div className='text-center font-mono text-xs opacity-60'>
{(1000 / delayMs).toFixed(1)} moves/s
<div className='text-center font-mono text-xs text-zinc-500'>
{(1000 / delayMs).toFixed(1)} moves/s · Arrows / WASD / Swipe · P pause
</div>
</div>
);
Expand Down
Loading