botWebWars/frontend/src/components/MovementControls.tsx

243 lines
8.3 KiB
TypeScript
Raw Normal View History

2026-09-05 21:20:39 +00:00
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 currentTurnId = boardState.turn.current_player_id;
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
const controlledPlayer = selectedPlayer || activePlayer;
const isMyTurn = controlledPlayer && controlledPlayer.id === currentTurnId;
const handleDirectionClick = useCallback(
(dir: string) => {
if (!controlledPlayer || !isMyTurn) return;
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
},
[controlledPlayer, isMyTurn, onMove]
);
const handlePassClick = useCallback(() => {
if (!controlledPlayer || !isMyTurn) return;
onPass(controlledPlayer.id).catch((err) => alert(err.message));
}, [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 (!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);
}, [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 = !isMyTurn || !isAvailable;
const reason = 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">
<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>
{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={!isMyTurn}
title="Pass turn (Spacebar)"
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
!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}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-mono py-1.5 px-2.5 rounded-lg border border-slate-700 transition-colors flex items-center justify-center gap-1"
title="Make 1 random valid move for the active bot"
>
<span></span> Step Bot
</button>
<button
onClick={onToggleAutoPlay}
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 ${
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="Automatically cycle bot turns"
>
<span>{isAutoPlaying ? '⏸' : '▶'}</span> {isAutoPlaying ? 'Auto: ON' : 'Auto Play'}
</button>
</div>
<div className="text-[10px] text-slate-500 font-mono text-center">
WASD / Arrows / Numpad to move
</div>
</div>
);
};