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,55 @@
.row {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
max-width: none;
width: 100%;
pointer-events: none;
}
.compact {
gap: 3px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.75);
background: rgba(15, 23, 42, 0.85);
color: #93c5fd;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
pointer-events: auto;
}
.compact .chip {
font-size: 9px;
padding: 1px 6px;
}
.chipLabel {
pointer-events: none;
}
.chipRemove {
appearance: none;
border: none;
background: transparent;
color: inherit;
padding: 0;
margin: 0;
cursor: pointer;
font-size: 12px;
line-height: 1;
opacity: 0.85;
}
.chipRemove:hover {
opacity: 1;
}
@@ -0,0 +1,43 @@
import React from 'react';
import type { InitiativeConditionId } from '../../../shared/types/initiative';
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
import styles from './InitiativeConditionChips.module.css';
export function InitiativeConditionChips({
conditions,
removable = false,
onRemove,
compact = false,
}: {
conditions: readonly InitiativeConditionId[];
removable?: boolean;
onRemove?: (id: InitiativeConditionId) => void;
compact?: boolean;
}) {
const { t } = useEditorI18n();
if (conditions.length === 0) return null;
return (
<div className={[styles.row, compact ? styles.compact : ''].filter(Boolean).join(' ')}>
{conditions.map((id) => (
<span key={id} className={styles.chip} data-testid={`initiative-condition-${id}`}>
<span className={styles.chipLabel}>{t(`initiative.condition.${id}`)}</span>
{removable ? (
<button
type="button"
className={styles.chipRemove}
aria-label={t('initiative.removeCondition')}
onClick={(e) => {
e.stopPropagation();
onRemove?.(id);
}}
>
×
</button>
) : null}
</span>
))}
</div>
);
}
@@ -0,0 +1,13 @@
.wrap {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: 4px;
z-index: 3;
pointer-events: none;
}
@@ -0,0 +1,27 @@
import React from 'react';
import type { InitiativeConditionId } from '../../../shared/types/initiative';
import { InitiativeConditionChips } from './InitiativeConditionChips';
import styles from './TokenInitiativeDecor.module.css';
export {
initiativeConditionsForNpc,
initiativeConditionsForPlayer,
initiativeHighlightForNpc,
initiativeHighlightForPlayer,
} from './initiativeSceneHelpers';
/** Чипы состояний под токеном (видны и на презентации). */
export function TokenInitiativeDecor({
conditions,
}: {
conditions: readonly InitiativeConditionId[];
}) {
if (conditions.length === 0) return null;
return (
<div className={styles.wrap}>
<InitiativeConditionChips conditions={conditions} compact />
</div>
);
}
@@ -0,0 +1,37 @@
import type { InitiativeConditionId, InitiativeState } from '../../../shared/types/initiative';
import {
initiativeNpcEntryId,
initiativePlayerEntryId,
} from '../../../shared/types/initiative';
export function initiativeHighlightForNpc(
state: InitiativeState | null | undefined,
placementId: string,
): boolean {
if (!state?.enabled || !state.activeId) return false;
return state.activeId === initiativeNpcEntryId(placementId);
}
export function initiativeHighlightForPlayer(
state: InitiativeState | null | undefined,
playerId: string,
): boolean {
if (!state?.enabled || !state.activeId) return false;
return state.activeId === initiativePlayerEntryId(playerId);
}
export function initiativeConditionsForNpc(
state: InitiativeState | null | undefined,
placementId: string,
): InitiativeConditionId[] {
if (!state) return [];
return state.conditions[initiativeNpcEntryId(placementId)] ?? [];
}
export function initiativeConditionsForPlayer(
state: InitiativeState | null | undefined,
playerId: string,
): InitiativeConditionId[] {
if (!state) return [];
return state.conditions[initiativePlayerEntryId(playerId)] ?? [];
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { InitiativeEvent, InitiativeState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useInitiativeState(): readonly [
InitiativeState | null,
{ dispatch: (event: InitiativeEvent) => Promise<InitiativeState> },
] {
const api = getDndApi();
const [state, setState] = useState<InitiativeState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.initiative.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.initiative.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
const r = await api.invoke(ipcChannels.initiative.dispatch, { event });
setState(r.state);
return r.state;
},
},
] as const;
}