feat(players): NPC disposition types and launch-with-players session tokens
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>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user