hidden-grid/components/Game.tsx

315 lines
13 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { GridCell, Position, GameState, Direction } from '../types';
import { generateGrid, GRID_SIZE } from '../utils/gameLogic';
import { Cell } from './Cell';
import { RotateCcw, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, CircleHelp, BrickWall, Zap, Flag } from 'lucide-react';
const MOVE_INTERVAL_MS = 100; // Speed of movement when holding key
export const Game: React.FC = () => {
const [grid, setGrid] = useState<GridCell[][]>([]);
const [playerPos, setPlayerPos] = useState<Position>({ x: 0, y: 0 });
const [gameState, setGameState] = useState<GameState>(GameState.PLAYING);
const [moves, setMoves] = useState(0);
const [trapsTriggered, setTrapsTriggered] = useState(0);
// Refs for smooth movement loop and instant state access
const gridRef = useRef<GridCell[][]>([]);
const playerPosRef = useRef<Position>({ x: 0, y: 0 });
const activeDirectionRef = useRef<Direction | null>(null);
const lastMoveTimeRef = useRef<number>(0);
const requestRef = useRef<number>();
const gameStateRef = useRef<GameState>(GameState.PLAYING);
// Sync refs with state whenever state updates
useEffect(() => { gridRef.current = grid; }, [grid]);
useEffect(() => { playerPosRef.current = playerPos; }, [playerPos]);
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
// Initialize game
useEffect(() => {
startNewGame();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startNewGame = () => {
const newGrid = generateGrid();
setGrid(newGrid);
gridRef.current = newGrid;
const startPos = { x: 0, y: 0 };
setPlayerPos(startPos);
playerPosRef.current = startPos;
setGameState(GameState.PLAYING);
gameStateRef.current = GameState.PLAYING;
setMoves(0);
setTrapsTriggered(0);
activeDirectionRef.current = null;
};
const handleInteraction = (direction: Direction) => {
// Access latest state directly via refs
const currentGrid = gridRef.current;
const currentPos = playerPosRef.current;
if (!currentGrid.length || gameStateRef.current !== GameState.PLAYING) return;
let nextX = currentPos.x;
let nextY = currentPos.y;
switch (direction) {
case Direction.UP: nextY -= 1; break;
case Direction.DOWN: nextY += 1; break;
case Direction.LEFT: nextX -= 1; break;
case Direction.RIGHT: nextX += 1; break;
}
// 1. Boundary Check
if (nextX < 0 || nextX >= GRID_SIZE || nextY < 0 || nextY >= GRID_SIZE) {
return; // Hit grid edge
}
// 2. Interaction Logic
// Create a deep copy of the grid to ensure immutability
const newGrid = currentGrid.map(row => row.map(cell => ({ ...cell })));
const targetCell = newGrid[nextY][nextX];
const targetType = targetCell.type;
let gridChanged = false;
let newPos = currentPos;
// Reveal the cell
if (!targetCell.revealed) {
targetCell.revealed = true;
gridChanged = true;
}
if (targetType === 'wall') {
// Blocked: Do not move, but wall is revealed
} else if (targetType === 'trap') {
// Trap: Reset to start
setTrapsTriggered(t => t + 1);
newPos = { x: 0, y: 0 };
} else {
// Safe / Start / End
newPos = { x: nextX, y: nextY };
targetCell.steppedOn = true;
gridChanged = true;
if (targetType === 'end') {
setGameState(GameState.WON);
}
}
// Update States
if (gridChanged) {
setGrid(newGrid);
}
if (newPos.x !== currentPos.x || newPos.y !== currentPos.y) {
setPlayerPos(newPos);
setMoves(m => m + 1);
} else if (targetType === 'trap') {
// Ensure visual reset even if we were somehow already at 0,0
setPlayerPos({ x: 0, y: 0 });
}
};
// Game Loop
const animate = (time: number) => {
if (activeDirectionRef.current && gameStateRef.current === GameState.PLAYING) {
if (time - lastMoveTimeRef.current > MOVE_INTERVAL_MS) {
handleInteraction(activeDirectionRef.current);
lastMoveTimeRef.current = time;
}
}
requestRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
};
}, []);
// Key Listeners
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (gameStateRef.current !== GameState.PLAYING) return;
let dir: Direction | null = null;
switch (e.key) {
case 'ArrowUp': case 'w': case 'W': dir = Direction.UP; break;
case 'ArrowDown': case 's': case 'S': dir = Direction.DOWN; break;
case 'ArrowLeft': case 'a': case 'A': dir = Direction.LEFT; break;
case 'ArrowRight': case 'd': case 'D': dir = Direction.RIGHT; break;
}
if (dir) {
e.preventDefault(); // Prevent scrolling
if (activeDirectionRef.current !== dir) {
// Initial immediate move on press
activeDirectionRef.current = dir;
handleInteraction(dir);
lastMoveTimeRef.current = performance.now();
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
activeDirectionRef.current = null;
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-gray-950">
{/* Header */}
<header className="mb-4 flex flex-col items-center gap-2">
<h1 className="text-3xl font-bold text-blue-400 tracking-wider">GRID EXPLORER</h1>
<div className="flex gap-6 text-sm text-gray-400 bg-gray-900/50 px-6 py-2 rounded-full border border-gray-800">
<div className="flex items-center gap-2">
<span className="text-gray-500">Moves:</span>
<span className="text-white font-mono">{moves}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-500/70">Traps:</span>
<span className="text-white font-mono">{trapsTriggered}</span>
</div>
</div>
</header>
{/* Main Game Area */}
<div className="relative p-1 bg-gray-800 rounded-lg shadow-2xl shadow-black border border-gray-700">
{/* Overlay for Win State */}
{gameState === GameState.WON && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80 rounded-lg backdrop-blur-sm animate-in fade-in duration-500">
<h2 className="text-5xl font-bold text-yellow-400 mb-4 drop-shadow-[0_0_15px_rgba(250,204,21,0.6)]">VICTORY!</h2>
<p className="text-gray-300 mb-6">You reached the goal in {moves} moves.</p>
<button
onClick={startNewGame}
className="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full font-bold transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blue-500/30"
>
Play Again
</button>
</div>
)}
{/* The Grid */}
<div
className="grid gap-[1px] bg-gray-900 border-2 border-gray-900"
style={{
gridTemplateColumns: `repeat(${GRID_SIZE}, minmax(0, 1fr))`,
width: 'min(90vw, 600px)',
height: 'min(90vw, 600px)'
}}
>
{grid.map((row, y) => (
row.map((cell, x) => (
<Cell
key={`${x}-${y}`}
cell={cell}
isPlayerHere={playerPos.x === x && playerPos.y === y}
/>
))
))}
</div>
</div>
{/* Controls / Legend */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-2xl">
{/* Legend */}
<div className="bg-gray-900 p-4 rounded-xl border border-gray-800">
<h3 className="text-gray-500 text-xs uppercase font-bold mb-3 tracking-widest">Legend</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-600 border border-gray-500 rounded-sm"></div>
<span className="text-gray-400">Safe Path</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-slate-800 border border-slate-700 rounded-sm flex items-center justify-center"><BrickWall size={10} className="text-gray-600"/></div>
<span className="text-gray-400">Wall (Block)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-900 border border-red-800 rounded-sm flex items-center justify-center"><Zap size={10} className="text-red-500"/></div>
<span className="text-gray-400">Trap (Reset)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-900 border border-yellow-800 rounded-sm flex items-center justify-center"><Flag size={10} className="text-yellow-500"/></div>
<span className="text-gray-400">Goal</span>
</div>
</div>
</div>
{/* Instructions */}
<div className="bg-gray-900 p-4 rounded-xl border border-gray-800 flex flex-col justify-between">
<h3 className="text-gray-500 text-xs uppercase font-bold mb-3 tracking-widest flex items-center gap-2">
<CircleHelp size={14} /> Instructions
</h3>
<p className="text-sm text-gray-400 leading-relaxed">
Use <span className="text-blue-400 font-bold">Arrow Keys</span> or <span className="text-blue-400 font-bold">WASD</span> to move.
<br/>
Hold key to move continuously.
<br/>
Avoid hidden traps!
</p>
<button
onClick={startNewGame}
className="mt-4 w-full py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg flex items-center justify-center gap-2 text-sm transition-colors border border-gray-700"
>
<RotateCcw size={14} /> Restart Map
</button>
</div>
</div>
{/* Mobile Controls (Optional, keeping it subtle) */}
<div className="md:hidden mt-6 grid grid-cols-3 gap-2">
<div />
<button
onTouchStart={(e) => { e.preventDefault(); activeDirectionRef.current = Direction.UP; handleInteraction(Direction.UP); lastMoveTimeRef.current = performance.now(); }}
onTouchEnd={(e) => { e.preventDefault(); activeDirectionRef.current = null; }}
className="w-14 h-14 bg-gray-800 rounded-full flex items-center justify-center active:bg-blue-600 transition-colors"
>
<ArrowUp className="text-white" />
</button>
<div />
<button
onTouchStart={(e) => { e.preventDefault(); activeDirectionRef.current = Direction.LEFT; handleInteraction(Direction.LEFT); lastMoveTimeRef.current = performance.now(); }}
onTouchEnd={(e) => { e.preventDefault(); activeDirectionRef.current = null; }}
className="w-14 h-14 bg-gray-800 rounded-full flex items-center justify-center active:bg-blue-600 transition-colors"
>
<ArrowLeft className="text-white" />
</button>
<button
onTouchStart={(e) => { e.preventDefault(); activeDirectionRef.current = Direction.DOWN; handleInteraction(Direction.DOWN); lastMoveTimeRef.current = performance.now(); }}
onTouchEnd={(e) => { e.preventDefault(); activeDirectionRef.current = null; }}
className="w-14 h-14 bg-gray-800 rounded-full flex items-center justify-center active:bg-blue-600 transition-colors"
>
<ArrowDown className="text-white" />
</button>
<button
onTouchStart={(e) => { e.preventDefault(); activeDirectionRef.current = Direction.RIGHT; handleInteraction(Direction.RIGHT); lastMoveTimeRef.current = performance.now(); }}
onTouchEnd={(e) => { e.preventDefault(); activeDirectionRef.current = null; }}
className="w-14 h-14 bg-gray-800 rounded-full flex items-center justify-center active:bg-blue-600 transition-colors"
>
<ArrowRight className="text-white" />
</button>
</div>
</div>
);
};