Compare commits
10
Commits
2fcfd26429
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ddff93ba | ||
|
|
2757d54dbb | ||
|
|
41473f3b24 | ||
|
|
fcfff91d99 | ||
|
|
cfd45504a9 | ||
|
|
55970cc54e | ||
|
|
3d53b81307 | ||
|
|
03b0e723b4 | ||
|
|
5636d47aec | ||
|
|
c8218a9f16 |
@@ -52,17 +52,17 @@ import { useGame } from "@vimazing/vim-snake";
|
||||
import "@vimazing/vim-snake/game.css";
|
||||
|
||||
export function SnakeGame() {
|
||||
const { containerRef, gameStatus, startGame } = useGame(30, 20);
|
||||
|
||||
useEffect(() => {
|
||||
startGame();
|
||||
}, [startGame]);
|
||||
const { containerRef, gameStatus, score, level } = useGame();
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-fit space-y-4">
|
||||
<h1 className="text-2xl font-bold">VIMazing Snake</h1>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<div>Score: {score}</div>
|
||||
<div>Level: {level}</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="relative" />
|
||||
{gameStatus === "game-won" && <p>🎉 You won!</p>}
|
||||
{gameStatus === "game-over" && <p>Game Over! Press Space to restart</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -70,30 +70,75 @@ export function SnakeGame() {
|
||||
|
||||
> **Note:** You must manually import the CSS file. The package exports styles but does not auto-import them, giving you control over when and how styles are loaded.
|
||||
|
||||
Default controls:
|
||||
### Default Controls
|
||||
|
||||
| Key | Action |
|
||||
| ------------------- | --------------- |
|
||||
| `i` | Start game |
|
||||
| `h` / `j` / `k` / `l` | Change direction |
|
||||
| `q` | Quit game |
|
||||
| `Esc` | Cancel / pause |
|
||||
| Key | Action |
|
||||
| ----------- | ------------------- |
|
||||
| `Space` | Start / Restart |
|
||||
| `h` / `l` | Turn left / right |
|
||||
| `j` / `k` | Turn down / up |
|
||||
| `q` | Quit game |
|
||||
| `p` | Pause / unpause |
|
||||
|
||||
### Game Options
|
||||
|
||||
Use `GameOptions` to customize the game:
|
||||
|
||||
```tsx
|
||||
const game = useGame({
|
||||
cols: 40, // Board width (default: 30)
|
||||
rows: 25, // Board height (default: 20)
|
||||
startingLevel: 5, // Initial level (default: 1)
|
||||
foodsPerLevel: 5, // Foods to level up (default: 10)
|
||||
maxLevel: 20, // Maximum level (default: 25)
|
||||
initialSnakeSize: 5, // Snake segments at start (default: 3)
|
||||
initialFoodCount: 2, // Food items at start (default: 1)
|
||||
});
|
||||
```
|
||||
|
||||
All options are optional with sensible defaults. Call `useGame()` with no arguments for default settings.
|
||||
|
||||
---
|
||||
|
||||
## Core Hooks
|
||||
## API
|
||||
|
||||
| Hook | Description |
|
||||
| ---- | ----------- |
|
||||
| `useGame(cols, rows, platformHook?)` | One-stop hook that wires board rendering, snake state, food spawning, scoring, and keyboard bindings. Returns refs, status helpers, and managers you can compose with your UI. |
|
||||
| `useScore()` | Tracks timers, level progression, and final score calculation. |
|
||||
### useGame Hook
|
||||
|
||||
Each hook is exported individually, so you can cherry-pick only what you need:
|
||||
Main hook that orchestrates all game mechanics:
|
||||
|
||||
```ts
|
||||
import { useScore } from "@vimazing/vim-snake/useScore";
|
||||
const gameManager = useGame(options?, platformHook?);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `options` (optional): `GameOptions` object for customization
|
||||
- `platformHook` (optional): Function to handle platform-specific logic
|
||||
|
||||
**Returns:** `GameManager` with:
|
||||
- `containerRef` – DOM ref for game board rendering
|
||||
- `gameStatus` – Current state ('waiting' | 'started' | 'game-over' | 'game-won')
|
||||
- `score` – Current game score
|
||||
- `level` – Current level (also equals game speed in FPS)
|
||||
- `scoreManager` – Tracks time, keystrokes, and final score
|
||||
- `cursor` – Snake manager with movement and state
|
||||
- `keyLog` – Array of all key presses during game
|
||||
- Control methods: `startGame()`, `quitGame()`, `togglePause()`, etc.
|
||||
|
||||
### gameInfo Export
|
||||
|
||||
Get complete game documentation for platforms:
|
||||
|
||||
```ts
|
||||
import { gameInfo } from "@vimazing/vim-snake";
|
||||
|
||||
console.log(gameInfo.name); // "VIMazing Snake"
|
||||
console.log(gameInfo.controls); // Control mappings
|
||||
console.log(gameInfo.scoring); // Scoring rules
|
||||
console.log(gameInfo.configuration); // All GameOptions
|
||||
```
|
||||
|
||||
Perfect for building help screens, settings, and platform integrations.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vimazing/vim-snake",
|
||||
"version": "0.4.0",
|
||||
"version": "3.1.0",
|
||||
"description": "Classic Snake reimagined with Vim controls — a lightweight, typed React hook library for building snake games with hjkl navigation.",
|
||||
"author": "André Padez <andre.padez@gmail.com>",
|
||||
"keywords": [
|
||||
@@ -48,7 +48,7 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/game.css', 'dist/game.css');\"",
|
||||
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/game.css', 'dist/game.css'); fs.copyFileSync('package.json', 'dist/package.json');\"",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+7
-146
@@ -1,149 +1,10 @@
|
||||
/* =========================================================
|
||||
🔧 Controls (tweak these)
|
||||
========================================================= */
|
||||
:root {
|
||||
--snake-cell-size: 32px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Container & Board Base
|
||||
========================================================= */
|
||||
#snake-board {
|
||||
position: relative;
|
||||
background-color: var(--card, #1e2030);
|
||||
.snake-canvas {
|
||||
display: block;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 5%, transparent);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Snake Board Layout
|
||||
========================================================= */
|
||||
.snake-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.snake-cell {
|
||||
width: var(--snake-cell-size);
|
||||
height: var(--snake-cell-size);
|
||||
background-color: var(--muted, #2f334d);
|
||||
border: 1px solid var(--border, #3b3f5c);
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Snake Rendering
|
||||
========================================================= */
|
||||
.snake-cell.snake-head {
|
||||
background-color: var(--accent, #ffc777);
|
||||
box-shadow:
|
||||
0 0 8px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
||||
inset 0 0 4px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent);
|
||||
position: relative;
|
||||
0 0 20px rgba(27, 29, 43, 0.8),
|
||||
0 0 40px rgba(27, 29, 43, 0.4),
|
||||
inset 0 0 60px rgba(130, 170, 255, 0.05);
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.snake-cell.snake-head.dir-up {
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-head.dir-down {
|
||||
border-radius: 2px 2px 6px 6px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-head.dir-left {
|
||||
border-radius: 6px 2px 2px 6px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-head.dir-right {
|
||||
border-radius: 2px 6px 6px 2px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 30%;
|
||||
background: radial-gradient(circle, var(--accent-foreground, #1b1d2b) 30%, transparent 60%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.snake-cell.snake-body {
|
||||
background-color: var(--accent, #ffc777);
|
||||
opacity: 0.7;
|
||||
box-shadow: inset 0 0 3px color-mix(in srgb, var(--accent, #ffc777) 50%, transparent);
|
||||
}
|
||||
|
||||
.snake-cell.snake-tail {
|
||||
background-color: var(--accent, #ffc777);
|
||||
opacity: 0.5;
|
||||
box-shadow: 0 0 4px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
|
||||
}
|
||||
|
||||
.snake-cell.snake-tail.dir-up {
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-tail.dir-down {
|
||||
border-radius: 2px 2px 6px 6px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-tail.dir-left {
|
||||
border-radius: 6px 2px 2px 6px;
|
||||
}
|
||||
|
||||
.snake-cell.snake-tail.dir-right {
|
||||
border-radius: 2px 6px 6px 2px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Collision Effect
|
||||
========================================================= */
|
||||
.snake-cell.collision {
|
||||
background-color: var(--destructive, #c53b53) !important;
|
||||
box-shadow:
|
||||
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent) !important;
|
||||
animation: collision-pulse 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes collision-pulse {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 20px color-mix(in srgb, var(--destructive, #c53b53) 100%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--destructive, #c53b53) 80%, transparent),
|
||||
inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Food Rendering
|
||||
========================================================= */
|
||||
.snake-cell.food {
|
||||
background-color: var(--chart-2, #c3e88d);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 8px color-mix(in srgb, var(--chart-2, #c3e88d) 60%, transparent),
|
||||
inset 0 0 4px color-mix(in srgb, var(--chart-2, #c3e88d) 80%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.snake-cell.food::after {
|
||||
content: "🍎";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { version } from '../package.json';
|
||||
|
||||
export const gameInfo = {
|
||||
version,
|
||||
name: 'VIMazing Snake',
|
||||
description: 'Classic snake game with VIM-style hjkl controls',
|
||||
|
||||
controls: {
|
||||
navigation: [
|
||||
{ keys: 'h', description: 'Turn left' },
|
||||
{ keys: 'j', description: 'Turn down' },
|
||||
{ keys: 'k', description: 'Turn up' },
|
||||
{ keys: 'l', description: 'Turn right' },
|
||||
],
|
||||
|
||||
game: [
|
||||
{ keys: 'Space', description: 'Start game (when waiting) or restart (when game over)' },
|
||||
{ keys: 'q', description: 'Quit game' },
|
||||
{ keys: 'p', description: 'Pause/unpause game' },
|
||||
],
|
||||
},
|
||||
|
||||
rules: {
|
||||
movement: [
|
||||
{ rule: 'Continuous Motion', description: 'Snake moves continuously in current direction' },
|
||||
{ rule: 'Direction Changes', description: 'hjkl change direction on next movement frame' },
|
||||
{ rule: 'Input Buffering', description: 'Rapid key presses are buffered for smooth control' },
|
||||
],
|
||||
|
||||
gameFlow: [
|
||||
{
|
||||
phase: 'Start',
|
||||
description: 'Snake spawns at center with initial size',
|
||||
objective: 'Eat food to grow and earn points',
|
||||
},
|
||||
{
|
||||
phase: 'Growth',
|
||||
description: 'Each food eaten grows snake by 1 segment',
|
||||
objective: 'Survive and reach higher levels',
|
||||
},
|
||||
{
|
||||
phase: 'Level Up',
|
||||
description: 'After eating enough food, level increases',
|
||||
objective: 'Speed increases with each level',
|
||||
},
|
||||
{
|
||||
phase: 'Game Over',
|
||||
description: 'Collision with wall or self',
|
||||
objective: 'Final score is calculated',
|
||||
},
|
||||
],
|
||||
|
||||
gameElements: [
|
||||
{ element: 'Snake Head', appearance: 'Highlighted segment', description: 'Leads the snake' },
|
||||
{ element: 'Snake Body', appearance: 'Connected segments', description: 'Grows when eating food' },
|
||||
{ element: 'Food', appearance: 'Single cell marker', description: 'Eat to grow and gain points' },
|
||||
{ element: 'Walls', appearance: 'Board boundaries', description: 'Collision causes game over' },
|
||||
],
|
||||
|
||||
collisions: [
|
||||
{ type: 'Wall Collision', trigger: 'Snake head hits board edge', result: 'Game Over' },
|
||||
{ type: 'Self Collision', trigger: 'Snake head collides with own body', result: 'Game Over' },
|
||||
],
|
||||
},
|
||||
|
||||
scoring: {
|
||||
formula: 'Points per food = Current Level',
|
||||
levelProgression: 'Level increases after eating {foodsPerLevel} foods (configurable)',
|
||||
speedScaling: 'Game speed (FPS) equals current level',
|
||||
finalScore: 'Current game score when game ends',
|
||||
range: 'Dynamic based on level reached',
|
||||
|
||||
examples: [
|
||||
{
|
||||
level: 1,
|
||||
foodsEaten: 1,
|
||||
pointsPerFood: 1,
|
||||
totalPoints: 1,
|
||||
description: 'Starting level, first food',
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
foodsEaten: 1,
|
||||
pointsPerFood: 5,
|
||||
totalPoints: 20,
|
||||
description: 'Level 5, each food worth 5 points',
|
||||
},
|
||||
{
|
||||
level: 10,
|
||||
foodsEaten: 5,
|
||||
pointsPerFood: 10,
|
||||
totalPoints: 50,
|
||||
description: 'Level 10, eating multiple foods at high level',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
gameOver: {
|
||||
conditions: [
|
||||
{
|
||||
type: 'Wall Collision',
|
||||
trigger: 'Snake head reaches board boundary',
|
||||
message: 'Crashed into wall!',
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
type: 'Self Collision',
|
||||
trigger: 'Snake head hits own body',
|
||||
message: 'Crashed into yourself!',
|
||||
configurable: false,
|
||||
},
|
||||
],
|
||||
note: 'Final score equals current game score when collision occurs',
|
||||
},
|
||||
|
||||
metrics: {
|
||||
tracked: [
|
||||
{ metric: 'Time', unit: 'seconds', description: 'Elapsed time since game start' },
|
||||
{ metric: 'Level', unit: 'count', description: 'Current level (also equals FPS)' },
|
||||
{ metric: 'Score', unit: 'points', description: 'Total points earned' },
|
||||
{ metric: 'Keystrokes', unit: 'count', description: 'Total keys pressed during game' },
|
||||
{ metric: 'Snake Length', unit: 'segments', description: 'Current snake size' },
|
||||
{ metric: 'Final Score', unit: 'points', description: 'Score when game ends' },
|
||||
],
|
||||
displayed: 'Live during gameplay and final score on game over',
|
||||
},
|
||||
|
||||
configuration: {
|
||||
options: [
|
||||
{ option: 'cols', default: 30, description: 'Board width in cells' },
|
||||
{ option: 'rows', default: 20, description: 'Board height in cells' },
|
||||
{ option: 'startingLevel', default: 1, description: 'Initial level (affects starting speed and points)' },
|
||||
{ option: 'foodsPerLevel', default: 10, description: 'Foods needed to level up' },
|
||||
{ option: 'maxLevel', default: 25, description: 'Maximum level cap' },
|
||||
{ option: 'initialSnakeSize', default: 3, description: 'Snake segments at game start' },
|
||||
{ option: 'initialFoodCount', default: 1, description: 'Food items on board at start' },
|
||||
],
|
||||
example: 'useGame({ cols: 40, rows: 30, startingLevel: 5, maxLevel: 20 })',
|
||||
},
|
||||
|
||||
speedProgression: {
|
||||
formula: 'FPS = Current Level',
|
||||
description: 'Game speed is directly tied to level',
|
||||
examples: [
|
||||
{ level: 1, fps: '1 frame/sec', msPerFrame: '1000ms' },
|
||||
{ level: 5, fps: '5 frames/sec', msPerFrame: '200ms' },
|
||||
{ level: 10, fps: '10 frames/sec', msPerFrame: '100ms' },
|
||||
{ level: 25, fps: '25 frames/sec (max default)', msPerFrame: '40ms' },
|
||||
],
|
||||
},
|
||||
|
||||
objective: 'Grow your snake by eating food while avoiding walls and yourself. Higher levels mean faster gameplay and more points per food.',
|
||||
|
||||
winCondition: 'There is no win condition - survival and high scores are the goal. Reach maxLevel to cap difficulty.',
|
||||
|
||||
tips: [
|
||||
'Use hjkl to smoothly change direction - input buffering helps with precise turns',
|
||||
'Plan ahead - remember the snake moves every frame',
|
||||
'Higher levels are harder but award more points',
|
||||
'Try different startingLevel values for custom difficulty',
|
||||
'Configure foodsPerLevel to control level progression speed',
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type GameInfo = typeof gameInfo;
|
||||
@@ -4,4 +4,5 @@ export * from './useGameStatus';
|
||||
export * from './useBoard';
|
||||
export * from './useScore';
|
||||
export * from './types';
|
||||
export * from './gameInfo';
|
||||
|
||||
|
||||
+2
-3
@@ -37,6 +37,7 @@ export type GameOptions = {
|
||||
maxLevel?: number;
|
||||
initialSnakeSize?: number;
|
||||
initialFoodCount?: number;
|
||||
cellSize?: number;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -74,13 +75,12 @@ export type SnakeCursorManager = CursorManager & {
|
||||
snakeBody: SnakeBody;
|
||||
direction: Direction;
|
||||
changeDirection: (dir: Direction) => void;
|
||||
// Snake-specific for internal use
|
||||
snakeBodyRef: MutableRefObject<SnakeBody>;
|
||||
directionRef: MutableRefObject<Direction>;
|
||||
initSnake: () => void;
|
||||
clearSnake: () => void;
|
||||
applyBufferedDirection: () => void;
|
||||
moveSnake: (grow?: boolean) => 'continue' | 'wall-collision' | 'self-collision';
|
||||
renderSnake: (body: SnakeBody) => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -90,7 +90,6 @@ export type SnakeCursorManager = CursorManager & {
|
||||
export type ScoreManager = {
|
||||
timeValue: number;
|
||||
totalKeystrokes: number;
|
||||
finalScore: number | null;
|
||||
startTimer: () => void;
|
||||
stopTimer: () => void;
|
||||
resetTimer: () => void;
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { Position, SnakeBody, Direction } from "../types";
|
||||
|
||||
export type RenderState = {
|
||||
snakeBody: SnakeBody;
|
||||
direction: Direction;
|
||||
foodPositions: Position[];
|
||||
gameOver: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
export type CanvasTheme = {
|
||||
background: string;
|
||||
gridLine: string;
|
||||
snakeHead: string;
|
||||
snakeHeadGlow: string;
|
||||
snakeBody: string;
|
||||
snakeBodyGlow: string;
|
||||
snakeTail: string;
|
||||
snakeEye: string;
|
||||
food: string;
|
||||
foodGlow: string;
|
||||
collision: string;
|
||||
collisionGlow: string;
|
||||
};
|
||||
|
||||
const DEFAULT_THEME: CanvasTheme = {
|
||||
background: "#1e2030",
|
||||
gridLine: "#2f334d",
|
||||
snakeHead: "#ffc777",
|
||||
snakeHeadGlow: "rgba(255, 199, 119, 0.6)",
|
||||
snakeBody: "#ffb347",
|
||||
snakeBodyGlow: "rgba(255, 179, 71, 0.3)",
|
||||
snakeTail: "#ff9933",
|
||||
snakeEye: "#1b1d2b",
|
||||
food: "#c3e88d",
|
||||
foodGlow: "rgba(195, 232, 141, 0.5)",
|
||||
collision: "#c53b53",
|
||||
collisionGlow: "rgba(197, 59, 83, 0.8)",
|
||||
};
|
||||
|
||||
export class CanvasRenderer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private cols: number;
|
||||
private rows: number;
|
||||
private cellSize: number;
|
||||
private theme: CanvasTheme;
|
||||
private animationTime: number = 0;
|
||||
private particleSystem: Particle[] = [];
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
cols: number,
|
||||
rows: number,
|
||||
cellSize: number = 24,
|
||||
theme: Partial<CanvasTheme> = {}
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get canvas 2d context");
|
||||
this.ctx = ctx;
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.cellSize = cellSize;
|
||||
this.theme = { ...DEFAULT_THEME, ...theme };
|
||||
|
||||
this.canvas.width = cols * cellSize;
|
||||
this.canvas.height = rows * cellSize;
|
||||
}
|
||||
|
||||
updateDimensions(cols: number, rows: number) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.canvas.width = cols * this.cellSize;
|
||||
this.canvas.height = rows * this.cellSize;
|
||||
}
|
||||
|
||||
render(state: RenderState, deltaTime: number) {
|
||||
this.animationTime += deltaTime;
|
||||
this.updateParticles(deltaTime);
|
||||
|
||||
this.clearCanvas();
|
||||
this.drawBackground();
|
||||
this.drawGrid();
|
||||
this.drawParticles();
|
||||
this.drawFood(state.foodPositions);
|
||||
this.drawSnake(state.snakeBody, state.direction, state.gameOver);
|
||||
|
||||
if (state.paused) {
|
||||
this.drawPauseOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
private clearCanvas() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
private drawBackground() {
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2,
|
||||
0,
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2,
|
||||
Math.max(this.canvas.width, this.canvas.height) / 2
|
||||
);
|
||||
gradient.addColorStop(0, "#252840");
|
||||
gradient.addColorStop(1, this.theme.background);
|
||||
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
private drawGrid() {
|
||||
this.ctx.strokeStyle = this.theme.gridLine;
|
||||
this.ctx.lineWidth = 0.5;
|
||||
|
||||
for (let c = 0; c <= this.cols; c++) {
|
||||
const x = c * this.cellSize;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x, 0);
|
||||
this.ctx.lineTo(x, this.canvas.height);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
for (let r = 0; r <= this.rows; r++) {
|
||||
const y = r * this.cellSize;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, y);
|
||||
this.ctx.lineTo(this.canvas.width, y);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private drawSnake(body: SnakeBody, direction: Direction, gameOver: boolean) {
|
||||
if (body.length === 0) return;
|
||||
|
||||
const color = gameOver ? this.theme.collision : this.theme.snakeBody;
|
||||
const glowColor = gameOver ? this.theme.collisionGlow : this.theme.snakeBodyGlow;
|
||||
|
||||
for (let i = body.length - 1; i >= 0; i--) {
|
||||
const segment = body[i];
|
||||
const x = segment.c * this.cellSize;
|
||||
const y = segment.r * this.cellSize;
|
||||
const padding = 2;
|
||||
const size = this.cellSize - padding * 2;
|
||||
|
||||
if (i === 0) {
|
||||
this.drawSnakeHead(x, y, direction, gameOver);
|
||||
} else if (i === body.length - 1) {
|
||||
this.drawSnakeTail(x, y, body, i, gameOver);
|
||||
} else {
|
||||
this.drawSnakeBody(x, y, size, padding, color, glowColor, i / body.length, body, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawSnakeHead(x: number, y: number, direction: Direction, gameOver: boolean) {
|
||||
const padding = 1;
|
||||
const size = this.cellSize - padding * 2;
|
||||
const centerX = x + this.cellSize / 2;
|
||||
const centerY = y + this.cellSize / 2;
|
||||
|
||||
const headColor = gameOver ? this.theme.collision : this.theme.snakeHead;
|
||||
const glowColor = gameOver ? this.theme.collisionGlow : this.theme.snakeHeadGlow;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.shadowColor = glowColor;
|
||||
this.ctx.shadowBlur = gameOver ? 20 + Math.sin(this.animationTime * 10) * 5 : 15;
|
||||
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
centerX - size * 0.2,
|
||||
centerY - size * 0.2,
|
||||
0,
|
||||
centerX,
|
||||
centerY,
|
||||
size * 0.6
|
||||
);
|
||||
gradient.addColorStop(0, this.lightenColor(headColor, 30));
|
||||
gradient.addColorStop(1, headColor);
|
||||
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.drawRoundedRect(
|
||||
x + padding,
|
||||
y + padding,
|
||||
size,
|
||||
size,
|
||||
this.getHeadRadius(direction)
|
||||
);
|
||||
this.ctx.fill();
|
||||
this.ctx.restore();
|
||||
|
||||
if (!gameOver) {
|
||||
this.drawEyes(centerX, centerY, direction, size);
|
||||
} else {
|
||||
this.drawDeadEyes(centerX, centerY, direction, size);
|
||||
}
|
||||
}
|
||||
|
||||
private getHeadRadius(direction: Direction): [number, number, number, number] {
|
||||
const large = this.cellSize * 0.35;
|
||||
const small = this.cellSize * 0.1;
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return [large, large, small, small];
|
||||
case "down":
|
||||
return [small, small, large, large];
|
||||
case "left":
|
||||
return [large, small, small, large];
|
||||
case "right":
|
||||
return [small, large, large, small];
|
||||
}
|
||||
}
|
||||
|
||||
private drawEyes(cx: number, cy: number, direction: Direction, size: number) {
|
||||
const eyeSize = size * 0.15;
|
||||
const eyeOffset = size * 0.2;
|
||||
const pupilOffset = size * 0.05;
|
||||
|
||||
let eye1X: number, eye1Y: number, eye2X: number, eye2Y: number;
|
||||
let pupilDX = 0, pupilDY = 0;
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
eye1X = cx - eyeOffset;
|
||||
eye1Y = cy - eyeOffset * 0.5;
|
||||
eye2X = cx + eyeOffset;
|
||||
eye2Y = cy - eyeOffset * 0.5;
|
||||
pupilDY = -pupilOffset;
|
||||
break;
|
||||
case "down":
|
||||
eye1X = cx - eyeOffset;
|
||||
eye1Y = cy + eyeOffset * 0.5;
|
||||
eye2X = cx + eyeOffset;
|
||||
eye2Y = cy + eyeOffset * 0.5;
|
||||
pupilDY = pupilOffset;
|
||||
break;
|
||||
case "left":
|
||||
eye1X = cx - eyeOffset * 0.5;
|
||||
eye1Y = cy - eyeOffset;
|
||||
eye2X = cx - eyeOffset * 0.5;
|
||||
eye2Y = cy + eyeOffset;
|
||||
pupilDX = -pupilOffset;
|
||||
break;
|
||||
case "right":
|
||||
eye1X = cx + eyeOffset * 0.5;
|
||||
eye1Y = cy - eyeOffset;
|
||||
eye2X = cx + eyeOffset * 0.5;
|
||||
eye2Y = cy + eyeOffset;
|
||||
pupilDX = pupilOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
this.ctx.fillStyle = "#ffffff";
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(eye1X, eye1Y, eyeSize, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(eye2X, eye2Y, eyeSize, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.fillStyle = this.theme.snakeEye;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(eye1X + pupilDX, eye1Y + pupilDY, eyeSize * 0.6, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(eye2X + pupilDX, eye2Y + pupilDY, eyeSize * 0.6, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
private drawDeadEyes(cx: number, cy: number, direction: Direction, size: number) {
|
||||
const eyeOffset = size * 0.2;
|
||||
const xSize = size * 0.12;
|
||||
|
||||
let eye1X: number, eye1Y: number, eye2X: number, eye2Y: number;
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
eye1X = cx - eyeOffset;
|
||||
eye1Y = cy - eyeOffset * 0.5;
|
||||
eye2X = cx + eyeOffset;
|
||||
eye2Y = cy - eyeOffset * 0.5;
|
||||
break;
|
||||
case "down":
|
||||
eye1X = cx - eyeOffset;
|
||||
eye1Y = cy + eyeOffset * 0.5;
|
||||
eye2X = cx + eyeOffset;
|
||||
eye2Y = cy + eyeOffset * 0.5;
|
||||
break;
|
||||
case "left":
|
||||
eye1X = cx - eyeOffset * 0.5;
|
||||
eye1Y = cy - eyeOffset;
|
||||
eye2X = cx - eyeOffset * 0.5;
|
||||
eye2Y = cy + eyeOffset;
|
||||
break;
|
||||
case "right":
|
||||
eye1X = cx + eyeOffset * 0.5;
|
||||
eye1Y = cy - eyeOffset;
|
||||
eye2X = cx + eyeOffset * 0.5;
|
||||
eye2Y = cy + eyeOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
this.ctx.strokeStyle = "#ffffff";
|
||||
this.ctx.lineWidth = 2;
|
||||
|
||||
[{ x: eye1X, y: eye1Y }, { x: eye2X, y: eye2Y }].forEach(({ x, y }) => {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x - xSize, y - xSize);
|
||||
this.ctx.lineTo(x + xSize, y + xSize);
|
||||
this.ctx.moveTo(x + xSize, y - xSize);
|
||||
this.ctx.lineTo(x - xSize, y + xSize);
|
||||
this.ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
private drawSnakeBody(
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
padding: number,
|
||||
color: string,
|
||||
glowColor: string,
|
||||
progress: number,
|
||||
body: SnakeBody,
|
||||
index: number
|
||||
) {
|
||||
const centerX = x + this.cellSize / 2;
|
||||
const centerY = y + this.cellSize / 2;
|
||||
const opacity = 0.9 - progress * 0.3;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.shadowColor = glowColor;
|
||||
this.ctx.shadowBlur = 8;
|
||||
this.ctx.globalAlpha = opacity;
|
||||
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
centerX - size * 0.15,
|
||||
centerY - size * 0.15,
|
||||
0,
|
||||
centerX,
|
||||
centerY,
|
||||
size * 0.5
|
||||
);
|
||||
gradient.addColorStop(0, this.lightenColor(color, 20));
|
||||
gradient.addColorStop(1, color);
|
||||
|
||||
this.ctx.fillStyle = gradient;
|
||||
|
||||
const radius = this.getBodyRadius(body, index);
|
||||
this.drawRoundedRect(x + padding, y + padding, size, size, radius);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private getBodyRadius(body: SnakeBody, index: number): [number, number, number, number] {
|
||||
const defaultRadius = this.cellSize * 0.15;
|
||||
if (index <= 0 || index >= body.length - 1) {
|
||||
return [defaultRadius, defaultRadius, defaultRadius, defaultRadius];
|
||||
}
|
||||
|
||||
const prev = body[index - 1];
|
||||
const curr = body[index];
|
||||
const next = body[index + 1];
|
||||
|
||||
const fromPrev = { dr: curr.r - prev.r, dc: curr.c - prev.c };
|
||||
const toNext = { dr: next.r - curr.r, dc: next.c - curr.c };
|
||||
|
||||
if (fromPrev.dr === toNext.dr && fromPrev.dc === toNext.dc) {
|
||||
return [defaultRadius, defaultRadius, defaultRadius, defaultRadius];
|
||||
}
|
||||
|
||||
const large = this.cellSize * 0.3;
|
||||
const small = this.cellSize * 0.08;
|
||||
|
||||
if (fromPrev.dr === -1 && toNext.dc === 1) return [small, large, small, small];
|
||||
if (fromPrev.dr === -1 && toNext.dc === -1) return [large, small, small, small];
|
||||
if (fromPrev.dr === 1 && toNext.dc === 1) return [small, small, small, large];
|
||||
if (fromPrev.dr === 1 && toNext.dc === -1) return [small, small, large, small];
|
||||
if (fromPrev.dc === -1 && toNext.dr === 1) return [small, small, small, large];
|
||||
if (fromPrev.dc === -1 && toNext.dr === -1) return [small, large, small, small];
|
||||
if (fromPrev.dc === 1 && toNext.dr === 1) return [small, small, large, small];
|
||||
if (fromPrev.dc === 1 && toNext.dr === -1) return [large, small, small, small];
|
||||
|
||||
return [defaultRadius, defaultRadius, defaultRadius, defaultRadius];
|
||||
}
|
||||
|
||||
private drawSnakeTail(x: number, y: number, body: SnakeBody, _index: number, gameOver: boolean) {
|
||||
const padding = 3;
|
||||
const size = this.cellSize - padding * 2;
|
||||
const centerX = x + this.cellSize / 2;
|
||||
const centerY = y + this.cellSize / 2;
|
||||
|
||||
const color = gameOver ? this.theme.collision : this.theme.snakeTail;
|
||||
const glowColor = gameOver ? this.theme.collisionGlow : this.theme.snakeBodyGlow;
|
||||
|
||||
const tailDir = this.getTailDirection(body);
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.shadowColor = glowColor;
|
||||
this.ctx.shadowBlur = 5;
|
||||
this.ctx.globalAlpha = 0.6;
|
||||
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
centerX,
|
||||
centerY,
|
||||
0,
|
||||
centerX,
|
||||
centerY,
|
||||
size * 0.5
|
||||
);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, this.darkenColor(color, 20));
|
||||
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.drawRoundedRect(
|
||||
x + padding,
|
||||
y + padding,
|
||||
size,
|
||||
size,
|
||||
this.getTailRadius(tailDir)
|
||||
);
|
||||
this.ctx.fill();
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private getTailDirection(body: SnakeBody): Direction {
|
||||
if (body.length < 2) return "up";
|
||||
const tail = body[body.length - 1];
|
||||
const beforeTail = body[body.length - 2];
|
||||
|
||||
if (tail.r < beforeTail.r) return "up";
|
||||
if (tail.r > beforeTail.r) return "down";
|
||||
if (tail.c < beforeTail.c) return "left";
|
||||
return "right";
|
||||
}
|
||||
|
||||
private getTailRadius(direction: Direction): [number, number, number, number] {
|
||||
const large = this.cellSize * 0.4;
|
||||
const small = this.cellSize * 0.05;
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return [large, large, small, small];
|
||||
case "down":
|
||||
return [small, small, large, large];
|
||||
case "left":
|
||||
return [large, small, small, large];
|
||||
case "right":
|
||||
return [small, large, large, small];
|
||||
}
|
||||
}
|
||||
|
||||
private drawFood(positions: Position[]) {
|
||||
positions.forEach((pos) => {
|
||||
const x = pos.c * this.cellSize;
|
||||
const y = pos.r * this.cellSize;
|
||||
const centerX = x + this.cellSize / 2;
|
||||
const centerY = y + this.cellSize / 2;
|
||||
const baseRadius = this.cellSize * 0.35;
|
||||
|
||||
const pulse = 1 + Math.sin(this.animationTime * 4) * 0.08;
|
||||
const radius = baseRadius * pulse;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.shadowColor = this.theme.foodGlow;
|
||||
this.ctx.shadowBlur = 15 + Math.sin(this.animationTime * 3) * 5;
|
||||
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
centerX - radius * 0.3,
|
||||
centerY - radius * 0.3,
|
||||
0,
|
||||
centerX,
|
||||
centerY,
|
||||
radius
|
||||
);
|
||||
gradient.addColorStop(0, this.lightenColor(this.theme.food, 40));
|
||||
gradient.addColorStop(0.5, this.theme.food);
|
||||
gradient.addColorStop(1, this.darkenColor(this.theme.food, 20));
|
||||
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.fillStyle = this.darkenColor(this.theme.food, 40);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.ellipse(
|
||||
centerX + radius * 0.3,
|
||||
centerY - radius * 0.5,
|
||||
radius * 0.2,
|
||||
radius * 0.1,
|
||||
Math.PI / 4,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(
|
||||
centerX - radius * 0.3,
|
||||
centerY - radius * 0.3,
|
||||
radius * 0.2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
private drawPauseOverlay() {
|
||||
this.ctx.save();
|
||||
this.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
this.ctx.fillStyle = "#ffffff";
|
||||
this.ctx.font = `bold ${this.cellSize}px sans-serif`;
|
||||
this.ctx.textAlign = "center";
|
||||
this.ctx.textBaseline = "middle";
|
||||
this.ctx.fillText("PAUSED", this.canvas.width / 2, this.canvas.height / 2);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
spawnFoodParticles(pos: Position) {
|
||||
const centerX = pos.c * this.cellSize + this.cellSize / 2;
|
||||
const centerY = pos.r * this.cellSize + this.cellSize / 2;
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (Math.PI * 2 * i) / 8 + Math.random() * 0.5;
|
||||
const speed = 50 + Math.random() * 50;
|
||||
this.particleSystem.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
decay: 2 + Math.random(),
|
||||
size: 3 + Math.random() * 2,
|
||||
color: this.theme.food,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateParticles(deltaTime: number) {
|
||||
this.particleSystem = this.particleSystem.filter((p) => {
|
||||
p.x += p.vx * deltaTime;
|
||||
p.y += p.vy * deltaTime;
|
||||
p.life -= p.decay * deltaTime;
|
||||
p.vx *= 0.98;
|
||||
p.vy *= 0.98;
|
||||
return p.life > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private drawParticles() {
|
||||
this.particleSystem.forEach((p) => {
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = p.life;
|
||||
this.ctx.fillStyle = p.color;
|
||||
this.ctx.shadowColor = p.color;
|
||||
this.ctx.shadowBlur = 5;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
this.ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
private drawRoundedRect(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radii: [number, number, number, number]
|
||||
) {
|
||||
const [tl, tr, br, bl] = radii;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x + tl, y);
|
||||
this.ctx.lineTo(x + width - tr, y);
|
||||
this.ctx.quadraticCurveTo(x + width, y, x + width, y + tr);
|
||||
this.ctx.lineTo(x + width, y + height - br);
|
||||
this.ctx.quadraticCurveTo(x + width, y + height, x + width - br, y + height);
|
||||
this.ctx.lineTo(x + bl, y + height);
|
||||
this.ctx.quadraticCurveTo(x, y + height, x, y + height - bl);
|
||||
this.ctx.lineTo(x, y + tl);
|
||||
this.ctx.quadraticCurveTo(x, y, x + tl, y);
|
||||
this.ctx.closePath();
|
||||
}
|
||||
|
||||
private lightenColor(color: string, percent: number): string {
|
||||
const num = parseInt(color.replace("#", ""), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = Math.min(255, (num >> 16) + amt);
|
||||
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt);
|
||||
const B = Math.min(255, (num & 0x0000ff) + amt);
|
||||
return `#${((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
private darkenColor(color: string, percent: number): string {
|
||||
const num = parseInt(color.replace("#", ""), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = Math.max(0, (num >> 16) - amt);
|
||||
const G = Math.max(0, ((num >> 8) & 0x00ff) - amt);
|
||||
const B = Math.max(0, (num & 0x0000ff) - amt);
|
||||
return `#${((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
decay: number;
|
||||
size: number;
|
||||
color: string;
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from './useBoard';
|
||||
export { CanvasRenderer } from './CanvasRenderer';
|
||||
export type { RenderState, CanvasTheme } from './CanvasRenderer';
|
||||
|
||||
+47
-21
@@ -1,45 +1,71 @@
|
||||
import { useRef } from "react";
|
||||
import { CanvasRenderer, type RenderState } from "./CanvasRenderer";
|
||||
|
||||
export type BoardData = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
export function useBoard() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
export type UseBoardParams = {
|
||||
cellSize?: number;
|
||||
};
|
||||
|
||||
const renderBoard = (cols: number, rows: number) => {
|
||||
export function useBoard(params?: UseBoardParams) {
|
||||
const cellSize = params?.cellSize ?? 24;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<CanvasRenderer | null>(null);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
|
||||
function initCanvas(cols: number, rows: number) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.id = "snake-board";
|
||||
board.className = "snake-board";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "snake-canvas";
|
||||
canvas.className = "snake-canvas";
|
||||
container.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const rowDiv = document.createElement("div");
|
||||
rowDiv.className = "snake-row";
|
||||
rowDiv.setAttribute("data-r", String(r));
|
||||
rendererRef.current = new CanvasRenderer(canvas, cols, rows, cellSize);
|
||||
lastFrameTimeRef.current = performance.now();
|
||||
}
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cellDiv = document.createElement("div");
|
||||
cellDiv.className = "snake-cell";
|
||||
cellDiv.setAttribute("data-r", String(r));
|
||||
cellDiv.setAttribute("data-c", String(c));
|
||||
rowDiv.appendChild(cellDiv);
|
||||
}
|
||||
|
||||
board.appendChild(rowDiv);
|
||||
function renderBoard(cols: number, rows: number) {
|
||||
if (!canvasRef.current || !rendererRef.current) {
|
||||
initCanvas(cols, rows);
|
||||
} else {
|
||||
rendererRef.current.updateDimensions(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(board);
|
||||
};
|
||||
function renderFrame(state: RenderState) {
|
||||
if (!rendererRef.current) return;
|
||||
|
||||
const now = performance.now();
|
||||
const deltaTime = (now - lastFrameTimeRef.current) / 1000;
|
||||
lastFrameTimeRef.current = now;
|
||||
|
||||
rendererRef.current.render(state, deltaTime);
|
||||
}
|
||||
|
||||
function spawnFoodParticles(pos: { r: number; c: number }) {
|
||||
rendererRef.current?.spawnFoodParticles(pos);
|
||||
}
|
||||
|
||||
function getRenderer(): CanvasRenderer | null {
|
||||
return rendererRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
canvasRef,
|
||||
renderBoard,
|
||||
renderFrame,
|
||||
spawnFoodParticles,
|
||||
getRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+52
-120
@@ -1,6 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import type { Direction, SnakeBody, Position } from "../types";
|
||||
import type { UseBoardType } from "../useBoard";
|
||||
|
||||
export type UseCursorParams = {
|
||||
initialSnakeSize?: number;
|
||||
@@ -9,104 +8,49 @@ export type UseCursorParams = {
|
||||
export function useCursor(
|
||||
cols: number,
|
||||
rows: number,
|
||||
boardManager: UseBoardType,
|
||||
params?: UseCursorParams
|
||||
) {
|
||||
const initialSnakeSize = params?.initialSnakeSize ?? 3;
|
||||
const { containerRef } = boardManager;
|
||||
const [snakeBody, setSnakeBody] = useState<SnakeBody>([]);
|
||||
const snakeBodyRef = useRef<SnakeBody>([]);
|
||||
const [direction, setDirection] = useState<Direction>("up");
|
||||
const directionRef = useRef<Direction>("up");
|
||||
const lastMovedDirectionRef = useRef<Direction>("up");
|
||||
const nextDirectionRef = useRef<Direction | null>(null);
|
||||
const lastMotionRef = useRef<Direction | null>(null);
|
||||
|
||||
const initSnake = () => {
|
||||
const centerR = Math.floor(rows / 2);
|
||||
const centerC = Math.floor(cols / 2);
|
||||
function initSnake() {
|
||||
const centerR = Math.floor(rows / 2);
|
||||
const centerC = Math.floor(cols / 2);
|
||||
|
||||
const initialBody: SnakeBody = [];
|
||||
for (let i = 0; i < initialSnakeSize; i++) {
|
||||
initialBody.push({ r: centerR + i, c: centerC });
|
||||
}
|
||||
const initialBody: SnakeBody = [];
|
||||
for (let i = 0; i < initialSnakeSize; i++) {
|
||||
initialBody.push({ r: centerR + i, c: centerC });
|
||||
}
|
||||
|
||||
setSnakeBody(initialBody);
|
||||
snakeBodyRef.current = initialBody;
|
||||
setDirection("up");
|
||||
directionRef.current = "up";
|
||||
lastMovedDirectionRef.current = "up";
|
||||
renderSnake(initialBody);
|
||||
};
|
||||
nextDirectionRef.current = null;
|
||||
}
|
||||
|
||||
const clearSnake = () => {
|
||||
function clearSnake() {
|
||||
setSnakeBody([]);
|
||||
snakeBodyRef.current = [];
|
||||
setDirection("up");
|
||||
directionRef.current = "up";
|
||||
lastMovedDirectionRef.current = "up";
|
||||
};
|
||||
nextDirectionRef.current = null;
|
||||
}
|
||||
|
||||
const renderSnake = (body: SnakeBody) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const dir = directionRef.current;
|
||||
const newPositions = new Set<string>();
|
||||
|
||||
// First, apply new positions and track them
|
||||
body.forEach((segment, idx) => {
|
||||
const posKey = `${segment.r},${segment.c}`;
|
||||
newPositions.add(posKey);
|
||||
|
||||
const cell = container.querySelector(
|
||||
`.snake-cell[data-r="${segment.r}"][data-c="${segment.c}"]`
|
||||
);
|
||||
if (cell) {
|
||||
cell.className = "snake-cell";
|
||||
if (idx === 0) {
|
||||
cell.classList.add("snake-head", `dir-${dir}`);
|
||||
} else if (idx === body.length - 1) {
|
||||
const tailDir = getTailDirection(body);
|
||||
cell.classList.add("snake-tail", `dir-${tailDir}`);
|
||||
} else {
|
||||
cell.classList.add("snake-body");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Then clear old positions not in the new body
|
||||
const allSnakeCells = container.querySelectorAll(".snake-head, .snake-body, .snake-tail");
|
||||
allSnakeCells.forEach((el: Element) => {
|
||||
const r = el.getAttribute("data-r");
|
||||
const c = el.getAttribute("data-c");
|
||||
const posKey = `${r},${c}`;
|
||||
if (!newPositions.has(posKey)) {
|
||||
(el as HTMLElement).className = "snake-cell";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getTailDirection = (body: SnakeBody): Direction => {
|
||||
if (body.length < 2) return directionRef.current;
|
||||
|
||||
const tail = body[body.length - 1];
|
||||
const beforeTail = body[body.length - 2];
|
||||
|
||||
// Tail points away from beforeTail
|
||||
if (tail.r < beforeTail.r) return "up";
|
||||
if (tail.r > beforeTail.r) return "down";
|
||||
if (tail.c < beforeTail.c) return "left";
|
||||
if (tail.c > beforeTail.c) return "right";
|
||||
|
||||
return directionRef.current;
|
||||
};
|
||||
|
||||
const changeDirection = (newDirection: Direction) => {
|
||||
// Simply buffer the direction - apply validation on next move
|
||||
function changeDirection(newDirection: Direction) {
|
||||
nextDirectionRef.current = newDirection;
|
||||
};
|
||||
lastMotionRef.current = newDirection;
|
||||
}
|
||||
|
||||
const applyBufferedDirection = () => {
|
||||
function applyBufferedDirection() {
|
||||
if (nextDirectionRef.current === null) return;
|
||||
|
||||
const newDirection = nextDirectionRef.current;
|
||||
@@ -117,16 +61,14 @@ export function useCursor(
|
||||
right: "left",
|
||||
};
|
||||
|
||||
// Only prevent immediate 180° reversal (would cause self-collision on same frame)
|
||||
// This is the CURRENT direction, not the last moved direction
|
||||
if (opposites[directionRef.current] !== newDirection) {
|
||||
setDirection(newDirection);
|
||||
directionRef.current = newDirection;
|
||||
nextDirectionRef.current = null;
|
||||
}
|
||||
};
|
||||
nextDirectionRef.current = null;
|
||||
}
|
||||
|
||||
const moveSnake = (grow: boolean = false): "continue" | "wall-collision" | "self-collision" => {
|
||||
function moveSnake(grow: boolean = false): "continue" | "wall-collision" | "self-collision" {
|
||||
const body = snakeBodyRef.current;
|
||||
if (body.length === 0) return "continue";
|
||||
|
||||
@@ -157,7 +99,6 @@ export function useCursor(
|
||||
|
||||
const newHead = { r: newR, c: newC };
|
||||
|
||||
// Check self-collision (excluding tail if not growing, as tail will move)
|
||||
const bodyToCheck = grow ? body : body.slice(0, -1);
|
||||
const collision = bodyToCheck.some(
|
||||
(segment) => segment.r === newHead.r && segment.c === newHead.c
|
||||
@@ -175,65 +116,58 @@ export function useCursor(
|
||||
|
||||
setSnakeBody(newBody);
|
||||
snakeBodyRef.current = newBody;
|
||||
renderSnake(newBody);
|
||||
|
||||
return "continue";
|
||||
};
|
||||
}
|
||||
|
||||
// Unified API compliant methods
|
||||
const position = (): Position => {
|
||||
function position(): Position {
|
||||
const head = snakeBodyRef.current[0];
|
||||
return head || { r: 0, c: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
const mode = () => 'normal' as const;
|
||||
function mode() {
|
||||
return "normal" as const;
|
||||
}
|
||||
|
||||
const moveLeft = (_count?: number) => {
|
||||
changeDirection('left');
|
||||
};
|
||||
function moveLeft(_count?: number) {
|
||||
changeDirection("left");
|
||||
}
|
||||
|
||||
const moveRight = (_count?: number) => {
|
||||
changeDirection('right');
|
||||
};
|
||||
function moveRight(_count?: number) {
|
||||
changeDirection("right");
|
||||
}
|
||||
|
||||
const moveUp = (_count?: number) => {
|
||||
changeDirection('up');
|
||||
};
|
||||
function moveUp(_count?: number) {
|
||||
changeDirection("up");
|
||||
}
|
||||
|
||||
const moveDown = (_count?: number) => {
|
||||
changeDirection('down');
|
||||
};
|
||||
function moveDown(_count?: number) {
|
||||
changeDirection("down");
|
||||
}
|
||||
|
||||
const moveToStart = () => {
|
||||
// Snake-specific: move to leftmost position on current row
|
||||
changeDirection('left');
|
||||
};
|
||||
function moveToStart() {
|
||||
changeDirection("left");
|
||||
}
|
||||
|
||||
const moveToEnd = () => {
|
||||
// Snake-specific: move to rightmost position on current row
|
||||
changeDirection('right');
|
||||
};
|
||||
function moveToEnd() {
|
||||
changeDirection("right");
|
||||
}
|
||||
|
||||
const moveToTop = () => {
|
||||
// Snake-specific: move to top of board
|
||||
changeDirection('up');
|
||||
};
|
||||
function moveToTop() {
|
||||
changeDirection("up");
|
||||
}
|
||||
|
||||
const moveToBottom = () => {
|
||||
// Snake-specific: move to bottom of board
|
||||
changeDirection('down');
|
||||
};
|
||||
function moveToBottom() {
|
||||
changeDirection("down");
|
||||
}
|
||||
|
||||
const lastMotionRef = useRef<Direction | null>(null);
|
||||
|
||||
const repeatLastMotion = () => {
|
||||
function repeatLastMotion() {
|
||||
if (lastMotionRef.current) {
|
||||
changeDirection(lastMotionRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Unified API methods
|
||||
position,
|
||||
mode,
|
||||
moveLeft,
|
||||
@@ -246,7 +180,6 @@ export function useCursor(
|
||||
moveToBottom,
|
||||
repeatLastMotion,
|
||||
|
||||
// Snake-specific methods (with proper values)
|
||||
snakeBody,
|
||||
snakeBodyRef,
|
||||
direction,
|
||||
@@ -256,7 +189,6 @@ export function useCursor(
|
||||
changeDirection,
|
||||
applyBufferedDirection,
|
||||
moveSnake,
|
||||
renderSnake,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+17
-47
@@ -1,21 +1,19 @@
|
||||
import { useState, useRef } from "react";
|
||||
import type { Position, SnakeBody } from "../types";
|
||||
import type { UseBoardType } from "../useBoard";
|
||||
|
||||
export function useFood(
|
||||
cols: number,
|
||||
rows: number,
|
||||
boardManager: UseBoardType
|
||||
) {
|
||||
const { containerRef } = boardManager;
|
||||
export function useFood(cols: number, rows: number) {
|
||||
const [foodPositions, setFoodPositions] = useState<Position[]>([]);
|
||||
const foodPositionsRef = useRef<Position[]>([]);
|
||||
|
||||
const spawnFood = (snakeBody: SnakeBody, count: number = 1) => {
|
||||
function spawnFood(snakeBody: SnakeBody, count: number = 1) {
|
||||
const occupiedPositions = new Set(
|
||||
snakeBody.map((seg) => `${seg.r},${seg.c}`)
|
||||
);
|
||||
|
||||
foodPositionsRef.current.forEach((f) => {
|
||||
occupiedPositions.add(`${f.r},${f.c}`);
|
||||
});
|
||||
|
||||
const availablePositions: Position[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
@@ -26,7 +24,7 @@ export function useFood(
|
||||
}
|
||||
}
|
||||
|
||||
const newFood: Position[] = [];
|
||||
const newFood: Position[] = [...foodPositionsRef.current];
|
||||
for (let i = 0; i < count && availablePositions.length > 0; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * availablePositions.length);
|
||||
const food = availablePositions.splice(randomIndex, 1)[0];
|
||||
@@ -35,52 +33,25 @@ export function useFood(
|
||||
|
||||
setFoodPositions(newFood);
|
||||
foodPositionsRef.current = newFood;
|
||||
renderFood(newFood);
|
||||
};
|
||||
}
|
||||
|
||||
const clearFood = () => {
|
||||
setFoodPositions([]);
|
||||
foodPositionsRef.current = [];
|
||||
const container = containerRef.current;
|
||||
container?.querySelectorAll(".food").forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.remove("food");
|
||||
});
|
||||
};
|
||||
function clearFood() {
|
||||
setFoodPositions([]);
|
||||
foodPositionsRef.current = [];
|
||||
}
|
||||
|
||||
const renderFood = (positions: Position[]) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.querySelectorAll(".food").forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.remove("food");
|
||||
});
|
||||
|
||||
positions.forEach((pos) => {
|
||||
const cell = container.querySelector(
|
||||
`.snake-cell[data-r="${pos.r}"][data-c="${pos.c}"]`
|
||||
);
|
||||
if (cell) {
|
||||
cell.classList.add("food");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const checkFoodCollision = (headPos: Position): boolean => {
|
||||
function checkFoodCollision(headPos: Position): boolean {
|
||||
const food = foodPositionsRef.current;
|
||||
const collision = food.some(
|
||||
(f) => f.r === headPos.r && f.c === headPos.c
|
||||
);
|
||||
return collision;
|
||||
};
|
||||
return food.some((f) => f.r === headPos.r && f.c === headPos.c);
|
||||
}
|
||||
|
||||
const removeFood = (pos: Position) => {
|
||||
function removeFood(pos: Position) {
|
||||
const newFood = foodPositionsRef.current.filter(
|
||||
(f) => !(f.r === pos.r && f.c === pos.c)
|
||||
);
|
||||
setFoodPositions(newFood);
|
||||
foodPositionsRef.current = newFood;
|
||||
renderFood(newFood);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
foodPositions,
|
||||
@@ -89,7 +60,6 @@ export function useFood(
|
||||
clearFood,
|
||||
checkFoodCollision,
|
||||
removeFood,
|
||||
renderFood,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+31
-17
@@ -8,21 +8,43 @@ import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings";
|
||||
import { useScore } from "../useScore";
|
||||
|
||||
export function useGame(options?: GameOptions, platformHook?: unknown): GameManager {
|
||||
const { cols = 30, rows = 20, startingLevel, foodsPerLevel, maxLevel, initialSnakeSize, initialFoodCount } = options ?? {};
|
||||
const {
|
||||
cols = 30,
|
||||
rows = 20,
|
||||
startingLevel,
|
||||
foodsPerLevel,
|
||||
maxLevel,
|
||||
initialSnakeSize,
|
||||
initialFoodCount,
|
||||
cellSize,
|
||||
} = options ?? {};
|
||||
|
||||
const boardManager = useBoard();
|
||||
const { containerRef, renderBoard } = boardManager;
|
||||
const boardManager = useBoard({ cellSize });
|
||||
const { containerRef, renderBoard, renderFrame } = boardManager;
|
||||
|
||||
const snakeManager = useCursor(cols, rows, boardManager, { initialSnakeSize });
|
||||
const { changeDirection } = snakeManager;
|
||||
const snakeManager = useCursor(cols, rows, { initialSnakeSize });
|
||||
const { changeDirection, snakeBodyRef, directionRef } = snakeManager;
|
||||
|
||||
const foodManager = useFood(cols, rows, boardManager);
|
||||
const foodManager = useFood(cols, rows);
|
||||
|
||||
const gameManager = useGameStatus(boardManager, snakeManager, foodManager, { startingLevel, foodsPerLevel, maxLevel, initialFoodCount });
|
||||
const gameManager = useGameStatus(boardManager, snakeManager, foodManager, {
|
||||
startingLevel,
|
||||
foodsPerLevel,
|
||||
maxLevel,
|
||||
initialFoodCount,
|
||||
});
|
||||
const { gameStatus, setGameStatus, startGame, quitGame, togglePause, score, level } = gameManager;
|
||||
|
||||
useEffect(() => {
|
||||
renderBoard(cols, rows);
|
||||
|
||||
renderFrame({
|
||||
snakeBody: snakeBodyRef.current,
|
||||
direction: directionRef.current,
|
||||
foodPositions: foodManager.foodPositionsRef.current,
|
||||
gameOver: false,
|
||||
paused: false,
|
||||
});
|
||||
}, [cols, rows]);
|
||||
|
||||
const keyBindings: UseKeyBindingsType = useKeyBindings({
|
||||
@@ -34,37 +56,29 @@ export function useGame(options?: GameOptions, platformHook?: unknown): GameMana
|
||||
|
||||
const scoreManager = useScore({ gameStatus, keyLog: keyBindings.keyLog, currentScore: score });
|
||||
|
||||
// Wrap renderBoard to match Unified API signature
|
||||
const renderBoardWrapped = () => {
|
||||
function renderBoardWrapped() {
|
||||
renderBoard(cols, rows);
|
||||
};
|
||||
}
|
||||
|
||||
// Build Unified API compliant GameManager
|
||||
const gameManagerResult: GameManager = {
|
||||
// Required rendering
|
||||
containerRef,
|
||||
renderBoard: renderBoardWrapped,
|
||||
|
||||
// Required managers
|
||||
cursor: snakeManager,
|
||||
scoreManager,
|
||||
|
||||
// Required lifecycle
|
||||
gameStatus,
|
||||
setGameStatus,
|
||||
startGame,
|
||||
quitGame,
|
||||
|
||||
// Required state
|
||||
level,
|
||||
score,
|
||||
|
||||
// Required key tracking
|
||||
keyLog: keyBindings.keyLog,
|
||||
clearKeyLog: keyBindings.clearLog,
|
||||
getKeyLog: keyBindings.getLog,
|
||||
|
||||
// Game-specific additions
|
||||
togglePause,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,127 +21,134 @@ export function useGameStatus(
|
||||
const foodsPerLevel = params?.foodsPerLevel ?? 10;
|
||||
const maxLevel = params?.maxLevel ?? 25;
|
||||
const initialFoodCount = params?.initialFoodCount ?? 1;
|
||||
const { containerRef } = boardManager;
|
||||
|
||||
const { renderFrame, spawnFoodParticles } = boardManager;
|
||||
const { initSnake, clearSnake, snakeBodyRef } = snakeManager;
|
||||
const { spawnFood, clearFood } = foodManager;
|
||||
|
||||
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
|
||||
const [score, setScore] = useState(0);
|
||||
const [level, setLevel] = useState(startingLevel);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const levelRef = useRef(startingLevel);
|
||||
const foodsEatenRef = useRef(0);
|
||||
const gameLoopRef = useRef<number | null>(null);
|
||||
const lastUpdateRef = useRef<number>(0);
|
||||
const lastGameTickRef = useRef<number>(0);
|
||||
const shouldGrowRef = useRef(false);
|
||||
const gameOverRef = useRef(false);
|
||||
|
||||
const snakeManagerRef = useRef(snakeManager);
|
||||
const foodManagerRef = useRef(foodManager);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
snakeManagerRef.current = snakeManager;
|
||||
foodManagerRef.current = foodManager;
|
||||
|
||||
const startGame = () => {
|
||||
function startGame() {
|
||||
initSnake();
|
||||
spawnFood(snakeBodyRef.current, initialFoodCount);
|
||||
setScore(0);
|
||||
setLevel(startingLevel);
|
||||
levelRef.current = startingLevel;
|
||||
foodsEatenRef.current = 0;
|
||||
shouldGrowRef.current = false;
|
||||
gameOverRef.current = false;
|
||||
setGameStatus("started");
|
||||
};
|
||||
}
|
||||
|
||||
const stopGame = () => {
|
||||
function stopGame() {
|
||||
if (gameLoopRef.current) {
|
||||
cancelAnimationFrame(gameLoopRef.current);
|
||||
gameLoopRef.current = null;
|
||||
}
|
||||
clearSnake();
|
||||
clearFood();
|
||||
gameOverRef.current = false;
|
||||
setGameStatus("waiting");
|
||||
const container = containerRef.current;
|
||||
container?.querySelectorAll(".snake-head, .snake-body, .snake-tail, .collision").forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.remove("snake-head", "snake-body", "snake-tail", "collision");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
setPaused((curr) => {
|
||||
pausedRef.current = !curr;
|
||||
return !curr;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus === "started") {
|
||||
if (paused) return;
|
||||
let shouldGrow = false;
|
||||
lastUpdateRef.current = performance.now();
|
||||
if (gameStatus !== "started") return;
|
||||
|
||||
const gameLoop = (timestamp: number) => {
|
||||
const msPerFrame = 1000 / levelRef.current;
|
||||
const elapsed = timestamp - lastUpdateRef.current;
|
||||
lastGameTickRef.current = performance.now();
|
||||
|
||||
if (elapsed >= msPerFrame) {
|
||||
// Apply any buffered direction input BEFORE moving
|
||||
snakeManagerRef.current.applyBufferedDirection();
|
||||
function gameLoop(timestamp: number) {
|
||||
const msPerTick = 1000 / levelRef.current;
|
||||
const elapsed = timestamp - lastGameTickRef.current;
|
||||
|
||||
const result = snakeManagerRef.current.moveSnake(shouldGrow);
|
||||
if (!pausedRef.current && !gameOverRef.current) {
|
||||
if (elapsed >= msPerTick) {
|
||||
snakeManagerRef.current.applyBufferedDirection();
|
||||
|
||||
shouldGrow = false;
|
||||
|
||||
if (result === "wall-collision" || result === "self-collision") {
|
||||
const container = containerRef.current;
|
||||
container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.add("collision");
|
||||
});
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
const result = snakeManagerRef.current.moveSnake(shouldGrowRef.current);
|
||||
shouldGrowRef.current = false;
|
||||
|
||||
if (result === "wall-collision" || result === "self-collision") {
|
||||
gameOverRef.current = true;
|
||||
setGameStatus("game-over");
|
||||
} else {
|
||||
const head = snakeManagerRef.current.snakeBodyRef.current[0];
|
||||
if (head && foodManagerRef.current.checkFoodCollision(head)) {
|
||||
spawnFoodParticles(head);
|
||||
foodManagerRef.current.removeFood(head);
|
||||
shouldGrow = true;
|
||||
shouldGrowRef.current = true;
|
||||
|
||||
// Check BEFORE incrementing if we're about to level up
|
||||
const willLevelUp = (foodsEatenRef.current + 1) >= foodsPerLevel;
|
||||
const willLevelUp = foodsEatenRef.current + 1 >= foodsPerLevel;
|
||||
foodsEatenRef.current += 1;
|
||||
|
||||
foodsEatenRef.current += 1;
|
||||
const pointsToAdd = levelRef.current;
|
||||
|
||||
// Save the current level BEFORE any changes
|
||||
const pointsToAdd = levelRef.current;
|
||||
if (willLevelUp && levelRef.current < maxLevel) {
|
||||
const newLevel = levelRef.current + 1;
|
||||
levelRef.current = newLevel;
|
||||
setLevel(newLevel);
|
||||
foodsEatenRef.current = 0;
|
||||
}
|
||||
|
||||
// Update level OUTSIDE of setScore to avoid multiple triggers
|
||||
if (willLevelUp && levelRef.current < maxLevel) {
|
||||
const newLevel = levelRef.current + 1;
|
||||
levelRef.current = newLevel;
|
||||
setLevel(newLevel);
|
||||
foodsEatenRef.current = 0;
|
||||
}
|
||||
setScore((prev) => prev + pointsToAdd);
|
||||
|
||||
setScore((prev) => {
|
||||
// Add points at CURRENT level
|
||||
let newScore = prev + pointsToAdd;
|
||||
foodManagerRef.current.spawnFood(
|
||||
snakeManagerRef.current.snakeBodyRef.current,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foodManagerRef.current.spawnFood(
|
||||
snakeManagerRef.current.snakeBodyRef.current,
|
||||
1
|
||||
);
|
||||
return newScore;
|
||||
});
|
||||
lastGameTickRef.current = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lastUpdateRef.current = timestamp;
|
||||
}
|
||||
|
||||
gameLoopRef.current = requestAnimationFrame(gameLoop);
|
||||
};
|
||||
renderFrame({
|
||||
snakeBody: snakeManagerRef.current.snakeBodyRef.current,
|
||||
direction: snakeManagerRef.current.directionRef.current,
|
||||
foodPositions: foodManagerRef.current.foodPositionsRef.current,
|
||||
gameOver: gameOverRef.current,
|
||||
paused: pausedRef.current,
|
||||
});
|
||||
|
||||
gameLoopRef.current = requestAnimationFrame(gameLoop);
|
||||
|
||||
return () => {
|
||||
if (gameLoopRef.current) {
|
||||
cancelAnimationFrame(gameLoopRef.current);
|
||||
gameLoopRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [gameStatus, paused]);
|
||||
|
||||
gameLoopRef.current = requestAnimationFrame(gameLoop);
|
||||
|
||||
return () => {
|
||||
if (gameLoopRef.current) {
|
||||
cancelAnimationFrame(gameLoopRef.current);
|
||||
gameLoopRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [gameStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus === "game-over") {
|
||||
if (gameStatus === "game-over" || gameStatus === "waiting") {
|
||||
if (gameLoopRef.current) {
|
||||
cancelAnimationFrame(gameLoopRef.current);
|
||||
gameLoopRef.current = null;
|
||||
@@ -156,7 +163,7 @@ export function useGameStatus(
|
||||
stopGame,
|
||||
quitGame: stopGame,
|
||||
paused,
|
||||
togglePause: () => setPaused(curr => !curr),
|
||||
togglePause,
|
||||
score,
|
||||
level,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type UseTimerReturn = {
|
||||
timeValue: number;
|
||||
@@ -8,9 +8,6 @@ export type UseTimerReturn = {
|
||||
};
|
||||
|
||||
export type UseTimerOptions = {
|
||||
/**
|
||||
* Interval (in ms) between updates. Defaults to 100ms.
|
||||
*/
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
@@ -22,16 +19,15 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
const offsetRef = useRef(0);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
function update() {
|
||||
if (startTimeRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
setTimeValue(offsetRef.current + (now - startTimeRef.current));
|
||||
}, []);
|
||||
}
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
function startTimer() {
|
||||
if (intervalIdRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -42,9 +38,9 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
|
||||
update();
|
||||
intervalIdRef.current = setInterval(update, Math.max(16, interval));
|
||||
}, [interval, update]);
|
||||
}
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
function stopTimer() {
|
||||
if (intervalIdRef.current !== null) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
@@ -55,9 +51,9 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
offsetRef.current += performance.now() - startTimeRef.current;
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [update]);
|
||||
}
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
function resetTimer() {
|
||||
if (intervalIdRef.current !== null) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
@@ -66,7 +62,7 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
startTimeRef.current = null;
|
||||
offsetRef.current = 0;
|
||||
setTimeValue(0);
|
||||
}, []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -8,18 +8,15 @@ export type UseScoreParams = {
|
||||
currentScore: number;
|
||||
};
|
||||
|
||||
export function useScore({ gameStatus, keyLog, currentScore }: UseScoreParams) {
|
||||
export function useScore({ gameStatus, keyLog }: UseScoreParams) {
|
||||
const timer = useTimer();
|
||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||
|
||||
useScoreTime({ gameStatus, timer });
|
||||
|
||||
const finalScore = gameStatus === 'game-over' || gameStatus === 'game-won' ? currentScore : null;
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
totalKeystrokes: keyLog.length,
|
||||
finalScore,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src", "global.d.ts"],
|
||||
"exclude": ["example"]
|
||||
|
||||
Reference in New Issue
Block a user