457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
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>
|
||
);
|
||
};
|