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
+2
View File
@@ -24,6 +24,7 @@ export type AppWindowKind =
| 'materials'
| 'playlists'
| 'timer'
| 'initiative'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
@@ -38,6 +39,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
materials: { ru: 'Материалы', en: 'Materials' },
playlists: { ru: 'Плейлисты', en: 'Playlists' },
timer: { ru: 'Таймер', en: 'Timer' },
initiative: { ru: 'Инициатива', en: 'Initiative' },
npcsEditor: { ru: 'НПС', en: 'NPCs' },
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
+27
View File
@@ -57,6 +57,8 @@ import type {
SceneViewState,
SessionTimerEvent,
SessionTimerState,
InitiativeEvent,
InitiativeState,
TokenId,
TokenPathSessionEvent,
TokenPathSessionState,
@@ -167,6 +169,8 @@ export const ipcChannels = {
closePlaylists: 'windows.closePlaylists',
openTimer: 'windows.openTimer',
closeTimer: 'windows.closeTimer',
openInitiative: 'windows.openInitiative',
closeInitiative: 'windows.closeInitiative',
openNpcsEditor: 'windows.openNpcsEditor',
closeNpcsEditor: 'windows.closeNpcsEditor',
openNpcs: 'windows.openNpcs',
@@ -209,6 +213,11 @@ export const ipcChannels = {
dispatch: 'sessionTimer.dispatch',
stateChanged: 'sessionTimer.stateChanged',
},
initiative: {
getState: 'initiative.getState',
dispatch: 'initiative.dispatch',
stateChanged: 'initiative.stateChanged',
},
sceneDarkness: {
getState: 'sceneDarkness.getState',
dispatch: 'sceneDarkness.dispatch',
@@ -367,6 +376,7 @@ export type IpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
@@ -804,6 +814,14 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openInitiative]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeInitiative]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openNpcsEditor]: {
req: Record<string, never>;
res: { ok: true };
@@ -868,6 +886,14 @@ export type IpcInvokeMap = {
req: { event: SessionTimerEvent };
res: { state: SessionTimerState };
};
[ipcChannels.initiative.getState]: {
req: Record<string, never>;
res: { state: InitiativeState };
};
[ipcChannels.initiative.dispatch]: {
req: { event: InitiativeEvent };
res: { state: InitiativeState };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
@@ -1121,6 +1147,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
+1
View File
@@ -21,4 +21,5 @@ export * from './presentationSettings';
export * from './sceneTraps';
export * from './sceneView';
export * from './sessionTimer';
export * from './initiative';
export * from './videoPlayback';
+152
View File
@@ -0,0 +1,152 @@
/** Состояния (conditions) для трекера инициативы. */
export const INITIATIVE_CONDITION_IDS = [
'frightened',
'exhaustion',
'concentration',
'invisible',
'incapacitated',
'petrified',
'restrained',
'blinded',
'poisoned',
'charmed',
'stunned',
'paralyzed',
'prone',
'grappled',
'unconscious',
'deafened',
] as const;
export type InitiativeConditionId = (typeof INITIATIVE_CONDITION_IDS)[number];
export function isInitiativeConditionId(raw: string): raw is InitiativeConditionId {
return (INITIATIVE_CONDITION_IDS as readonly string[]).includes(raw);
}
/** Палитра градиентов для ручных слотов (фиксируется при создании). */
export const INITIATIVE_MANUAL_GRADIENTS = [
'linear-gradient(135deg, #3b82f6 0%, #1e3a8a 100%)',
'linear-gradient(135deg, #22c55e 0%, #14532d 100%)',
'linear-gradient(135deg, #f59e0b 0%, #92400e 100%)',
'linear-gradient(135deg, #ef4444 0%, #7f1d1d 100%)',
'linear-gradient(135deg, #a855f7 0%, #4c1d95 100%)',
'linear-gradient(135deg, #06b6d4 0%, #155e75 100%)',
'linear-gradient(135deg, #ec4899 0%, #9d174d 100%)',
'linear-gradient(135deg, #84cc16 0%, #3f6212 100%)',
] as const;
export type InitiativeEntryKind = 'npc' | 'player' | 'manual';
export type InitiativeNpcEntry = {
id: string;
kind: 'npc';
placementId: string;
npcId: string;
name: string;
avatarAssetId: string | null;
};
export type InitiativePlayerEntry = {
id: string;
kind: 'player';
playerId: string;
name: string;
};
export type InitiativeManualEntry = {
id: string;
kind: 'manual';
name: string;
letter: string;
gradient: string;
};
export type InitiativeEntry = InitiativeNpcEntry | InitiativePlayerEntry | InitiativeManualEntry;
export type InitiativePresenceNpc = {
placementId: string;
npcId: string;
name: string;
avatarAssetId: string | null;
};
export type InitiativePresencePlayer = {
playerId: string;
name: string;
};
export type InitiativeState = {
revision: number;
/** Окно трекера открыто — курсор на сцене виден. */
enabled: boolean;
entries: Record<string, InitiativeEntry>;
/** Активная очередь (слева направо). */
order: string[];
/** Запас: удалены из очереди / скрытые игроки. */
reserve: string[];
activeId: string | null;
conditions: Record<string, InitiativeConditionId[]>;
/** Последняя известная видимость токенов игроков на карте. */
playersVisible: boolean;
};
export type InitiativeEvent =
| { kind: 'setEnabled'; enabled: boolean }
| {
kind: 'syncPresence';
npcs: readonly InitiativePresenceNpc[];
players: readonly InitiativePresencePlayer[];
playersVisible: boolean;
}
| { kind: 'reorder'; order: readonly string[] }
| { kind: 'setActive'; id: string | null }
| { kind: 'next' }
| { kind: 'addManual'; name: string }
| { kind: 'moveToReserve'; id: string }
| { kind: 'restoreFromReserve'; id: string }
| { kind: 'deleteEntry'; id: string }
| { kind: 'addCondition'; id: string; conditionId: InitiativeConditionId }
| { kind: 'removeCondition'; id: string; conditionId: InitiativeConditionId }
| { kind: 'clear' };
export function createInitialInitiativeState(): InitiativeState {
return {
revision: 1,
enabled: false,
entries: {},
order: [],
reserve: [],
activeId: null,
conditions: {},
playersVisible: false,
};
}
export function newInitiativeManualId(): string {
return `init_manual_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export function initiativeManualLetter(name: string): string {
const t = name.trim();
if (!t) return '?';
const ch = [...t][0];
return (ch ?? '?').toUpperCase();
}
export function pickInitiativeManualGradient(seed?: number): string {
const n = INITIATIVE_MANUAL_GRADIENTS.length;
const idx =
typeof seed === 'number' && Number.isFinite(seed)
? Math.abs(Math.floor(seed)) % n
: Math.floor(Math.random() * n);
return INITIATIVE_MANUAL_GRADIENTS[idx]!;
}
export function initiativeNpcEntryId(placementId: string): string {
return `init_npc_${placementId}`;
}
export function initiativePlayerEntryId(playerId: string): string {
return `init_player_${playerId}`;
}