feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user