snake-reactor / src /App.jsx
underrate's picture
Overhauled UI
232d21d verified
Raw
History Blame Contribute Delete
24.2 kB
import { useCallback, useEffect, useRef, useState } from 'react';
import './App.css';
const CELL_SIZE = 20;
const DEFAULT_CANVAS_SIZE = 400; // Default canvas size for initial state
const SPECIAL_EVERY = 5;
const SPECIAL_BONUS = 5;
const SPECIAL_DURATION_MS = 5000;
function getSpeedForLevel(level) {
const clamped = Math.min(5, Math.max(1, level));
return 170 - (clamped - 1) * 25;
}
export default function App() {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const gameLoopRef = useRef(null);
const snakeRef = useRef([]);
const foodRef = useRef({ x: 0, y: 0 });
const specialFoodRef = useRef(null);
const specialExpiresAtRef = useRef(0);
const directionRef = useRef({ dx: 1, dy: 0 });
const foodsEatenRef = useRef(0);
const scoreRef = useRef(0);
const lastProcessedDirectionRef = useRef({ dx: 1, dy: 0 });
const isPausedRef = useRef(false);
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
const [gridWidth, setGridWidth] = useState(Math.floor(DEFAULT_CANVAS_SIZE / CELL_SIZE));
const [gridHeight, setGridHeight] = useState(Math.floor(DEFAULT_CANVAS_SIZE / CELL_SIZE));
const [score, setScore] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [highScore, setHighScore] = useState(() => {
return Number(localStorage.getItem('snakeHighList')) || 0;
});
const [level, setLevel] = useState('1');
const [startTitle, setStartTitle] = useState('Snake Game');
const [finalScore, setFinalScore] = useState(0);
const [showStartScreen, setShowStartScreen] = useState(true);
const [scoreUpdated, setScoreUpdated] = useState(false);
const [gameOverShake, setGameOverShake] = useState(false);
const [coinDropping, setCoinDropping] = useState(false);
// Touch control refs
const touchStartRef = useRef({ x: 0, y: 0 });
const touchStartTimeRef = useRef(0);
const placeFood = useCallback(() => {
let candidate;
do {
candidate = {
x: Math.floor(Math.random() * gridWidth),
y: Math.floor(Math.random() * gridHeight),
};
} while (
snakeRef.current.some(
(segment) => segment.x === candidate.x && segment.y === candidate.y
) ||
(specialFoodRef.current &&
specialFoodRef.current.x === candidate.x &&
specialFoodRef.current.y === candidate.y)
);
return candidate;
}, [gridWidth, gridHeight]);
const placeSpecialFood = useCallback(() => {
let candidate;
do {
candidate = {
x: Math.floor(Math.random() * gridWidth),
y: Math.floor(Math.random() * gridHeight),
};
} while (
snakeRef.current.some(
(segment) => segment.x === candidate.x && segment.y === candidate.y
) ||
(foodRef.current &&
foodRef.current.x === candidate.x &&
foodRef.current.y === candidate.y)
);
return candidate;
}, [gridWidth, gridHeight]);
// CRT-style background with subtle grid
const drawBackground = useCallback((ctx) => {
// Dark CRT background
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, canvasSize, canvasSize);
// Subtle grid lines (very faint)
ctx.strokeStyle = 'rgba(0, 255, 247, 0.03)';
ctx.lineWidth = 1;
for (let i = 0; i <= gridWidth; i++) {
ctx.beginPath();
ctx.moveTo(i * CELL_SIZE, 0);
ctx.lineTo(i * CELL_SIZE, canvasSize);
ctx.stroke();
}
for (let i = 0; i <= gridHeight; i++) {
ctx.beginPath();
ctx.moveTo(0, i * CELL_SIZE);
ctx.lineTo(canvasSize, i * CELL_SIZE);
ctx.stroke();
}
}, [canvasSize, gridWidth, gridHeight]);
// Phosphor green snake with glow
const drawSnake = useCallback((ctx) => {
const snake = snakeRef.current;
const { dx, dy } = directionRef.current;
snake.forEach((segment, index) => {
const x = segment.x * CELL_SIZE;
const y = segment.y * CELL_SIZE;
// Glow effect for head
if (index === 0) {
ctx.shadowColor = '#00ff00';
ctx.shadowBlur = 10;
} else {
ctx.shadowBlur = 0;
}
// Phosphor green color - head is brighter
ctx.fillStyle = index === 0 ? '#00ff00' : '#00cc00';
ctx.fillRect(x + 1, y + 1, CELL_SIZE - 2, CELL_SIZE - 2);
// Reset shadow
ctx.shadowBlur = 0;
// Draw eyes on head
if (index === 0) {
ctx.fillStyle = '#000';
let eye1X, eye1Y, eye2X, eye2Y;
if (dx === 1) {
eye1X = x + 12; eye1Y = y + 5;
eye2X = x + 12; eye2Y = y + 12;
} else if (dx === -1) {
eye1X = x + 5; eye1Y = y + 5;
eye2X = x + 5; eye2Y = y + 12;
} else if (dy === 1) {
eye1X = x + 5; eye1Y = y + 12;
eye2X = x + 12; eye2Y = y + 12;
} else {
eye1X = x + 5; eye1Y = y + 5;
eye2X = x + 12; eye2Y = y + 5;
}
ctx.fillRect(eye1X, eye1Y, 3, 3);
ctx.fillRect(eye2X, eye2Y, 3, 3);
}
});
}, []);
// Food with neon glow
const drawFood = useCallback((ctx) => {
const food = foodRef.current;
const x = food.x * CELL_SIZE + CELL_SIZE / 2;
const y = food.y * CELL_SIZE + CELL_SIZE / 2;
// Glow effect
ctx.shadowColor = '#ff6600';
ctx.shadowBlur = 15;
ctx.fillStyle = '#ff6600';
ctx.beginPath();
ctx.arc(x, y, CELL_SIZE / 2 - 2, 0, Math.PI * 2);
ctx.fill();
// Reset shadow
ctx.shadowBlur = 0;
}, []);
// Special food with cyan glow
const drawSpecialFood = useCallback((ctx) => {
const specialFood = specialFoodRef.current;
if (!specialFood) return;
const x = specialFood.x * CELL_SIZE + CELL_SIZE / 2;
const y = specialFood.y * CELL_SIZE + CELL_SIZE / 2;
// Pulsing glow effect
const pulse = Math.sin(Date.now() / 100) * 0.3 + 0.7;
ctx.shadowColor = '#00fff7';
ctx.shadowBlur = 20 * pulse;
ctx.fillStyle = '#00fff7';
ctx.beginPath();
ctx.arc(x, y, CELL_SIZE / 2 - 2, 0, Math.PI * 2);
ctx.fill();
// Reset shadow
ctx.shadowBlur = 0;
}, []);
const drawFrame = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
drawBackground(ctx);
drawFood(ctx);
drawSpecialFood(ctx);
drawSnake(ctx);
}, [drawBackground, drawFood, drawSnake, drawSpecialFood]);
const resetGame = useCallback(() => {
const startX = Math.floor(gridWidth / 2);
const startY = Math.floor(gridHeight / 2);
snakeRef.current = [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
];
directionRef.current = { dx: 1, dy: 0 };
lastProcessedDirectionRef.current = { dx: 1, dy: 0 };
scoreRef.current = 0;
foodsEatenRef.current = 0;
specialFoodRef.current = null;
specialExpiresAtRef.current = 0;
setScore(0);
setScoreUpdated(false);
foodRef.current = placeFood();
drawFrame();
}, [drawFrame, placeFood, gridWidth, gridHeight]);
const endGame = useCallback(() => {
if (gameLoopRef.current !== null) {
clearInterval(gameLoopRef.current);
gameLoopRef.current = null;
}
setGameOverShake(true);
setTimeout(() => setGameOverShake(false), 500);
setStartTitle('Game Over');
setFinalScore(scoreRef.current);
if (scoreRef.current > highScore) {
setHighScore(scoreRef.current);
localStorage.setItem('snakeHighList', scoreRef.current);
}
setShowStartScreen(true);
}, [highScore]);
const update = useCallback(() => {
if (isPausedRef.current) return;
const snake = snakeRef.current;
const { dx, dy } = directionRef.current;
if (snake.length === 0) return;
lastProcessedDirectionRef.current = directionRef.current;
const head = { x: snake[0].x + dx, y: snake[0].y + dy };
if (
specialFoodRef.current &&
Date.now() > specialExpiresAtRef.current
) {
specialFoodRef.current = null;
specialExpiresAtRef.current = 0;
}
if (head.x < 0 || head.x >= gridWidth || head.y < 0 || head.y >= gridHeight) {
endGame();
return;
}
if (snake.some((segment) => segment.x === head.x && segment.y === head.y)) {
endGame();
return;
}
snake.unshift(head);
if (head.x === foodRef.current.x && head.y === foodRef.current.y) {
scoreRef.current += 1;
foodsEatenRef.current += 1;
setScore(scoreRef.current);
setScoreUpdated(true);
setTimeout(() => setScoreUpdated(false), 300);
foodRef.current = placeFood();
if (foodsEatenRef.current % SPECIAL_EVERY === 0) {
specialFoodRef.current = placeSpecialFood();
specialExpiresAtRef.current = Date.now() + SPECIAL_DURATION_MS;
}
} else {
snake.pop();
}
if (
specialFoodRef.current &&
head.x === specialFoodRef.current.x &&
head.y === specialFoodRef.current.y
) {
scoreRef.current += SPECIAL_BONUS;
setScore(scoreRef.current);
setScoreUpdated(true);
setTimeout(() => setScoreUpdated(false), 300);
specialFoodRef.current = null;
specialExpiresAtRef.current = 0;
}
drawFrame();
}, [drawFrame, endGame, placeFood, placeSpecialFood, gridWidth, gridHeight]);
const startGame = useCallback(() => {
if (gameLoopRef.current !== null) {
clearInterval(gameLoopRef.current);
}
setStartTitle('Snake Game');
setFinalScore(0);
resetGame();
isPausedRef.current = false;
setIsPaused(false);
const speed = getSpeedForLevel(Number(level));
gameLoopRef.current = setInterval(update, speed);
setShowStartScreen(false);
}, [level, resetGame, update]);
// Coin slot animation
const handleCoinClick = useCallback(() => {
if (coinDropping) return;
setCoinDropping(true);
setTimeout(() => setCoinDropping(false), 600);
}, [coinDropping]);
// Handle canvas resize based on container
useEffect(() => {
const updateCanvasSize = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
const containerHeight = containerRef.current.clientHeight;
// Use the smaller dimension to maintain square aspect ratio
// but maximize the use of available space
const size = Math.min(containerWidth, containerHeight);
// Round down to nearest multiple of CELL_SIZE for clean grid
const roundedSize = Math.floor(size / CELL_SIZE) * CELL_SIZE;
// Ensure minimum size (10 cells)
const newSize = Math.max(roundedSize, CELL_SIZE * 10);
setCanvasSize(prevSize => {
if (prevSize !== newSize) {
// Grid dimensions changed - reset game state to prevent out-of-bounds issues
const newGridWidth = Math.floor(newSize / CELL_SIZE);
const newGridHeight = Math.floor(newSize / CELL_SIZE);
// Reposition snake to center of new grid if it's outside bounds
const snake = snakeRef.current;
if (snake.length > 0) {
const maxX = newGridWidth - 1;
const maxY = newGridHeight - 1;
const head = snake[0];
// Check if snake head is out of bounds
if (head.x > maxX || head.y > maxY) {
// Reset snake to center of new grid
const startX = Math.floor(newGridWidth / 2);
const startY = Math.floor(newGridHeight / 2);
snakeRef.current = [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
];
directionRef.current = { dx: 1, dy: 0 };
lastProcessedDirectionRef.current = { dx: 1, dy: 0 };
}
}
// Reposition food if out of bounds
if (foodRef.current.x >= newGridWidth || foodRef.current.y >= newGridHeight) {
foodRef.current = {
x: Math.floor(Math.random() * newGridWidth),
y: Math.floor(Math.random() * newGridHeight),
};
}
// Remove special food if out of bounds
if (specialFoodRef.current &&
(specialFoodRef.current.x >= newGridWidth || specialFoodRef.current.y >= newGridHeight)) {
specialFoodRef.current = null;
specialExpiresAtRef.current = 0;
}
setGridWidth(newGridWidth);
setGridHeight(newGridHeight);
}
return newSize;
});
}
};
// Initial size calculation with a small delay to ensure container is rendered
const initialTimer = setTimeout(updateCanvasSize, 100);
// Use ResizeObserver for more accurate container size detection
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
// Also update on window resize
window.addEventListener('resize', updateCanvasSize);
return () => {
clearTimeout(initialTimer);
resizeObserver.disconnect();
window.removeEventListener('resize', updateCanvasSize);
};
}, []);
useEffect(() => {
resetGame();
return () => {
if (gameLoopRef.current !== null) {
clearInterval(gameLoopRef.current);
}
};
}, [resetGame]);
useEffect(() => {
const onKeyDown = (event) => {
const key = event.key.toLowerCase();
const { dx, dy } = lastProcessedDirectionRef.current;
switch (key) {
case 'arrowup':
case 'w':
if (dy === 0) {
directionRef.current = { dx: 0, dy: -1 };
}
event.preventDefault();
break;
case 'arrowdown':
case 's':
if (dy === 0) {
directionRef.current = { dx: 0, dy: 1 };
}
event.preventDefault();
break;
case 'arrowleft':
case 'a':
if (dx === 0) {
directionRef.current = { dx: -1, dy: 0 };
}
event.preventDefault();
break;
case 'arrowright':
case 'd':
if (dx === 0) {
directionRef.current = { dx: 1, dy: 0 };
}
event.preventDefault();
break;
case ' ':
case 'escape':
if (gameLoopRef.current) {
isPausedRef.current = !isPausedRef.current;
setIsPaused((prev) => !prev);
event.preventDefault();
}
break;
default:
break;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
// Touch swipe handling
const handleTouchStart = useCallback((e) => {
const touch = e.touches[0];
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
touchStartTimeRef.current = Date.now();
}, []);
const handleTouchMove = useCallback((e) => {
e.preventDefault();
}, []);
const handleTouchEnd = useCallback((e) => {
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
const touchDuration = Date.now() - touchStartTimeRef.current;
const minSwipeDistance = 30;
const maxTouchDuration = 300;
if (Math.abs(dx) < minSwipeDistance && Math.abs(dy) < minSwipeDistance) return;
if (touchDuration > maxTouchDuration) return;
const lastDir = lastProcessedDirectionRef.current;
if (Math.abs(dx) > Math.abs(dy)) {
if (dx > 0 && lastDir.dx === 0) {
directionRef.current = { dx: 1, dy: 0 };
} else if (dx < 0 && lastDir.dx === 0) {
directionRef.current = { dx: -1, dy: 0 };
}
} else {
if (dy > 0 && lastDir.dy === 0) {
directionRef.current = { dx: 0, dy: 1 };
} else if (dy < 0 && lastDir.dy === 0) {
directionRef.current = { dx: 0, dy: -1 };
}
}
}, []);
// Direction button handler for on-screen controls
const handleDirectionButton = useCallback((dx, dy) => {
const lastDir = lastProcessedDirectionRef.current;
if (dx !== 0 && lastDir.dx === 0) {
directionRef.current = { dx, dy: 0 };
} else if (dy !== 0 && lastDir.dy === 0) {
directionRef.current = { dx: 0, dy };
}
}, []);
return (
<main className="arcade-cabinet">
<div className="cabinet-inner">
{/* Marquee Area */}
<header className="marquee">
<h1 className="marquee-title">Snake Reactor</h1>
<p className="marquee-subtitle">Arcade Edition</p>
</header>
{/* Decorative Rivets */}
<div className="rivets-row">
<div className="rivet"></div>
<div className="rivet"></div>
<div className="rivet"></div>
</div>
{/* LED Score Display */}
<div className="led-display-panel">
<div className="led-display">
<span className="led-label">Score</span>
<span className="led-value">{String(score).padStart(4, '0')}</span>
</div>
<div className="led-display">
<span className="led-label">Level</span>
<span className="led-value highlight">{level}</span>
</div>
<div className="led-display">
<span className="led-label">High</span>
<span className="led-value">{String(Math.max(score, highScore)).padStart(4, '0')}</span>
</div>
</div>
{/* CRT Screen Area */}
<section className={`screen-bezel ${gameOverShake ? 'game-over-shake' : ''}`}>
<div ref={containerRef} className="crt-screen">
{showStartScreen && (
<div className="game-overlay">
<h2 className={startTitle === 'Game Over' ? 'game-over' : ''}>{startTitle}</h2>
{startTitle === 'Game Over' && (
<div className="final-score-box">
<p className="final-score">Score: {finalScore}</p>
{finalScore >= highScore && finalScore > 0 && (
<p className="new-high-score">New High Score!</p>
)}
</div>
)}
<label htmlFor="levelSelect">Select Level</label>
<select
id="levelSelect"
className="level-select"
value={level}
onChange={(event) => setLevel(event.target.value)}
>
<option value="1">Level 1 - Slow</option>
<option value="2">Level 2</option>
<option value="3">Level 3</option>
<option value="4">Level 4</option>
<option value="5">Level 5 - Fast</option>
</select>
<button type="button" className="start-btn" onClick={startGame}>
Start Game
</button>
</div>
)}
{isPaused && !showStartScreen && (
<div className="game-overlay">
<h2>Paused</h2>
<button
type="button"
className="start-btn"
onClick={() => {
isPausedRef.current = false;
setIsPaused(false);
}}
>
Resume
</button>
</div>
)}
<canvas
ref={canvasRef}
width={canvasSize}
height={canvasSize}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
</div>
</section>
{/* Control Panel */}
<div className="control-panel">
{/* Coin Slot */}
<div className="coin-slot-area">
<div className="coin-slot">
<span className="coin-slot-label">Insert Coin</span>
<div
className={`coin-slot-opening ${coinDropping ? 'dropping' : ''}`}
onClick={handleCoinClick}
>
<div className="coin"></div>
</div>
</div>
{/* Power Switch */}
<div className="power-switch-container">
<span className="power-switch-label">Power</span>
<div className="power-switch"></div>
</div>
{/* Volume Knob */}
<div className="volume-control">
<div className="volume-knob"></div>
<span className="volume-knob-label">Volume</span>
</div>
</div>
{/* LED Indicators */}
<div className="led-indicator-row">
<div className="led-indicator"></div>
<div className="led-indicator green"></div>
<div className="led-indicator yellow"></div>
</div>
{/* Start Button */}
{!showStartScreen && (
<div className="pause-btn-container">
<button
type="button"
className="start-btn"
onClick={() => {
isPausedRef.current = !isPausedRef.current;
setIsPaused((prev) => !prev);
}}
>
{isPaused ? 'Resume' : 'Pause'}
</button>
</div>
)}
{/* Direction Controls */}
<div className="direction-controls">
<div className="arcade-btn-bezel">
<button
type="button"
className="arcade-btn red"
onPointerDown={() => handleDirectionButton(0, -1)}
aria-label="Move up"
>
</button>
</div>
<div className="control-row">
<div className="arcade-btn-bezel">
<button
type="button"
className="arcade-btn yellow"
onPointerDown={() => handleDirectionButton(-1, 0)}
aria-label="Move left"
>
</button>
</div>
<div className="arcade-btn-bezel">
<button
type="button"
className="arcade-btn red"
onPointerDown={() => handleDirectionButton(0, 1)}
aria-label="Move down"
>
</button>
</div>
<div className="arcade-btn-bezel">
<button
type="button"
className="arcade-btn yellow"
onPointerDown={() => handleDirectionButton(1, 0)}
aria-label="Move right"
>
</button>
</div>
</div>
</div>
</div>
{/* Brand Plaque */}
<div className="brand-plaque">
<span className="brand-plaque-text">ARCADE SYSTEMS © 1985</span>
</div>
{/* Speaker Grille */}
<div className="speaker-grille">
<div className="speaker-holes">
{[...Array(24)].map((_, i) => (
<div key={i} className="speaker-hole"></div>
))}
</div>
</div>
{/* Cabinet Vents */}
<div className="cabinet-vents">
<div className="vent-slot"></div>
<div className="vent-slot"></div>
<div className="vent-slot"></div>
</div>
{/* Cabinet Screws */}
<div className="cabinet-screws">
<div className="screw"></div>
<div className="screw"></div>
</div>
</div>
</main>
);
}