Files
DndGamePlayer/app/renderer/editor/LaunchPlayersModal.tsx
T
Ivan Fontosh cc50e64e21 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>
2026-07-30 17:11:01 +08:00

166 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}