botWebWars/frontend/src/components/MovementControls.tsx

457 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useCallback } from 'react';
import type { AvailableMovesResponse, BoardState, Player } from '../types';
import { isPlayerDead } from '../utils/pixelAvatars';
interface MovementControlsProps {
boardState: BoardState;
selectedPlayer: Player | null;
availableMoves: AvailableMovesResponse | null;
onMove: (playerId: string, direction: string) => Promise<unknown>;
onPass: (playerId: string) => Promise<unknown>;
onSleep?: (playerId: string) => Promise<unknown>;
onChallengeWizard?: (playerId: string) => Promise<unknown>;
onStepBot: () => void;
isAutoPlaying: boolean;
onToggleAutoPlay: () => void;
showPopups?: boolean;
onTogglePopups?: (value?: boolean) => void;
showNamesAndControls?: boolean;
onToggleNamesAndControls?: (value: boolean) => void;
}
export const MovementControls: React.FC<MovementControlsProps> = ({
boardState,
selectedPlayer,
availableMoves,
onMove,
onPass,
onSleep,
onChallengeWizard,
onStepBot,
isAutoPlaying,
onToggleAutoPlay,
showPopups = true,
onTogglePopups,
showNamesAndControls = true,
onToggleNamesAndControls,
}) => {
const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id;
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 isTroll = controlledPlayer?.character_type === 'troll';
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const playerParty = controlledPlayer?.party_id
? (boardState.parties || []).find((p) => p.id === controlledPlayer.party_id)
: null;
const isLeader = Boolean(controlledPlayer?.is_party_leader);
const consecutivePasses = playerParty?.consecutive_passes ?? 0;
const willUnstuckOnPass = isLeader && consecutivePasses === 1;
const isAdjacentToWizard = Boolean(
!isDead &&
controlledPlayer &&
boardState.wizard &&
Math.max(
Math.abs(controlledPlayer.x - boardState.wizard.x),
Math.abs(controlledPlayer.y - boardState.wizard.y)
) <= 1
);
const canInitiateWizardChallenge = !isTroll && (!controlledPlayer?.party_id || controlledPlayer?.is_party_leader);
const handleSleepClick = useCallback(() => {
if (!isStarted || !controlledPlayer || !isMyTurn || !isTroll) return;
onSleep?.(controlledPlayer.id).catch((err: unknown) => {
if (err instanceof Error) alert(err.message);
});
}, [isStarted, controlledPlayer, isMyTurn, isTroll, onSleep]);
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]);
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;
case 'r':
case 'R':
if (isTroll) {
e.preventDefault();
handleSleepClick();
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
if (boardState.players.length === 0 || !showNamesAndControls) {
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>
) : 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 ? (
<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"
: willUnstuckOnPass
? "Pass turn (Spacebar) - 2nd consecutive pass forces squad to move 1 space away from obstacles to get unstuck!"
: "Pass turn (Spacebar)"
}
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex flex-col items-center justify-center transition-all ${
!isStarted || !isMyTurn
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
: willUnstuckOnPass
? 'bg-amber-950/80 hover:bg-amber-600 hover:text-white text-amber-300 border border-amber-400/80 shadow-md ring-1 ring-amber-400/50 active:scale-95'
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
}`}
>
<span>PASS</span>
{willUnstuckOnPass && (
<span className="text-[7px] text-amber-200 uppercase tracking-tighter leading-none">Unstick</span>
)}
</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>
{/* Troll Sleep Action */}
{isTroll && onSleep && (
<button
onClick={handleSleepClick}
disabled={!isStarted || !isMyTurn}
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
? 'bg-emerald-950/40 text-emerald-400/50 border-emerald-900/50 cursor-not-allowed'
: 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400 shadow-xl shadow-emerald-950/60 animate-pulse active:scale-95 cursor-pointer'
}`}
title={
!isStarted
? 'Game has not started yet'
: !isMyTurn
? `Waiting for ${controlledPlayer?.name}'s turn`
: 'Rest and sleep for 1 turn to regenerate +0.1 HP (Hotkey: R)'
}
>
<span className="text-base">💤</span>
<span>Sleep (+0.1 HP)</span>
</button>
)}
{/* Challenge Wizard Button if adjacent (Players/Leaders only) */}
{isAdjacentToWizard && onChallengeWizard && !isTroll && (
<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>
)}
{/* 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>
{/* Result Popups & Names/Controls Toggles */}
<div className="pt-2 border-t border-slate-800/80 flex flex-col gap-1.5 px-0.5">
<label
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
title={
showPopups
? 'Result popups enabled: Click to hide battle and wizard popups'
: 'Result popups disabled: Click to show battle and wizard popups'
}
>
<input
type="checkbox"
checked={showPopups}
onChange={(e) => onTogglePopups?.(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
/>
<span className="text-[11px]">
Popups: <strong className={showPopups ? 'text-sky-300' : 'text-slate-500'}>{showPopups ? 'ON' : 'OFF'}</strong>
</span>
</label>
<label
className="flex items-center gap-2 cursor-pointer select-none text-xs font-mono text-slate-400 hover:text-slate-200 transition-colors"
title={
showNamesAndControls
? 'Names & controls enabled: Click to hide character nametags and manual control box'
: 'Names & controls disabled: Click to show character nametags and manual control box'
}
>
<input
type="checkbox"
checked={showNamesAndControls}
onChange={(e) => onToggleNamesAndControls?.(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-sky-500 cursor-pointer"
/>
<span className="text-[11px]">
Names & Controls: <strong className={showNamesAndControls ? 'text-sky-300' : 'text-slate-500'}>{showNamesAndControls ? 'ON' : 'OFF'}</strong>
</span>
</label>
</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>
);
};