botWebWars/frontend/src/components/PlayerList.tsx

385 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 from 'react';
import type { BoardState, Player } from '../types';
import { PixelAvatar, getPlayerPieceType, isPlayerDead } from '../utils/pixelAvatars';
interface PlayerListProps {
boardState: BoardState;
selectedPlayer: Player | null;
onSelectPlayer: (player: Player) => void;
onRemovePlayer: (id: string) => void;
onOpenPartyModal?: () => void;
onDefeatParty?: (partyId: string) => void;
onFightBattle?: (challengerId: string, defenderId: string) => void;
onChallengeWizard?: (playerId: string) => void;
}
export const PlayerList: React.FC<PlayerListProps> = ({
boardState,
selectedPlayer,
onSelectPlayer,
onRemovePlayer,
onOpenPartyModal,
onDefeatParty,
onChallengeWizard,
}) => {
const { players, parties = [], turn } = boardState;
const currentTurnId = turn.current_player_id;
const partyMap = new Map<string, string>();
parties.forEach((p) => {
p.member_ids.forEach((mid) => partyMap.set(mid, p.name));
});
const isStarted = Boolean(turn.game_started);
const liveSelected = selectedPlayer
? players.find((p) => p.id === selectedPlayer.id) || selectedPlayer
: null;
const activePlayer = players.find((p) => p.id === currentTurnId) || null;
const candidate = liveSelected || activePlayer;
const candidateAdjacent = Boolean(
candidate &&
boardState.wizard &&
Math.max(
Math.abs(candidate.x - boardState.wizard.x),
Math.abs(candidate.y - boardState.wizard.y)
) <= 1
);
const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId);
const candidateIsTroll = candidate?.character_type === 'troll';
const candidateCanChallenge = Boolean(
candidateAdjacent &&
candidateIsTurn &&
!candidateIsTroll &&
(!candidate?.party_id || candidate?.is_party_leader)
);
return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */}
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-400" />
<h3 className="font-semibold text-slate-100 text-sm">Active Bots & Parties</h3>
</div>
<span className="text-xs font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700">
{players.length} Bots
</span>
</div>
{/* Turn & Round Banner */}
{players.length > 0 && (
<div className="px-4 py-2.5 bg-slate-950/70 border-b border-slate-800/80 flex items-center justify-between text-xs font-mono">
<span className="text-slate-400">
Round <strong className="text-sky-400">{turn.round_number}</strong>
</span>
<span className="text-slate-400">
Turn <strong className="text-amber-400">#{turn.turn_number}</strong>
</span>
</div>
)}
{/* Parties Section */}
{parties.length > 0 && (
<div className="p-3 border-b border-slate-800 bg-slate-950/40">
<div className="text-[11px] font-mono text-slate-400 uppercase tracking-wider mb-2 flex items-center justify-between">
<span>🛡 Active Parties ({parties.length})</span>
</div>
<div className="space-y-1.5">
{parties.map((party) => (
<div
key={party.id}
className="bg-slate-900/90 border border-sky-500/40 rounded-lg p-2 flex items-center justify-between text-xs"
>
<div className="min-w-0 flex-1">
<div className="font-bold text-sky-300 truncate flex items-center gap-1">
<span>👑</span> {party.name}
</div>
<div className="text-[10px] text-slate-400 font-mono">
Leader: <strong className="text-amber-300">{party.leader_name}</strong> {' '}
{party.member_ids.length} bots <span className="text-amber-400 font-bold">{party.total_strength}</span>
</div>
</div>
{onDefeatParty && (
<button
onClick={() => onDefeatParty(party.id)}
className="ml-2 bg-rose-950/80 hover:bg-rose-900 text-rose-300 border border-rose-700/60 px-2 py-1 rounded text-[10px] font-mono transition-all"
title="Defeat party: leader killed (-1 pt), leader respawns, remainder assigns new leader"
>
💥 Defeat
</button>
)}
</div>
))}
</div>
</div>
)}
{/* Wizard NPC Section */}
{boardState.wizard && (
<div className="p-3 border-b border-slate-800 bg-purple-950/20">
<div className="text-[11px] font-mono text-purple-300 uppercase tracking-wider mb-1.5 flex items-center justify-between">
<span>🧙 Wandering Wizard (NPC)</span>
<span className="text-[9px] bg-purple-900/60 px-1.5 py-0.5 rounded text-purple-300 border border-purple-700 font-mono">
CHALLENGE NPC
</span>
</div>
<div className="bg-slate-900/90 border border-purple-500/40 rounded-lg p-2 flex items-center justify-between text-xs">
<div className="min-w-0 flex-1">
<div className="font-bold text-purple-300 flex items-center gap-1.5">
<span>🧙</span> {boardState.wizard.name}
</div>
<div className="text-[10px] text-slate-400 font-mono mt-0.5">
pos: <span className="text-emerald-400">({boardState.wizard.x}, {boardState.wizard.y})</span> Str: <span className="text-amber-400 font-bold">{boardState.wizard.strength}</span>
</div>
</div>
{candidateAdjacent && onChallengeWizard ? (
<button
onClick={() => {
if (!candidate) return;
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (!candidateIsTurn) {
alert(`It is not ${candidate.name}'s turn! Wait for their turn to challenge.`);
return;
}
if (candidate.party_id && !candidate.is_party_leader) {
alert("Only party leader can challenge the wizard.");
return;
}
onChallengeWizard(candidate.id);
}}
disabled={!candidateCanChallenge}
className={`ml-2 px-2.5 py-1 rounded text-xs font-mono font-bold flex items-center gap-1 transition-all ${
candidateCanChallenge
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
: 'bg-purple-950/60 text-purple-400/60 border border-purple-800/40 cursor-not-allowed text-[10px]'
}`}
title={
!candidateIsTurn
? `Wait for ${candidate?.name || 'bot'}'s turn`
: candidate?.party_id && !candidate.is_party_leader
? 'Only party leader can challenge'
: `Challenge Wizard with ${candidate?.name}!`
}
>
<span></span>
<span>{candidateCanChallenge ? 'Challenge' : 'Wait Turn'}</span>
</button>
) : (
<div className="text-[10px] font-mono text-purple-400 bg-purple-950/80 border border-purple-800 px-2 py-1 rounded text-center">
D20 Duel
</div>
)}
</div>
</div>
)}
{/* Action to form party */}
{players.length >= 2 && onOpenPartyModal && (
<div className="p-2 border-b border-slate-800 bg-slate-950/30">
<button
onClick={onOpenPartyModal}
className="w-full bg-slate-800 hover:bg-sky-700 text-slate-200 hover:text-white py-1.5 px-3 rounded-lg text-xs font-mono border border-slate-700 transition-colors flex items-center justify-center gap-1.5"
>
<span>🤝</span> Form / Join Party
</button>
</div>
)}
{/* Players List */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? (
<div className="text-center py-12 px-4 text-slate-500 text-xs">
<div className="text-2xl mb-2">🤖</div>
No players registered yet.
<br />
Register a player or spawn a random bot to start taking turns!
</div>
) : (
players.map((player) => {
const isDead = isPlayerDead(player);
const isTroll = player.character_type === 'troll';
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = !isDead && currentTurnId === player.id;
const isLeader = !isDead && player.is_party_leader;
const partyName = !isDead ? partyMap.get(player.id) : null;
return (
<div
key={player.id}
onClick={() => onSelectPlayer(player)}
className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${
isDead
? isSelected
? 'bg-slate-900/80 border-slate-600 shadow-md shadow-slate-900/50'
: 'bg-slate-950/40 border-slate-800/80 hover:border-slate-700 opacity-75'
: isCurrentTurn
? 'bg-amber-950/25 border-amber-500/70 shadow-md shadow-amber-500/10'
: isSelected
? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10'
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-800/40'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-950/80 border p-0.5 shadow transition-transform"
style={{
borderColor: isDead
? '#64748b'
: isLeader
? '#fbbf24'
: isCurrentTurn
? '#f59e0b'
: `${player.color}66`,
boxShadow: isDead
? 'none'
: isLeader
? '0 0 12px #fbbf2455'
: isCurrentTurn
? '0 0 12px #f59e0b55'
: `0 0 8px ${player.color}33`,
}}
>
<PixelAvatar
pieceType={getPlayerPieceType(player)}
color={player.color}
isLeader={isLeader}
size={32}
/>
</div>
{isLeader && (
<span className="absolute -top-2 -right-1 text-[12px] leading-none">
👑
</span>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-semibold truncate ${isDead ? 'text-slate-400 line-through' : 'text-slate-200'}`}>
{player.name}
</span>
{isTroll && (
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-emerald-950 text-emerald-300 border border-emerald-700 font-bold">
👹 TROLL
</span>
)}
{isDead ? (
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-rose-950/80 text-rose-400 border border-rose-800 font-bold">
💀 DEAD
</span>
) : (
<span
className={`text-[10px] font-mono px-1.5 py-0.2 rounded ${
player.score < 0
? 'bg-rose-950 text-rose-300 border border-rose-800'
: player.score > 0
? 'bg-emerald-950 text-emerald-300 border border-emerald-800'
: 'bg-slate-800 text-slate-400'
}`}
>
{player.score >= 0 ? `+${player.score}` : player.score} pts
</span>
)}
</div>
<div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> Str: <span className="text-amber-400">{Number(player.strength.toFixed(1))}</span> HP:{' '}
<span className={isDead ? 'text-rose-500 font-bold' : 'text-rose-400 font-bold'}>
{isDead ? '💀 0' : `❤️${Number((player.health ?? 10).toFixed(1))}`}
</span>
{isDead && (
<span className="ml-1 text-slate-500 font-sans italic">
Score: {player.score}
</span>
)}
{partyName && !isDead && (
<span className="ml-1 text-sky-400 truncate font-sans">
{isLeader ? 'Leader' : 'Squad'}
</span>
)}
{isTroll && !isDead && (
<span className="ml-1 text-emerald-400 truncate font-sans">
Solo Hunter
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
{!isDead &&
!isTroll &&
boardState.wizard &&
Math.max(
Math.abs(player.x - boardState.wizard.x),
Math.abs(player.y - boardState.wizard.y)
) <= 1 &&
onChallengeWizard && (
<button
onClick={(e) => {
e.stopPropagation();
if (!isStarted) {
alert("Game has not started yet. Click 'Start Game' in header first.");
return;
}
if (currentTurnId !== player.id) {
alert(`It is not ${player.name}'s turn! Wait for their turn to challenge.`);
return;
}
if (player.party_id && !player.is_party_leader) {
alert("Only party leader can challenge the wizard.");
return;
}
onChallengeWizard(player.id);
}}
disabled={
!isStarted ||
currentTurnId !== player.id ||
Boolean(player.party_id && !player.is_party_leader)
}
className={`px-2 py-0.5 rounded text-[10px] font-mono font-bold flex items-center gap-1 transition-all ${
isStarted &&
currentTurnId === player.id &&
(!player.party_id || player.is_party_leader)
? 'bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-900/60 animate-pulse active:scale-95 cursor-pointer'
: 'bg-purple-950/50 text-purple-400/50 border border-purple-800/40 cursor-not-allowed'
}`}
title={
currentTurnId === player.id
? `Challenge Wizard with ${player.name}!`
: `Wait for ${player.name}'s turn to challenge`
}
>
<span>🧙</span>
<span>Duel</span>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onRemovePlayer(player.id);
}}
title="Remove Bot"
className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-red-950/40 transition-colors text-xs"
>
</button>
</div>
</div>
);
})
)}
</div>
{/* Footer Info */}
<div className="p-3 border-t border-slate-800 bg-slate-950/40 text-[11px] font-mono text-slate-400 flex justify-between items-center">
<span>Coordinate range:</span>
<span className="text-sky-400 font-semibold">[0,0] [64,64]</span>
</div>
</div>
);
};