html5 canvas instead of DOM
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user