feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -21,4 +21,5 @@ export * from './presentationSettings';
|
||||
export * from './sceneTraps';
|
||||
export * from './sceneView';
|
||||
export * from './sessionTimer';
|
||||
export * from './initiative';
|
||||
export * from './videoPlayback';
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user