feat(control): трекер инициативы в отдельном окне

Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 10:09:51 +08:00
parent db1168a46c
commit 86d6d157f0
28 changed files with 1783 additions and 4 deletions
@@ -0,0 +1,222 @@
.page {
height: 100vh;
box-sizing: border-box;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg);
color: var(--text);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.addRow {
display: flex;
flex: 1;
gap: 8px;
min-width: 220px;
}
.addRow > :first-child {
flex: 1;
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text2);
text-transform: uppercase;
}
.queue {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
overflow-x: auto;
overflow-y: visible;
padding: 8px 4px 12px;
min-height: 160px;
}
.empty {
color: var(--text2);
font-size: var(--text-sm);
padding: 12px 4px;
}
.slot {
position: relative;
flex: 0 0 auto;
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 6px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: transparent;
cursor: grab;
user-select: none;
overflow: visible;
}
.slot:active {
cursor: grabbing;
}
.slotActive {
border-color: rgba(245, 197, 66, 0.55);
background: rgba(245, 197, 66, 0.08);
}
.slotActive .avatar {
border-color: #f5c542;
box-shadow:
0 0 0 2px rgba(0, 0, 0, 0.45),
0 0 12px rgba(245, 197, 66, 0.45);
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 3px solid #3f3f46;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #18181b;
flex-shrink: 0;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.letter {
font-size: 26px;
font-weight: 800;
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
.slotName {
width: 100%;
text-align: center;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slotActions {
display: flex;
gap: 4px;
}
.miniBtn {
appearance: none;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.35));
color: var(--text);
cursor: pointer;
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.miniBtn:hover {
border-color: var(--accent, #a78bfa);
}
.conditionMenu {
position: fixed;
z-index: 10000;
box-sizing: border-box;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg-elevated, #111827);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: hidden;
overflow-y: auto;
}
.conditionItem {
display: block;
width: 100%;
text-align: left;
appearance: none;
border: none;
background: transparent;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.conditionItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.conditionEmpty {
color: var(--text2);
font-size: 12px;
padding: 6px 8px;
}
.reserve {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 8px;
}
.reserveRow {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.25));
}
.reserveRow .avatar {
width: 36px;
height: 36px;
border-width: 2px;
}
.reserveRow .letter {
font-size: 16px;
}
.reserveName {
flex: 1;
font-size: var(--text-sm);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+381
View File
@@ -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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { InitiativeApp } from './InitiativeApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Initiative">
<EditorI18nProvider>
<InitiativeApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);