html5 canvas instead of DOM

This commit is contained in:
2025-12-19 02:31:49 +00:00
parent 41473f3b24
commit 2757d54dbb
10 changed files with 871 additions and 440 deletions
+7 -146
View File
@@ -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
View File
@@ -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;
};
// ============================================================================
+623
View File
@@ -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;
};
+2
View File
@@ -1 +1,3 @@
export * from './useBoard';
export { CanvasRenderer } from './CanvasRenderer';
export type { RenderState, CanvasTheme } from './CanvasRenderer';
+47 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+77 -70
View File
@@ -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,
};
+9 -13
View File
@@ -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 () => {