Files
Ivan Fontosh 86d6d157f0 feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 10:09:51 +08:00

382 lines
13 KiB
TypeScript
Raw Permalink 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, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
INITIATIVE_CONDITION_IDS,
type InitiativeConditionId,
type InitiativeEntry,
} from '../../shared/types/initiative';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { InitiativeConditionChips } from '../shared/initiative/InitiativeConditionChips';
import { useInitiativeState } from '../shared/initiative/useInitiativeState';
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { Button, Input } from '../shared/ui/controls';
import styles from './InitiativeApp.module.css';
const DND_MIME = 'application/x-dnd-initiative-entry-id';
const CONDITION_MENU_WIDTH = 200;
/** Компактная высота меню; фактическая не больше доступного места на экране. */
const CONDITION_MENU_MAX_H = 220;
const CONDITION_MENU_GAP = 6;
const CONDITION_MENU_EDGE = 8;
function EntryAvatar({ entry }: { entry: InitiativeEntry }) {
if (entry.kind === 'manual') {
return (
<div className={styles.avatar} style={{ background: entry.gradient }} aria-hidden>
<span className={styles.letter}>{entry.letter}</span>
</div>
);
}
if (entry.kind === 'npc') {
return <NpcAvatar assetId={entry.avatarAssetId} name={entry.name} />;
}
return <PlayerAvatar playerId={entry.playerId} name={entry.name} />;
}
function NpcAvatar({ assetId, name }: { assetId: string | null; name: string }) {
const url = useAssetUrl(assetId as import('../../shared/types').AssetId | null);
if (url) {
return (
<div className={styles.avatar}>
<img src={url} alt="" className={styles.avatarImg} />
</div>
);
}
const letter = (name.trim()[0] ?? '?').toUpperCase();
return (
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#334155,#0f172a)' }}>
<span className={styles.letter}>{letter}</span>
</div>
);
}
function PlayerAvatar({ playerId, name }: { playerId: string; name: string }) {
const url = usePlayerImageUrl(playerId as import('../../shared/types').PlayerId);
if (url) {
return (
<div className={styles.avatar}>
<img src={url} alt="" className={styles.avatarImg} />
</div>
);
}
const letter = (name.trim()[0] ?? '?').toUpperCase();
return (
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#1d4ed8,#0f172a)' }}>
<span className={styles.letter}>{letter}</span>
</div>
);
}
function QueueSlot({
entry,
active,
conditions,
onSelect,
onRemove,
onDragStart,
onDragOver,
onDrop,
conditionsOpen,
onToggleConditions,
onAddCondition,
onRemoveCondition,
}: {
entry: InitiativeEntry;
active: boolean;
conditions: readonly InitiativeConditionId[];
onSelect: () => void;
onRemove: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
conditionsOpen: boolean;
onToggleConditions: () => void;
onAddCondition: (id: InitiativeConditionId) => void;
onRemoveCondition: (id: InitiativeConditionId) => void;
}) {
const { t } = useEditorI18n();
const available = INITIATIVE_CONDITION_IDS.filter((id) => !conditions.includes(id));
const addBtnRef = useRef<HTMLButtonElement | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; left: number; maxHeight: number } | null>(
null,
);
useLayoutEffect(() => {
if (!conditionsOpen) {
setMenuPos(null);
return;
}
const btn = addBtnRef.current;
if (!btn) return;
const place = () => {
const r = btn.getBoundingClientRect();
const left = Math.max(
CONDITION_MENU_EDGE,
Math.min(
window.innerWidth - CONDITION_MENU_WIDTH - CONDITION_MENU_EDGE,
r.left,
),
);
const spaceBelow = window.innerHeight - r.bottom - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
const spaceAbove = r.top - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
// Предпочитаем под кнопкой; если снизу мало места — над ней.
const openBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
const available = Math.max(80, openBelow ? spaceBelow : spaceAbove);
const maxHeight = Math.min(CONDITION_MENU_MAX_H, available);
const top = openBelow
? r.bottom + CONDITION_MENU_GAP
: Math.max(CONDITION_MENU_EDGE, r.top - CONDITION_MENU_GAP - maxHeight);
setMenuPos({ top, left, maxHeight });
};
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [conditionsOpen]);
useEffect(() => {
if (!conditionsOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (addBtnRef.current?.contains(target)) return;
const menu = document.getElementById(`initiative-condition-menu-${entry.id}`);
if (menu?.contains(target)) return;
onToggleConditions();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [conditionsOpen, entry.id, onToggleConditions]);
return (
<div
className={[styles.slot, active ? styles.slotActive : ''].filter(Boolean).join(' ')}
data-testid={`initiative-slot-${entry.id}`}
data-active={active ? '1' : '0'}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onSelect}
>
<EntryAvatar entry={entry} />
<div className={styles.slotName} title={entry.name}>
{entry.name}
</div>
<InitiativeConditionChips
conditions={conditions}
removable
onRemove={onRemoveCondition}
/>
<div className={styles.slotActions}>
<button
ref={addBtnRef}
type="button"
className={styles.miniBtn}
title={t('initiative.conditions')}
onClick={(e) => {
e.stopPropagation();
onToggleConditions();
}}
>
+
</button>
<button
type="button"
className={styles.miniBtn}
title={t('initiative.toReserve')}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
×
</button>
</div>
{conditionsOpen && menuPos
? createPortal(
<div
id={`initiative-condition-menu-${entry.id}`}
className={styles.conditionMenu}
style={{
top: menuPos.top,
left: menuPos.left,
width: CONDITION_MENU_WIDTH,
maxHeight: menuPos.maxHeight,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{available.length === 0 ? (
<div className={styles.conditionEmpty}>{t('initiative.conditionsEmpty')}</div>
) : (
available.map((id) => (
<button
key={id}
type="button"
className={styles.conditionItem}
onClick={() => onAddCondition(id)}
>
{t(`initiative.condition.${id}`)}
</button>
))
)}
</div>,
document.body,
)
: null}
</div>
);
}
export function InitiativeApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, initiativeApi] = useInitiativeState();
const [manualName, setManualName] = useState('');
const [conditionsFor, setConditionsFor] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeInitiative, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
const orderEntries = useMemo(() => {
if (!state) return [];
return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
}, [state]);
const reserveEntries = useMemo(() => {
if (!state) return [];
return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
}, [state]);
if (!state) {
return <div className={styles.page} data-testid="initiative-app" />;
}
const reorderDrop = (targetId: string, e: React.DragEvent) => {
e.preventDefault();
const fromId = e.dataTransfer.getData(DND_MIME);
if (!fromId || fromId === targetId) return;
const next = [...state.order];
const fromIdx = next.indexOf(fromId);
const toIdx = next.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
next.splice(fromIdx, 1);
next.splice(toIdx, 0, fromId);
void initiativeApi.dispatch({ kind: 'reorder', order: next });
};
const addManual = () => {
const name = manualName.trim();
if (!name) return;
void initiativeApi.dispatch({ kind: 'addManual', name });
setManualName('');
};
return (
<div className={styles.page} data-testid="initiative-app">
<div className={styles.toolbar}>
<Button variant="primary" onClick={() => void initiativeApi.dispatch({ kind: 'next' })}>
{t('initiative.next')}
</Button>
<div className={styles.addRow}>
<Input
value={manualName}
onChange={setManualName}
placeholder={t('initiative.manualPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addManual();
}
}}
/>
<Button variant="ghost" onClick={addManual}>
{t('initiative.addManual')}
</Button>
</div>
</div>
<div className={styles.sectionLabel}>{t('initiative.queue')}</div>
<div className={styles.queue} data-testid="initiative-queue">
{orderEntries.length === 0 ? (
<div className={styles.empty}>{t('initiative.queueEmpty')}</div>
) : (
orderEntries.map((entry) => (
<QueueSlot
key={entry.id}
entry={entry}
active={state.activeId === entry.id}
conditions={state.conditions[entry.id] ?? []}
onSelect={() => void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })}
onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })}
onDragStart={(e) => {
e.dataTransfer.setData(DND_MIME, entry.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => reorderDrop(entry.id, e)}
conditionsOpen={conditionsFor === entry.id}
onToggleConditions={() =>
setConditionsFor((cur) => (cur === entry.id ? null : entry.id))
}
onAddCondition={(conditionId) => {
void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId });
setConditionsFor(null);
}}
onRemoveCondition={(conditionId) => {
void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId });
}}
/>
))
)}
</div>
<div className={styles.sectionLabel}>{t('initiative.reserve')}</div>
<div className={styles.reserve} data-testid="initiative-reserve">
{reserveEntries.length === 0 ? (
<div className={styles.empty}>{t('initiative.reserveEmpty')}</div>
) : (
reserveEntries.map((entry) => (
<div key={entry.id} className={styles.reserveRow}>
<EntryAvatar entry={entry} />
<span className={styles.reserveName}>{entry.name}</span>
<Button
variant="ghost"
onClick={() => void initiativeApi.dispatch({ kind: 'restoreFromReserve', id: entry.id })}
>
{t('initiative.restore')}
</Button>
{entry.kind === 'manual' ? (
<Button
variant="ghost"
onClick={() => void initiativeApi.dispatch({ kind: 'deleteEntry', id: entry.id })}
>
{t('initiative.delete')}
</Button>
) : null}
</div>
))
)}
</div>
</div>
);
}