Files
evolution/src/apps/SnakeAI/SnakeCanvas.tsx
2026-01-10 12:18:54 +11:00

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>
);
}