315 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}; |