Initial commit
This commit is contained in:
196
src/apps/SnakeAI/SnakeCanvas.tsx
Normal file
196
src/apps/SnakeAI/SnakeCanvas.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 }: SnakeCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [currentGame, setCurrentGame] = useState<GameState | null>(null);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const lastUpdateRef = useRef<number>(0);
|
||||
|
||||
const CELL_SIZE = CELL_SIZES[size];
|
||||
|
||||
// Initialize game when network or gridSize changes
|
||||
useEffect(() => {
|
||||
if (network) {
|
||||
setCurrentGame(createGame(gridSize));
|
||||
}
|
||||
}, [network, gridSize]);
|
||||
|
||||
// Animation loop to step through game
|
||||
useEffect(() => {
|
||||
if (!network || !currentGame) return;
|
||||
|
||||
const STEPS_PER_SECOND = 10; // Speed of game playback
|
||||
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 inputs = getInputs(prevGame);
|
||||
const action = getAction(network, 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, currentGame, gridSize]);
|
||||
|
||||
// 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 = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
||||
|
||||
// Draw grid
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = '#2a2a3e';
|
||||
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 Inter, sans-serif`;
|
||||
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: '2px solid #3a3a4e',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#1a1a2e',
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user