205 lines
7.6 KiB
TypeScript
205 lines
7.6 KiB
TypeScript
import { useRef, useEffect, useState } from 'react';
|
|
import type { GameState } from '../../lib/snakeAI/game';
|
|
import { createGame, step, getInputs } from '../../lib/snakeAI/game';
|
|
import { getAction, type Network } from '../../lib/snakeAI/network';
|
|
|
|
interface SnakeCanvasProps {
|
|
network: Network | null;
|
|
gridSize: number;
|
|
showGrid?: boolean;
|
|
size?: 'small' | 'normal' | 'large';
|
|
showStats?: boolean; // Show score/length/steps even in small mode
|
|
playbackSpeed?: number; // Steps per second (default: 15)
|
|
}
|
|
|
|
const CELL_SIZES = {
|
|
small: 8,
|
|
normal: 20,
|
|
large: 30,
|
|
};
|
|
|
|
const CANVAS_PADDING = 10;
|
|
|
|
export default function SnakeCanvas({ network, gridSize, showGrid = true, size = 'normal', showStats = false, playbackSpeed = 15 }: SnakeCanvasProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [currentGame, setCurrentGame] = useState<GameState | null>(null);
|
|
const animationFrameRef = useRef<number>();
|
|
const lastUpdateRef = useRef<number>(0);
|
|
const networkRef = useRef(network);
|
|
|
|
useEffect(() => {
|
|
networkRef.current = network;
|
|
}, [network]);
|
|
|
|
const CELL_SIZE = CELL_SIZES[size];
|
|
|
|
// Initialize game when network or gridSize changes
|
|
useEffect(() => {
|
|
if (network) {
|
|
setCurrentGame(createGame(gridSize));
|
|
}
|
|
}, [network?.id, gridSize]);
|
|
|
|
// Animation loop to step through game
|
|
useEffect(() => {
|
|
if (!network || !currentGame) return;
|
|
|
|
const STEPS_PER_SECOND = playbackSpeed; // Use prop
|
|
const UPDATE_INTERVAL = 1000 / STEPS_PER_SECOND;
|
|
|
|
const animate = (timestamp: number) => {
|
|
const elapsed = timestamp - lastUpdateRef.current;
|
|
|
|
if (elapsed >= UPDATE_INTERVAL) {
|
|
setCurrentGame((prevGame) => {
|
|
if (!prevGame) return prevGame;
|
|
|
|
// If game is over, start a new one
|
|
if (!prevGame.alive) {
|
|
return createGame(gridSize);
|
|
}
|
|
|
|
// Get neural network decision
|
|
const currentNetwork = networkRef.current;
|
|
if (!currentNetwork) return prevGame;
|
|
|
|
const inputs = getInputs(prevGame);
|
|
const action = getAction(currentNetwork, inputs);
|
|
|
|
// Step the game forward
|
|
return step(prevGame, action);
|
|
});
|
|
|
|
lastUpdateRef.current = timestamp;
|
|
}
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
};
|
|
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
|
return () => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
}
|
|
};
|
|
}, [network?.id, !!currentGame, gridSize, playbackSpeed]); // Added playbackSpeed dependency
|
|
|
|
// Set canvas size once when props change (not on every render)
|
|
useEffect(() => {
|
|
if (!canvasRef.current) return;
|
|
const canvas = canvasRef.current;
|
|
const canvasSize = gridSize * CELL_SIZE + CANVAS_PADDING * 2;
|
|
canvas.width = canvasSize;
|
|
canvas.height = canvasSize;
|
|
}, [gridSize, size]);
|
|
|
|
// Render game to canvas
|
|
useEffect(() => {
|
|
if (!currentGame || !canvasRef.current) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const canvasSize = gridSize * CELL_SIZE + CANVAS_PADDING * 2;
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = '#000000';
|
|
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
|
|
|
// Draw grid
|
|
if (showGrid) {
|
|
ctx.strokeStyle = '#1a1a1a';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= currentGame.gridSize; i++) {
|
|
const pos = i * CELL_SIZE + CANVAS_PADDING;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pos, CANVAS_PADDING);
|
|
ctx.lineTo(pos, currentGame.gridSize * CELL_SIZE + CANVAS_PADDING);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(CANVAS_PADDING, pos);
|
|
ctx.lineTo(currentGame.gridSize * CELL_SIZE + CANVAS_PADDING, pos);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Draw food
|
|
ctx.fillStyle = '#ff6b6b';
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowColor = '#ff6b6b';
|
|
const foodX = currentGame.food.x * CELL_SIZE + CANVAS_PADDING + CELL_SIZE / 2;
|
|
const foodY = currentGame.food.y * CELL_SIZE + CANVAS_PADDING + CELL_SIZE / 2;
|
|
ctx.beginPath();
|
|
ctx.arc(foodX, foodY, CELL_SIZE / 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw snake
|
|
currentGame.snake.forEach((segment, index) => {
|
|
const x = segment.x * CELL_SIZE + CANVAS_PADDING;
|
|
const y = segment.y * CELL_SIZE + CANVAS_PADDING;
|
|
|
|
if (index === 0) {
|
|
// Head - brighter and larger
|
|
ctx.fillStyle = currentGame.alive ? '#4ecdc4' : '#e74c3c';
|
|
ctx.shadowBlur = 10;
|
|
ctx.shadowColor = currentGame.alive ? '#4ecdc4' : '#e74c3c';
|
|
} else {
|
|
// Body - gradient from head to tail
|
|
const alpha = 1 - (index / currentGame.snake.length) * 0.5;
|
|
ctx.fillStyle = `rgba(78, 205, 196, ${alpha})`;
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
ctx.fillRect(x + 2, y + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
|
});
|
|
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw death overlay if dead
|
|
if (!currentGame.alive) {
|
|
ctx.fillStyle = 'rgba(231, 76, 60, 0.2)';
|
|
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
|
|
|
ctx.fillStyle = '#e74c3c';
|
|
ctx.font = `bold ${size === 'small' ? '16' : '24'}px 'Courier New', monospace`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('DEAD', canvasSize / 2, canvasSize / 2);
|
|
}
|
|
}, [currentGame, showGrid]);
|
|
|
|
// Calculate canvas size from props, not state, to prevent shaking
|
|
const canvasSize = gridSize * CELL_SIZE + CANVAS_PADDING * 2;
|
|
|
|
return (
|
|
<div className="snake-canvas-container">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={canvasSize}
|
|
height={canvasSize}
|
|
style={{
|
|
width: `${canvasSize}px`,
|
|
height: `${canvasSize}px`,
|
|
border: '1px solid #333',
|
|
backgroundColor: '#000000',
|
|
}}
|
|
/>
|
|
{currentGame && (size !== 'small' || showStats) && (
|
|
<div className="canvas-info" style={size === 'small' ? { fontSize: '0.7rem', gap: '0.5rem', padding: '0.4rem 0.8rem' } : {}}>
|
|
<div className="info-item">
|
|
<span className="label">Score:</span>
|
|
<span className="value" style={{ fontVariantNumeric: 'tabular-nums', minWidth: '2ch', display: 'inline-block' }}>{currentGame.score}</span>
|
|
</div>
|
|
<div className="info-item">
|
|
<span className="label">Steps:</span>
|
|
<span className="value" style={{ fontVariantNumeric: 'tabular-nums', minWidth: '3ch', display: 'inline-block' }}>{currentGame.steps}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|