252 lines
9.4 KiB
TypeScript
252 lines
9.4 KiB
TypeScript
import React, { useState } from 'react';
|
|
import type { BoardState, Player } from '../types';
|
|
|
|
interface PartyModalProps {
|
|
isOpen: boolean;
|
|
boardState: BoardState;
|
|
onClose: () => void;
|
|
onFormParty: (memberIds: string[], leaderId: string, name?: string) => Promise<unknown>;
|
|
}
|
|
|
|
export const PartyModal: React.FC<PartyModalProps> = ({
|
|
isOpen,
|
|
boardState,
|
|
onClose,
|
|
onFormParty,
|
|
}) => {
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [leaderId, setLeaderId] = useState<string>('');
|
|
const [partyName, setPartyName] = useState<string>('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const { players } = boardState;
|
|
|
|
// Check if two players are adjacent (Chebyshev distance <= 1)
|
|
const areAdjacent = (p1: Player, p2: Player) => {
|
|
return Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1;
|
|
};
|
|
|
|
// Find any adjacent pairs in the game for quick selection
|
|
const findAdjacentPairs = () => {
|
|
for (let i = 0; i < players.length; i++) {
|
|
for (let j = i + 1; j < players.length; j++) {
|
|
if (areAdjacent(players[i], players[j])) {
|
|
return [players[i], players[j]];
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const pickStrongestLeader = (ids: string[]) => {
|
|
if (ids.length === 0) return '';
|
|
const selectedBots = players.filter((p) => ids.includes(p.id));
|
|
selectedBots.sort((a, b) => (b.strength || 1) - (a.strength || 1));
|
|
return selectedBots[0]?.id || ids[0];
|
|
};
|
|
|
|
const handleQuickPair = () => {
|
|
const pair = findAdjacentPairs();
|
|
if (pair) {
|
|
const ids = [pair[0].id, pair[1].id];
|
|
setSelectedIds(ids);
|
|
const chosenLeader = pickStrongestLeader(ids);
|
|
setLeaderId(chosenLeader);
|
|
const leadBot = players.find((p) => p.id === chosenLeader) || pair[0];
|
|
setPartyName(`Squad ${leadBot.name}`);
|
|
setError(null);
|
|
} else {
|
|
setError('No adjacent bots found! Move bots within 1 distance of each other to link them.');
|
|
}
|
|
};
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setError(null);
|
|
setSelectedIds((prev) => {
|
|
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
|
|
setLeaderId(pickStrongestLeader(next));
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (selectedIds.length < 2) {
|
|
setError('Select at least 2 bots to form a party');
|
|
return;
|
|
}
|
|
if (!leaderId) {
|
|
setError('You must agree on a party leader');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
await onFormParty(selectedIds, leaderId, partyName.trim() || undefined);
|
|
onClose();
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error) {
|
|
setError(err.message);
|
|
} else {
|
|
setError('Failed to form party');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4">
|
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-md shadow-2xl p-6 relative">
|
|
<div className="flex items-center justify-between mb-4 border-b border-slate-800 pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xl">🤝</span>
|
|
<h2 className="text-lg font-bold text-slate-100">Form a Bot Party</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-slate-400 hover:text-slate-200 transition-colors text-sm"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-xs text-slate-400 mb-4 leading-relaxed">
|
|
The goal of a bot without a party is to form a party! Bots insist on being leader if they consider the other bot less than them (higher strength), and desire to join a bot that is equal or stronger.
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleQuickPair}
|
|
className="w-full bg-slate-800 hover:bg-slate-700 border border-sky-500/40 text-sky-300 text-xs font-mono py-2 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<span>🎯</span> Auto-Select Nearest Adjacent Bots
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Party Name */}
|
|
<div>
|
|
<label className="block text-xs font-mono text-slate-400 mb-1">Party Name (Optional)</label>
|
|
<input
|
|
type="text"
|
|
value={partyName}
|
|
onChange={(e) => setPartyName(e.target.value)}
|
|
placeholder="e.g. NeonVipers"
|
|
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-100 focus:outline-none focus:border-sky-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Member Selection */}
|
|
<div>
|
|
<label className="block text-xs font-mono text-slate-400 mb-1.5">
|
|
Select Members (At least 2):
|
|
</label>
|
|
<div className="max-h-48 overflow-y-auto space-y-1.5 border border-slate-800 rounded-xl p-2 bg-slate-950/50">
|
|
{players.map((p) => {
|
|
const isChecked = selectedIds.includes(p.id);
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
onClick={() => toggleSelect(p.id)}
|
|
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer border text-xs transition-colors ${
|
|
isChecked
|
|
? 'bg-sky-950/60 border-sky-500/70 text-sky-200'
|
|
: 'bg-slate-900/60 border-slate-800 text-slate-400 hover:border-slate-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: p.color }}
|
|
/>
|
|
<span className="font-semibold text-slate-200">{p.name}</span>
|
|
<span className="text-amber-400 font-mono text-[11px]">⚡{p.strength || 1}</span>
|
|
<span className="font-mono text-[10px] text-slate-500">
|
|
({p.x}, {p.y})
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => {}}
|
|
className="rounded border-slate-700 text-sky-500"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agreed Leader Selection */}
|
|
{selectedIds.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className="text-xs font-mono text-slate-400">
|
|
👑 Agreed Party Leader:
|
|
</label>
|
|
<span className="text-[10px] font-mono text-amber-400/90">
|
|
(Stronger bots insist on leading)
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{selectedIds.map((id) => {
|
|
const bot = players.find((p) => p.id === id);
|
|
if (!bot) return null;
|
|
const isLeader = leaderId === id;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={id}
|
|
onClick={() => setLeaderId(id)}
|
|
className={`p-2 rounded-lg border text-xs font-mono flex items-center justify-between gap-1 transition-all ${
|
|
isLeader
|
|
? 'bg-amber-950/80 border-amber-500 text-amber-200 shadow'
|
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-1.5 truncate">
|
|
<span>{isLeader ? '👑' : '🛡️'}</span>
|
|
<span className="truncate">{bot.name}</span>
|
|
</div>
|
|
<span className="text-amber-400 text-[10px]">⚡{bot.strength || 1}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-2.5 rounded-lg bg-red-950/70 border border-red-800/80 text-red-300 text-xs font-mono">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 py-2 rounded-xl text-xs font-mono transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading || selectedIds.length < 2 || !leaderId}
|
|
className="flex-1 bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 rounded-xl text-xs font-mono transition-colors shadow-lg shadow-sky-950/50"
|
|
>
|
|
{loading ? 'Forming...' : 'Confirm Party'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|