From 2757d54dbbda1efe3f6debf1c19227501fd44760 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Fri, 19 Dec 2025 02:31:49 +0000 Subject: [PATCH] html5 canvas instead of DOM --- src/game.css | 153 +------ src/types.ts | 4 +- src/useBoard/CanvasRenderer.ts | 623 +++++++++++++++++++++++++++++ src/useBoard/index.ts | 2 + src/useBoard/useBoard.ts | 68 +++- src/useCursor/useCursor.ts | 180 +++------ src/useGame/useFood.ts | 64 +-- src/useGame/useGame.ts | 48 ++- src/useGameStatus/useGameStatus.ts | 147 +++---- src/useScore/hooks/useTimer.ts | 22 +- 10 files changed, 871 insertions(+), 440 deletions(-) create mode 100644 src/useBoard/CanvasRenderer.ts diff --git a/src/game.css b/src/game.css index 5fa79f3..98a5105 100755 --- a/src/game.css +++ b/src/game.css @@ -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; -} - diff --git a/src/types.ts b/src/types.ts index 3c32da9..007328d 100755 --- a/src/types.ts +++ b/src/types.ts @@ -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; directionRef: MutableRefObject; initSnake: () => void; clearSnake: () => void; + applyBufferedDirection: () => void; moveSnake: (grow?: boolean) => 'continue' | 'wall-collision' | 'self-collision'; - renderSnake: (body: SnakeBody) => void; }; // ============================================================================ diff --git a/src/useBoard/CanvasRenderer.ts b/src/useBoard/CanvasRenderer.ts new file mode 100644 index 0000000..c1ccb6b --- /dev/null +++ b/src/useBoard/CanvasRenderer.ts @@ -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 = {} + ) { + 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; +}; diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts index f381252..4d6425f 100644 --- a/src/useBoard/index.ts +++ b/src/useBoard/index.ts @@ -1 +1,3 @@ export * from './useBoard'; +export { CanvasRenderer } from './CanvasRenderer'; +export type { RenderState, CanvasTheme } from './CanvasRenderer'; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index a111752..b7abefc 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -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(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(null); + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const lastFrameTimeRef = useRef(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, }; } diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index b478df4..bffe7c2 100755 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -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([]); const snakeBodyRef = useRef([]); const [direction, setDirection] = useState("up"); const directionRef = useRef("up"); const lastMovedDirectionRef = useRef("up"); const nextDirectionRef = useRef(null); + const lastMotionRef = useRef(null); + + 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 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 }); - } - 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(); - - // 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 @@ -167,73 +108,66 @@ export function useCursor( return "self-collision"; } - const newBody = grow + const newBody = grow ? [newHead, ...body] : [newHead, ...body.slice(0, -1)]; lastMovedDirectionRef.current = dir; - + 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(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, }; } diff --git a/src/useGame/useFood.ts b/src/useGame/useFood.ts index b9166c3..56d4bfa 100755 --- a/src/useGame/useFood.ts +++ b/src/useGame/useFood.ts @@ -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([]); const foodPositionsRef = useRef([]); - 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, }; } diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 7f8ae8c..168ebe5 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -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, }; diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index 8942a04..7820d2b 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -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("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(null); - const lastUpdateRef = useRef(0); + const lastGameTickRef = useRef(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; - - // 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; - } + if (willLevelUp && levelRef.current < maxLevel) { + const newLevel = levelRef.current + 1; + levelRef.current = newLevel; + setLevel(newLevel); + foodsEatenRef.current = 0; + } - setScore((prev) => { - // Add points at CURRENT level - let newScore = prev + pointsToAdd; + setScore((prev) => prev + pointsToAdd); - foodManagerRef.current.spawnFood( - snakeManagerRef.current.snakeBodyRef.current, - 1 - ); - return newScore; - }); + foodManagerRef.current.spawnFood( + snakeManagerRef.current.snakeBodyRef.current, + 1 + ); + } + } - } + 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, }; diff --git a/src/useScore/hooks/useTimer.ts b/src/useScore/hooks/useTimer.ts index 44b0e9e..24cd311 100644 --- a/src/useScore/hooks/useTimer.ts +++ b/src/useScore/hooks/useTimer.ts @@ -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(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 () => {