botWebWars/frontend/src/components/MovementControls.tsx

351 lines
13 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';
import { isPlayerDead } from '../utils/pixelAvatars';
2026-09-05 21:20:39 +00:00
interface MovementControlsProps {
boardState: BoardState;
selectedPlayer: Player | null;
availableMoves: AvailableMovesResponse | null;
onMove: (playerId: string, direction: string) => Promise<unknown>;
onPass: (playerId: string) => Promise<unknown>;
2026-09-09 22:11:54 +00:00
onChallengeWizard?: (playerId: string) => Promise<unknown>;
2026-09-05 21:20:39 +00:00
onStepBot: () => void;
isAutoPlaying: boolean;
onToggleAutoPlay: () => void;
}
export const MovementControls: React.FC<MovementControlsProps> = ({
boardState,
selectedPlayer,
availableMoves,
onMove,
onPass,
2026-09-09 22:11:54 +00:00
onChallengeWizard,
2026-09-05 21:20:39 +00:00
onStepBot,
isAutoPlaying,
onToggleAutoPlay,
}) => {
const isStarted = Boolean(boardState.turn?.game_started);
2026-09-05 21:20:39 +00:00
const currentTurnId = boardState.turn.current_player_id;
2026-09-09 22:11:54 +00:00
const activePlayer = boardState.players.find((p) => p.id === currentTurnId) || null;
// Always resolve the live coordinates from boardState.players to prevent stale player coordinates
const liveSelectedPlayer = selectedPlayer
? boardState.players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const controlledPlayer = liveSelectedPlayer || activePlayer;
const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer));
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
2026-09-05 21:20:39 +00:00
2026-09-09 22:11:54 +00:00
const isAdjacentToWizard = Boolean(
!isDead &&
controlledPlayer &&
2026-09-09 22:11:54 +00:00
boardState.wizard &&
Math.max(
Math.abs(controlledPlayer.x - boardState.wizard.x),
Math.abs(controlledPlayer.y - boardState.wizard.y)
) <= 1
);
const canInitiateWizardChallenge = !controlledPlayer?.party_id || controlledPlayer?.is_party_leader;
const handleChallengeWizardClick = useCallback(() => {
if (!controlledPlayer) return;
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (!isMyTurn) {
alert(`It is not ${controlledPlayer.name}'s turn! Current turn belongs to ${activePlayer?.name || 'another bot'}.`);
return;
}
if (!canInitiateWizardChallenge) {
alert("Party member cannot challenge the wizard individually. Only party leader can initiate challenges.");
return;
}
if (!isAdjacentToWizard) {
alert("Must be within 1 space of the wizard to challenge.");
return;
}
onChallengeWizard?.(controlledPlayer.id).catch((err: unknown) => {
if (err instanceof Error) alert(err.message);
});
}, [controlledPlayer, isStarted, isMyTurn, canInitiateWizardChallenge, isAdjacentToWizard, activePlayer, onChallengeWizard]);
2026-09-05 21:20:39 +00:00
const handleDirectionClick = useCallback(
(dir: string) => {
if (!isStarted || !controlledPlayer || !isMyTurn) return;
2026-09-05 21:20:39 +00:00
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
},
[isStarted, controlledPlayer, isMyTurn, onMove]
2026-09-05 21:20:39 +00:00
);
const handlePassClick = useCallback(() => {
if (!isStarted || !controlledPlayer || !isMyTurn) return;
2026-09-05 21:20:39 +00:00
onPass(controlledPlayer.id).catch((err) => alert(err.message));
}, [isStarted, controlledPlayer, isMyTurn, onPass]);
2026-09-05 21:20:39 +00:00
// 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;
2026-09-05 21:20:39 +00:00
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]);
2026-09-05 21:20:39 +00:00
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');
2026-09-05 21:20:39 +00:00
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>
)}
2026-09-05 21:20:39 +00:00
{!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>
) : isDead ? (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-rose-950 text-rose-300 border border-rose-700 font-bold">
💀 DECEASED
</span>
) : isMyTurn ? (
2026-09-05 21:20:39 +00:00
<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)"}
2026-09-05 21:20:39 +00:00
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
!isStarted || !isMyTurn
2026-09-05 21:20:39 +00:00
? '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>
2026-09-09 22:11:54 +00:00
{/* Challenge Wizard Button if adjacent */}
{isAdjacentToWizard && onChallengeWizard && (
<button
onClick={handleChallengeWizardClick}
disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge}
className={`w-full py-2.5 px-3 rounded-xl font-bold font-mono text-xs flex items-center justify-center gap-2 border transition-all ${
!isStarted || !isMyTurn || !canInitiateWizardChallenge
? 'bg-purple-950/40 text-purple-400/60 border-purple-900/50 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-500 text-white border-purple-400 shadow-xl shadow-purple-950/70 animate-pulse active:scale-95 cursor-pointer'
}`}
title={
!isStarted
? 'Game has not started yet'
: !isMyTurn
? `Waiting for ${controlledPlayer?.name}'s turn`
: !canInitiateWizardChallenge
? 'Only party leader can challenge the wizard'
: 'Challenge the Wizard NPC to a 3-bout D20 duel! (+2 score on win, -2 health/pts on loss)'
}
>
<span className="text-base">🧙</span>
<span>
{!isStarted
? 'Start Game to Duel Wizard'
: !isMyTurn
? `Wait for Turn to Duel`
: !canInitiateWizardChallenge
? 'Leader Only'
: 'Challenge Wizard (D20 Duel)'}
</span>
</button>
)}
2026-09-05 21:20:39 +00:00
{/* 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."}
2026-09-05 21:20:39 +00:00
>
<span></span> Step Bot
</button>
<button
onClick={onToggleAutoPlay}
disabled={!isStarted}
2026-09-05 21:20:39 +00:00
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
2026-09-05 21:20:39 +00:00
? '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."}
2026-09-05 21:20:39 +00:00
>
<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'}
2026-09-05 21:20:39 +00:00
</div>
</div>
);
};