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
+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}`;
}