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(null); const [currentGame, setCurrentGame] = useState(null); const animationFrameRef = useRef(); const lastUpdateRef = useRef(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 (
{currentGame && (size !== 'small' || showStats) && (
Score: {currentGame.score}
Steps: {currentGame.steps}
)}
); }