cc50e64e21
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map. Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||
import { Button } from '../shared/ui/controls';
|
||
|
||
import styles from './EditorApp.module.css';
|
||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||
import launchStyles from './LaunchPlayersModal.module.css';
|
||
|
||
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||
|
||
export function LaunchPlayersModal({
|
||
open,
|
||
onClose,
|
||
onConfirm,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onConfirm: (playerIds: string[]) => void;
|
||
}) {
|
||
const { t } = useEditorI18n();
|
||
const { players, teams } = useAppPlayers();
|
||
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
|
||
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setSelectedPlayers(new Set());
|
||
setSelectedTeams(new Set());
|
||
}, [open]);
|
||
|
||
const resolvedIds = useMemo(
|
||
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
|
||
[players, selectedPlayers, selectedTeams],
|
||
);
|
||
|
||
const standalonePlayers = useMemo(
|
||
() =>
|
||
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
|
||
[players, selectedTeams],
|
||
);
|
||
|
||
if (!open) return null;
|
||
|
||
const togglePlayer = (id: PlayerId) => {
|
||
setSelectedPlayers((prev) => {
|
||
const next = new Set(prev);
|
||
const key = String(id);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const toggleTeam = (id: PlayerTeamId) => {
|
||
const key = String(id);
|
||
setSelectedTeams((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
|
||
setSelectedPlayers((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const next = new Set(prev);
|
||
let changed = false;
|
||
for (const p of players) {
|
||
if (String(p.teamId) !== key) continue;
|
||
if (next.delete(String(p.id))) changed = true;
|
||
}
|
||
return changed ? next : prev;
|
||
});
|
||
};
|
||
|
||
return createPortal(
|
||
<>
|
||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||
<div
|
||
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
data-testid="launch-players-modal"
|
||
>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
onClick={onClose}
|
||
className={styles.modalClose}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
|
||
{teams.length > 0 ? (
|
||
<section className={launchStyles.section}>
|
||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
|
||
<div className={launchStyles.list}>
|
||
{teams.map((team: AppPlayerTeam) => {
|
||
const count = players.filter((p) => p.teamId === team.id).length;
|
||
return (
|
||
<label key={team.id} className={launchStyles.row}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedTeams.has(String(team.id))}
|
||
onChange={() => toggleTeam(team.id)}
|
||
data-testid={`launch-team-${team.id}`}
|
||
/>
|
||
<span className={launchStyles.teamDot} style={{ background: team.color }} />
|
||
<span>
|
||
{team.name} ({count})
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{standalonePlayers.length > 0 ? (
|
||
<section className={launchStyles.section}>
|
||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
|
||
<div className={launchStyles.list}>
|
||
{standalonePlayers.map((player: AppPlayer) => (
|
||
<label key={player.id} className={launchStyles.row}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedPlayers.has(String(player.id))}
|
||
onChange={() => togglePlayer(player.id)}
|
||
data-testid={`launch-player-${player.id}`}
|
||
/>
|
||
<span>{player.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : players.length === 0 ? (
|
||
<section className={launchStyles.section}>
|
||
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.modalFooter}>
|
||
<Button variant="ghost" onClick={onClose}>
|
||
{t('common.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
disabled={resolvedIds.length === 0}
|
||
data-testid="launch-players-confirm"
|
||
onClick={() => onConfirm(resolvedIds)}
|
||
>
|
||
{t('top.runWithPlayersConfirm')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|