272 lines
9.9 KiB
TypeScript
272 lines
9.9 KiB
TypeScript
import React, { useEffect, useCallback } from 'react';
|
|
import type { AvailableMovesResponse, BoardState, Player } from '../types';
|
|
|
|
interface MovementControlsProps {
|
|
boardState: BoardState;
|
|
selectedPlayer: Player | null;
|
|
availableMoves: AvailableMovesResponse | null;
|
|
onMove: (playerId: string, direction: string) => Promise<unknown>;
|
|
onPass: (playerId: string) => Promise<unknown>;
|
|
onStepBot: () => void;
|
|
isAutoPlaying: boolean;
|
|
onToggleAutoPlay: () => void;
|
|
}
|
|
|
|
export const MovementControls: React.FC<MovementControlsProps> = ({
|
|
boardState,
|
|
selectedPlayer,
|
|
availableMoves,
|
|
onMove,
|
|
onPass,
|
|
onStepBot,
|
|
isAutoPlaying,
|
|
onToggleAutoPlay,
|
|
}) => {
|
|
const isStarted = Boolean(boardState.turn?.game_started);
|
|
const currentTurnId = boardState.turn.current_player_id;
|
|
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
|
|
const controlledPlayer = selectedPlayer || activePlayer;
|
|
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
|
|
|
const handleDirectionClick = useCallback(
|
|
(dir: string) => {
|
|
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
|
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
|
|
},
|
|
[isStarted, controlledPlayer, isMyTurn, onMove]
|
|
);
|
|
|
|
const handlePassClick = useCallback(() => {
|
|
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
|
onPass(controlledPlayer.id).catch((err) => alert(err.message));
|
|
}, [isStarted, controlledPlayer, isMyTurn, onPass]);
|
|
|
|
// Keyboard shortcut listener
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Don't trigger if user is typing in an input
|
|
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) {
|
|
return;
|
|
}
|
|
if (!isStarted || !isMyTurn || !controlledPlayer) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
case 'w':
|
|
case 'W':
|
|
case '8':
|
|
e.preventDefault();
|
|
handleDirectionClick('UP');
|
|
break;
|
|
case 'ArrowDown':
|
|
case 's':
|
|
case 'S':
|
|
case '2':
|
|
e.preventDefault();
|
|
handleDirectionClick('DOWN');
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'a':
|
|
case 'A':
|
|
case '4':
|
|
e.preventDefault();
|
|
handleDirectionClick('LEFT');
|
|
break;
|
|
case 'ArrowRight':
|
|
case 'd':
|
|
case 'D':
|
|
case '6':
|
|
e.preventDefault();
|
|
handleDirectionClick('RIGHT');
|
|
break;
|
|
case '7':
|
|
case 'q':
|
|
case 'Q':
|
|
e.preventDefault();
|
|
handleDirectionClick('UP_LEFT');
|
|
break;
|
|
case '9':
|
|
case 'e':
|
|
case 'E':
|
|
e.preventDefault();
|
|
handleDirectionClick('UP_RIGHT');
|
|
break;
|
|
case '1':
|
|
case 'z':
|
|
case 'Z':
|
|
e.preventDefault();
|
|
handleDirectionClick('DOWN_LEFT');
|
|
break;
|
|
case '3':
|
|
case 'c':
|
|
case 'C':
|
|
e.preventDefault();
|
|
handleDirectionClick('DOWN_RIGHT');
|
|
break;
|
|
case ' ':
|
|
e.preventDefault();
|
|
handlePassClick();
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
|
|
|
|
if (boardState.players.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const moves = availableMoves?.moves || {};
|
|
|
|
const renderDirButton = (dir: string, label: string) => {
|
|
const check = moves[dir];
|
|
const isAvailable = check?.available ?? false;
|
|
const disabled = !isStarted || !isMyTurn || !isAvailable;
|
|
const reason = !isStarted
|
|
? "Game has not started yet. Click 'Start Game' in header."
|
|
: check?.reason || (isAvailable ? `Move to (${check?.target_x}, ${check?.target_y})` : 'Blocked');
|
|
|
|
return (
|
|
<button
|
|
onClick={() => handleDirectionClick(dir)}
|
|
disabled={disabled}
|
|
title={`${dir}: ${reason}`}
|
|
className={`w-10 h-10 rounded-xl font-bold text-sm flex items-center justify-center transition-all duration-150 ${
|
|
disabled
|
|
? 'bg-slate-900/60 text-slate-600 border border-slate-800 cursor-not-allowed'
|
|
: 'bg-slate-800 hover:bg-emerald-600 hover:text-white text-emerald-400 border border-emerald-500/40 hover:border-emerald-400 shadow-md shadow-emerald-950/40 active:scale-95'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="absolute bottom-4 right-84 z-20 bg-slate-900/95 backdrop-blur-md border border-slate-700/80 rounded-2xl p-4 shadow-2xl flex flex-col gap-3 min-w-[240px]">
|
|
{/* Turn Status Banner */}
|
|
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
|
|
{!isStarted ? (
|
|
<div className="flex items-center gap-2 text-amber-300">
|
|
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-ping" />
|
|
<div className="flex flex-col">
|
|
<span className="text-[10px] text-amber-400 font-mono leading-none">
|
|
LOBBY PHASE
|
|
</span>
|
|
<span className="text-xs font-bold text-slate-100">
|
|
Waiting for "Start Game"
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{activePlayer ? (
|
|
<>
|
|
<div
|
|
className="w-3.5 h-3.5 rounded-full ring-2 ring-white/50 animate-pulse"
|
|
style={{ backgroundColor: activePlayer.color }}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-[10px] text-slate-400 font-mono leading-none">
|
|
Round {boardState.turn.round_number} • Turn {boardState.turn.turn_number}
|
|
</span>
|
|
<span className="text-xs font-bold text-slate-100 truncate max-w-[130px]">
|
|
{activePlayer.name}
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-slate-400">Waiting for bots...</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isStarted ? (
|
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700">
|
|
NOT STARTED
|
|
</span>
|
|
) : isMyTurn ? (
|
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
|
|
YOUR TURN
|
|
</span>
|
|
) : (
|
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700">
|
|
WAITING
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 8-Directional D-Pad */}
|
|
<div className="flex flex-col items-center gap-1.5 py-1">
|
|
{/* Top Row: NW, UP, NE */}
|
|
<div className="flex gap-1.5">
|
|
{renderDirButton('UP_LEFT', '↖')}
|
|
{renderDirButton('UP', '↑')}
|
|
{renderDirButton('UP_RIGHT', '↗')}
|
|
</div>
|
|
|
|
{/* Middle Row: LEFT, Pass, RIGHT */}
|
|
<div className="flex gap-1.5">
|
|
{renderDirButton('LEFT', '←')}
|
|
<button
|
|
onClick={handlePassClick}
|
|
disabled={!isStarted || !isMyTurn}
|
|
title={!isStarted ? "Game has not started yet" : "Pass turn (Spacebar)"}
|
|
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
|
|
!isStarted || !isMyTurn
|
|
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
|
|
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
|
|
}`}
|
|
>
|
|
PASS
|
|
</button>
|
|
{renderDirButton('RIGHT', '→')}
|
|
</div>
|
|
|
|
{/* Bottom Row: SW, DOWN, SE */}
|
|
<div className="flex gap-1.5">
|
|
{renderDirButton('DOWN_LEFT', '↙')}
|
|
{renderDirButton('DOWN', '↓')}
|
|
{renderDirButton('DOWN_RIGHT', '↘')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Simulation / Bot Controls */}
|
|
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
|
|
<button
|
|
onClick={onStepBot}
|
|
disabled={!isStarted}
|
|
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-colors flex items-center justify-center gap-1 ${
|
|
!isStarted
|
|
? 'bg-slate-900/50 text-slate-600 border-slate-800 cursor-not-allowed'
|
|
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white border-slate-700'
|
|
}`}
|
|
title={isStarted ? 'Make 1 deliberate move for the active bot' : "Game has not started yet. Click 'Start Game' in header."}
|
|
>
|
|
<span>⚡</span> Step Bot
|
|
</button>
|
|
<button
|
|
onClick={onToggleAutoPlay}
|
|
disabled={!isStarted}
|
|
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-all flex items-center justify-center gap-1 ${
|
|
!isStarted
|
|
? 'bg-slate-900/50 text-slate-600 border-slate-800 cursor-not-allowed'
|
|
: isAutoPlaying
|
|
? 'bg-amber-950/80 border-amber-500 text-amber-300 shadow-md shadow-amber-950/50 animate-pulse'
|
|
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'
|
|
}`}
|
|
title={isStarted ? 'Automatically cycle bot turns' : "Game has not started yet. Click 'Start Game' in header."}
|
|
>
|
|
<span>{isAutoPlaying ? '⏸' : '▶'}</span> {isAutoPlaying ? 'Auto: ON' : 'Auto Play'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-[10px] text-slate-500 font-mono text-center">
|
|
{isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|