Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |