feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../shared/project/projectZipExtension';
|
} from '../shared/project/projectZipExtension';
|
||||||
import type { Project } from '../shared/types';
|
import type { Project } from '../shared/types';
|
||||||
import { asNpcId } from '../shared/types/ids';
|
import { asNpcId } from '../shared/types/ids';
|
||||||
|
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
||||||
import {
|
import {
|
||||||
asPreviewRotationDeg,
|
asPreviewRotationDeg,
|
||||||
previewRotationStepsCw,
|
previewRotationStepsCw,
|
||||||
@@ -35,6 +36,7 @@ import { readPresentationSettings, writePresentationSettings } from './preferenc
|
|||||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||||
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
||||||
|
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
|
||||||
import { ZipProjectStore } from './project/zipStore';
|
import { ZipProjectStore } from './project/zipStore';
|
||||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
@@ -69,6 +71,7 @@ import {
|
|||||||
openMaterialsWindow,
|
openMaterialsWindow,
|
||||||
openPlaylistsWindow,
|
openPlaylistsWindow,
|
||||||
openTimerWindow,
|
openTimerWindow,
|
||||||
|
openInitiativeWindow,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
openNpcsEditorWindow,
|
openNpcsEditorWindow,
|
||||||
openSceneEditorWindow,
|
openSceneEditorWindow,
|
||||||
@@ -78,6 +81,7 @@ import {
|
|||||||
closeMaterialsWindow,
|
closeMaterialsWindow,
|
||||||
closePlaylistsWindow,
|
closePlaylistsWindow,
|
||||||
closeTimerWindow,
|
closeTimerWindow,
|
||||||
|
closeInitiativeWindow,
|
||||||
closeNpcsEditorWindow,
|
closeNpcsEditorWindow,
|
||||||
closeSceneEditorWindow,
|
closeSceneEditorWindow,
|
||||||
closeTokenPathEditorWindow,
|
closeTokenPathEditorWindow,
|
||||||
@@ -182,6 +186,7 @@ const videoStore = new VideoPlaybackStore();
|
|||||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||||
const npcsOverlayStore = new NpcsOverlayStore();
|
const npcsOverlayStore = new NpcsOverlayStore();
|
||||||
const timerSessionStore = new TimerSessionStore();
|
const timerSessionStore = new TimerSessionStore();
|
||||||
|
const initiativeSessionStore = new InitiativeSessionStore();
|
||||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||||
@@ -283,6 +288,64 @@ function emitSessionTimerState(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitInitiativeState(): void {
|
||||||
|
const state = initiativeSessionStore.getState();
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||||
|
try {
|
||||||
|
win.webContents.send(ipcChannels.initiative.stateChanged, { state });
|
||||||
|
} catch {
|
||||||
|
// Окно может умирать при закрытии пульта/сессии (дочернее initiative).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncInitiativePresenceFromSession(): void {
|
||||||
|
const project = projectStore.getOpenProject();
|
||||||
|
const sceneId = project?.currentSceneId ?? null;
|
||||||
|
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||||
|
const npcSession = sceneNpcTokensSessionStore.getState();
|
||||||
|
const placements = listVisibleSceneNpcTokens(scene?.npcTokens, npcSession.spawned, sceneId);
|
||||||
|
const npcById = new Map((project?.npcs ?? []).map((n) => [String(n.id), n]));
|
||||||
|
const npcs = placements.map((p) => {
|
||||||
|
const npc = npcById.get(String(p.npcId));
|
||||||
|
return {
|
||||||
|
placementId: String(p.id),
|
||||||
|
npcId: String(p.npcId),
|
||||||
|
name: npc?.name?.trim() || 'NPC',
|
||||||
|
avatarAssetId: npc?.avatarAssetId ? String(npc.avatarAssetId) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const playerSession = scenePlayerTokensSessionStore.getState();
|
||||||
|
const playersLib = playersStore?.listPlayers() ?? [];
|
||||||
|
const playerById = new Map(playersLib.map((p) => [String(p.id), p]));
|
||||||
|
const players = playerSession.selectedPlayerIds.map((id) => {
|
||||||
|
const p = playerById.get(String(id));
|
||||||
|
return {
|
||||||
|
playerId: String(id),
|
||||||
|
name: p?.name?.trim() || 'Player',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = initiativeSessionStore.getState().revision;
|
||||||
|
initiativeSessionStore.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs,
|
||||||
|
players,
|
||||||
|
playersVisible: playerSession.visible,
|
||||||
|
});
|
||||||
|
if (initiativeSessionStore.getState().revision !== before) {
|
||||||
|
emitInitiativeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAndResyncInitiative(): void {
|
||||||
|
initiativeSessionStore.clear();
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
emitInitiativeState();
|
||||||
|
}
|
||||||
|
|
||||||
function emitSceneDarknessState(): void {
|
function emitSceneDarknessState(): void {
|
||||||
const state = sceneDarknessStore.getState();
|
const state = sceneDarknessStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
@@ -573,6 +636,7 @@ async function main() {
|
|||||||
scenePlayerTokensSessionStore.reset();
|
scenePlayerTokensSessionStore.reset();
|
||||||
tokenGridSnapSessionStore.reset();
|
tokenGridSnapSessionStore.reset();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
|
initiativeSessionStore.clear();
|
||||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||||
if (playerIds.length > 0) {
|
if (playerIds.length > 0) {
|
||||||
@@ -599,13 +663,17 @@ async function main() {
|
|||||||
emitTokenGridSnapState();
|
emitTokenGridSnapState();
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
emitInitiativeState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||||
closeMultiWindow();
|
closeMultiWindow();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
|
initiativeSessionStore.clear();
|
||||||
tokenGridSnapSessionStore.reset();
|
tokenGridSnapSessionStore.reset();
|
||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
|
emitInitiativeState();
|
||||||
emitTokenGridSnapState();
|
emitTokenGridSnapState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -669,6 +737,24 @@ async function main() {
|
|||||||
closeTimerWindow();
|
closeTimerWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.windows.openInitiative, () => {
|
||||||
|
openInitiativeWindow({
|
||||||
|
onClosed: () => {
|
||||||
|
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
|
||||||
|
emitInitiativeState();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: true });
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
emitInitiativeState();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.windows.closeInitiative, () => {
|
||||||
|
closeInitiativeWindow();
|
||||||
|
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
|
||||||
|
emitInitiativeState();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||||
openNpcsEditorWindow();
|
openNpcsEditorWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -733,6 +819,14 @@ async function main() {
|
|||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
return { state: timerSessionStore.getState() };
|
return { state: timerSessionStore.getState() };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.initiative.getState, () => {
|
||||||
|
return { state: initiativeSessionStore.getState() };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.initiative.dispatch, ({ event }) => {
|
||||||
|
initiativeSessionStore.dispatch(event);
|
||||||
|
emitInitiativeState();
|
||||||
|
return { state: initiativeSessionStore.getState() };
|
||||||
|
});
|
||||||
|
|
||||||
registerHandler(ipcChannels.project.list, async () => {
|
registerHandler(ipcChannels.project.list, async () => {
|
||||||
const projects = await projectStore.listProjects();
|
const projects = await projectStore.listProjects();
|
||||||
@@ -775,6 +869,7 @@ async function main() {
|
|||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
|
initiativeSessionStore.clear();
|
||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
sceneMarkerStore.resetSession();
|
sceneMarkerStore.resetSession();
|
||||||
sceneTrapsStore.resetSession();
|
sceneTrapsStore.resetSession();
|
||||||
@@ -788,6 +883,7 @@ async function main() {
|
|||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
|
emitInitiativeState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
emitSceneMarkerState();
|
emitSceneMarkerState();
|
||||||
emitSceneTrapsState();
|
emitSceneTrapsState();
|
||||||
@@ -813,6 +909,7 @@ async function main() {
|
|||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
scenePlayerTokensSessionStore.clearPlacements();
|
scenePlayerTokensSessionStore.clearPlacements();
|
||||||
|
clearAndResyncInitiative();
|
||||||
// Token moves persist for the whole play session (reset only on project open/close).
|
// Token moves persist for the whole play session (reset only on project open/close).
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) {
|
if (project) {
|
||||||
@@ -843,6 +940,7 @@ async function main() {
|
|||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
scenePlayerTokensSessionStore.clearPlacements();
|
scenePlayerTokensSessionStore.clearPlacements();
|
||||||
|
clearAndResyncInitiative();
|
||||||
// Token moves persist for the whole play session (reset only on project open/close).
|
// Token moves persist for the whole play session (reset only on project open/close).
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) {
|
if (project) {
|
||||||
@@ -902,6 +1000,9 @@ async function main() {
|
|||||||
syncSceneTrapsForProject(project);
|
syncSceneTrapsForProject(project);
|
||||||
emitSceneTrapsState();
|
emitSceneTrapsState();
|
||||||
}
|
}
|
||||||
|
if (project?.currentSceneId === sceneId && patch.npcTokens !== undefined) {
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
}
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { scene: next };
|
return { scene: next };
|
||||||
});
|
});
|
||||||
@@ -1879,6 +1980,9 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
|
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
|
||||||
sceneNpcTokensSessionStore.dispatch(event);
|
sceneNpcTokensSessionStore.dispatch(event);
|
||||||
emitSceneNpcTokensSessionState();
|
emitSceneNpcTokensSessionState();
|
||||||
|
if (event.kind === 'spawn' || event.kind === 'despawn' || event.kind === 'clear') {
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
|
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
|
||||||
@@ -1935,6 +2039,7 @@ async function main() {
|
|||||||
disposition: npc.disposition,
|
disposition: npc.disposition,
|
||||||
});
|
});
|
||||||
emitSceneNpcTokensSessionState();
|
emitSceneNpcTokensSessionState();
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
return { spawned: true };
|
return { spawned: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
|
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
|
||||||
@@ -1943,6 +2048,15 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
|
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
|
||||||
scenePlayerTokensSessionStore.dispatch(event);
|
scenePlayerTokensSessionStore.dispatch(event);
|
||||||
emitScenePlayerTokensSessionState();
|
emitScenePlayerTokensSessionState();
|
||||||
|
if (
|
||||||
|
event.kind === 'setSelection' ||
|
||||||
|
event.kind === 'setVisible' ||
|
||||||
|
event.kind === 'show' ||
|
||||||
|
event.kind === 'clear' ||
|
||||||
|
event.kind === 'clearPlacements'
|
||||||
|
) {
|
||||||
|
syncInitiativePresenceFromSession();
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { InitiativeSessionStore } from './initiativeSessionStore';
|
||||||
|
import {
|
||||||
|
initiativeNpcEntryId,
|
||||||
|
initiativePlayerEntryId,
|
||||||
|
} from '../../shared/types/initiative';
|
||||||
|
|
||||||
|
void test('initiative: addManual + next cycles order', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'A' });
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'B' });
|
||||||
|
const a = store.getState().order[0]!;
|
||||||
|
const b = store.getState().order[1]!;
|
||||||
|
assert.equal(store.getState().activeId, a);
|
||||||
|
store.dispatch({ kind: 'next' });
|
||||||
|
assert.equal(store.getState().activeId, b);
|
||||||
|
store.dispatch({ kind: 'next' });
|
||||||
|
assert.equal(store.getState().activeId, a);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: syncPresence adds npcs and players', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
const st = store.getState();
|
||||||
|
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
|
||||||
|
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
|
||||||
|
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: hide players moves them to reserve', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
const pid = initiativePlayerEntryId('pl1');
|
||||||
|
assert.ok(store.getState().order.includes(pid));
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
assert.ok(!store.getState().order.includes(pid));
|
||||||
|
assert.ok(store.getState().reserve.includes(pid));
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
assert.ok(store.getState().reserve.includes(pid));
|
||||||
|
assert.ok(!store.getState().order.includes(pid));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: conditions unique + reserve/restore', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'X' });
|
||||||
|
const id = store.getState().order[0]!;
|
||||||
|
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||||
|
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||||
|
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||||
|
store.dispatch({ kind: 'moveToReserve', id });
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
assert.deepEqual(store.getState().reserve, [id]);
|
||||||
|
store.dispatch({ kind: 'restoreFromReserve', id });
|
||||||
|
assert.deepEqual(store.getState().order, [id]);
|
||||||
|
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: clear keeps enabled', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'setEnabled', enabled: true });
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'Z' });
|
||||||
|
store.clear();
|
||||||
|
assert.equal(store.getState().enabled, true);
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: despawn removes npc entry', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||||
|
players: [],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
assert.deepEqual(Object.keys(store.getState().entries), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
createInitialInitiativeState,
|
||||||
|
initiativeManualLetter,
|
||||||
|
initiativeNpcEntryId,
|
||||||
|
initiativePlayerEntryId,
|
||||||
|
isInitiativeConditionId,
|
||||||
|
newInitiativeManualId,
|
||||||
|
pickInitiativeManualGradient,
|
||||||
|
type InitiativeEntry,
|
||||||
|
type InitiativeEvent,
|
||||||
|
type InitiativePresenceNpc,
|
||||||
|
type InitiativePresencePlayer,
|
||||||
|
type InitiativeState,
|
||||||
|
} from '../../shared/types/initiative';
|
||||||
|
|
||||||
|
function withoutId(ids: readonly string[], id: string): string[] {
|
||||||
|
return ids.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveValid(state: InitiativeState): InitiativeState {
|
||||||
|
if (state.activeId != null && state.order.includes(state.activeId)) return state;
|
||||||
|
const nextActive = state.order[0] ?? null;
|
||||||
|
if (state.activeId === nextActive) return state;
|
||||||
|
return { ...state, activeId: nextActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InitiativeSessionStore {
|
||||||
|
private state: InitiativeState = createInitialInitiativeState();
|
||||||
|
|
||||||
|
getState(): InitiativeState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): InitiativeState {
|
||||||
|
const enabled = this.state.enabled;
|
||||||
|
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: InitiativeEvent): InitiativeState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'clear': {
|
||||||
|
this.state = {
|
||||||
|
...createInitialInitiativeState(),
|
||||||
|
enabled: this.state.enabled,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setEnabled': {
|
||||||
|
if (this.state.enabled === event.enabled) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
enabled: event.enabled,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'syncPresence': {
|
||||||
|
return this.syncPresence(event.npcs, event.players, event.playersVisible);
|
||||||
|
}
|
||||||
|
case 'reorder': {
|
||||||
|
const allowed = new Set(this.state.order);
|
||||||
|
const nextOrder = event.order.filter((id) => allowed.has(id));
|
||||||
|
for (const id of this.state.order) {
|
||||||
|
if (!nextOrder.includes(id)) nextOrder.push(id);
|
||||||
|
}
|
||||||
|
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order: nextOrder,
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setActive': {
|
||||||
|
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
|
||||||
|
if (this.state.activeId === event.id) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeId: event.id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'next': {
|
||||||
|
if (this.state.order.length === 0) {
|
||||||
|
if (this.state.activeId == null) return this.state;
|
||||||
|
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
|
||||||
|
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
|
||||||
|
const nextId = this.state.order[nextIdx] ?? null;
|
||||||
|
if (nextId === this.state.activeId) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeId: nextId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'addManual': {
|
||||||
|
const name = event.name.trim();
|
||||||
|
if (!name) return this.state;
|
||||||
|
const id = newInitiativeManualId();
|
||||||
|
const entry: InitiativeEntry = {
|
||||||
|
id,
|
||||||
|
kind: 'manual',
|
||||||
|
name,
|
||||||
|
letter: initiativeManualLetter(name),
|
||||||
|
gradient: pickInitiativeManualGradient(),
|
||||||
|
};
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries: { ...this.state.entries, [id]: entry },
|
||||||
|
order: [...this.state.order, id],
|
||||||
|
activeId: this.state.activeId ?? id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'moveToReserve': {
|
||||||
|
if (!this.state.order.includes(event.id)) return this.state;
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const order = withoutId(this.state.order, event.id);
|
||||||
|
const reserve = this.state.reserve.includes(event.id)
|
||||||
|
? this.state.reserve
|
||||||
|
: [...this.state.reserve, event.id];
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'restoreFromReserve': {
|
||||||
|
if (!this.state.reserve.includes(event.id)) return this.state;
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const reserve = withoutId(this.state.reserve, event.id);
|
||||||
|
const order = this.state.order.includes(event.id)
|
||||||
|
? this.state.order
|
||||||
|
: [...this.state.order, event.id];
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
activeId: this.state.activeId ?? event.id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'deleteEntry': {
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const { [event.id]: _removed, ...entries } = this.state.entries;
|
||||||
|
const { [event.id]: _c, ...conditions } = this.state.conditions;
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries,
|
||||||
|
conditions,
|
||||||
|
order: withoutId(this.state.order, event.id),
|
||||||
|
reserve: withoutId(this.state.reserve, event.id),
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'addCondition': {
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
if (!isInitiativeConditionId(event.conditionId)) return this.state;
|
||||||
|
const cur = this.state.conditions[event.id] ?? [];
|
||||||
|
if (cur.includes(event.conditionId)) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
conditions: {
|
||||||
|
...this.state.conditions,
|
||||||
|
[event.id]: [...cur, event.conditionId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'removeCondition': {
|
||||||
|
const cur = this.state.conditions[event.id];
|
||||||
|
if (!cur?.includes(event.conditionId)) return this.state;
|
||||||
|
const next = cur.filter((c) => c !== event.conditionId);
|
||||||
|
const conditions = { ...this.state.conditions };
|
||||||
|
if (next.length === 0) delete conditions[event.id];
|
||||||
|
else conditions[event.id] = next;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
conditions,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPresence(
|
||||||
|
npcs: readonly InitiativePresenceNpc[],
|
||||||
|
players: readonly InitiativePresencePlayer[],
|
||||||
|
playersVisible: boolean,
|
||||||
|
): InitiativeState {
|
||||||
|
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
|
||||||
|
const playerById = new Map(players.map((p) => [p.playerId, p]));
|
||||||
|
|
||||||
|
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
|
||||||
|
let order = [...this.state.order];
|
||||||
|
let reserve = [...this.state.reserve];
|
||||||
|
let conditions = { ...this.state.conditions };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [id, entry] of Object.entries(entries)) {
|
||||||
|
if (entry.kind === 'manual') continue;
|
||||||
|
if (entry.kind === 'npc') {
|
||||||
|
const live = npcByPlacement.get(entry.placementId);
|
||||||
|
if (!live) {
|
||||||
|
delete entries[id];
|
||||||
|
delete conditions[id];
|
||||||
|
order = withoutId(order, id);
|
||||||
|
reserve = withoutId(reserve, id);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
|
||||||
|
entries[id] = {
|
||||||
|
...entry,
|
||||||
|
npcId: live.npcId,
|
||||||
|
name: live.name,
|
||||||
|
avatarAssetId: live.avatarAssetId,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const live = playerById.get(entry.playerId);
|
||||||
|
if (!live) {
|
||||||
|
delete entries[id];
|
||||||
|
delete conditions[id];
|
||||||
|
order = withoutId(order, id);
|
||||||
|
reserve = withoutId(reserve, id);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name !== live.name) {
|
||||||
|
entries[id] = { ...entry, name: live.name };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const npc of npcs) {
|
||||||
|
const id = initiativeNpcEntryId(npc.placementId);
|
||||||
|
if (entries[id]) continue;
|
||||||
|
entries[id] = {
|
||||||
|
id,
|
||||||
|
kind: 'npc',
|
||||||
|
placementId: npc.placementId,
|
||||||
|
npcId: npc.npcId,
|
||||||
|
name: npc.name,
|
||||||
|
avatarAssetId: npc.avatarAssetId,
|
||||||
|
};
|
||||||
|
order.push(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const id = initiativePlayerEntryId(player.playerId);
|
||||||
|
if (entries[id]) continue;
|
||||||
|
entries[id] = {
|
||||||
|
id,
|
||||||
|
kind: 'player',
|
||||||
|
playerId: player.playerId,
|
||||||
|
name: player.name,
|
||||||
|
};
|
||||||
|
order.push(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasVisible = this.state.playersVisible;
|
||||||
|
if (wasVisible && !playersVisible) {
|
||||||
|
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
|
||||||
|
if (playerIdsInOrder.length > 0) {
|
||||||
|
for (const id of playerIdsInOrder) {
|
||||||
|
order = withoutId(order, id);
|
||||||
|
if (!reserve.includes(id)) reserve.push(id);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.playersVisible !== playersVisible) changed = true;
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
// Still update playersVisible if somehow same structure
|
||||||
|
if (this.state.playersVisible === playersVisible) return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next: InitiativeState = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
conditions,
|
||||||
|
playersVisible,
|
||||||
|
};
|
||||||
|
next = ensureActiveValid(next);
|
||||||
|
this.state = next;
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,14 @@ void test('createWindows: окно таймера закрывается с mult
|
|||||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно инициативы закрывается с multi-window', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openInitiativeWindow'));
|
||||||
|
assert.ok(src.includes('closeInitiativeWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('initiative'"));
|
||||||
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('openNpcsWindow'));
|
assert.ok(src.includes('openNpcsWindow'));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type WindowKind =
|
|||||||
| 'materials'
|
| 'materials'
|
||||||
| 'playlists'
|
| 'playlists'
|
||||||
| 'timer'
|
| 'timer'
|
||||||
|
| 'initiative'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
@@ -64,6 +65,10 @@ const PLAYLISTS_WINDOW_HEIGHT = 800;
|
|||||||
const TIMER_WINDOW_WIDTH = 360;
|
const TIMER_WINDOW_WIDTH = 360;
|
||||||
const TIMER_WINDOW_HEIGHT = 420;
|
const TIMER_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
|
/** Окно трекера инициативы. */
|
||||||
|
const INITIATIVE_WINDOW_WIDTH = 920;
|
||||||
|
const INITIATIVE_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||||
@@ -175,6 +180,7 @@ function closePlaySessionAuxiliaryWindows(): void {
|
|||||||
closeMaterialsWindow();
|
closeMaterialsWindow();
|
||||||
closePlaylistsWindow();
|
closePlaylistsWindow();
|
||||||
closeTimerWindow();
|
closeTimerWindow();
|
||||||
|
closeInitiativeWindow();
|
||||||
closeNpcsWindow();
|
closeNpcsWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +233,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'playlists.html';
|
return 'playlists.html';
|
||||||
case 'timer':
|
case 'timer':
|
||||||
return 'timer.html';
|
return 'timer.html';
|
||||||
|
case 'initiative':
|
||||||
|
return 'initiative.html';
|
||||||
case 'npcsEditor':
|
case 'npcsEditor':
|
||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
case 'sceneEditor':
|
case 'sceneEditor':
|
||||||
@@ -304,6 +312,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
|||||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||||
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
||||||
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
|
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
|
||||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
@@ -620,6 +629,13 @@ export function closeTimerWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeInitiativeWindow(): void {
|
||||||
|
const win = windows.get('initiative');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeNpcsEditorWindow(): void {
|
export function closeNpcsEditorWindow(): void {
|
||||||
const win = windows.get('npcsEditor');
|
const win = windows.get('npcsEditor');
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
@@ -839,6 +855,41 @@ export function openTimerWindow(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
|
||||||
|
const existing = windows.get('initiative');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('initiative', parent ? { parent } : undefined);
|
||||||
|
if (opts?.onClosed) {
|
||||||
|
win.once('closed', () => {
|
||||||
|
opts.onClosed?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||||
const parent = windows.get('editor');
|
const parent = windows.get('editor');
|
||||||
const { width, height } = win.getBounds();
|
const { width, height } = win.getBounds();
|
||||||
|
|||||||
@@ -1859,6 +1859,22 @@ export function ControlApp() {
|
|||||||
⏱
|
⏱
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
iconOnly
|
||||||
|
title={t('control.initiativeTool')}
|
||||||
|
ariaLabel={t('control.initiativeTool')}
|
||||||
|
data-testid="control-initiative-tool"
|
||||||
|
onClick={() => {
|
||||||
|
void api.invoke(ipcChannels.windows.openInitiative, {}).catch((err) => {
|
||||||
|
console.error('[control] openInitiative failed', err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph} aria-hidden>
|
||||||
|
⚔
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconOnly
|
iconOnly
|
||||||
|
|||||||
@@ -659,6 +659,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.materialsTool': 'Материалы',
|
'control.materialsTool': 'Материалы',
|
||||||
'control.playlistsTool': 'Плейлисты',
|
'control.playlistsTool': 'Плейлисты',
|
||||||
'control.timerTool': 'Таймер',
|
'control.timerTool': 'Таймер',
|
||||||
|
'control.initiativeTool': 'Инициатива',
|
||||||
'control.npcsTool': 'НПС',
|
'control.npcsTool': 'НПС',
|
||||||
|
|
||||||
'sessionTimer.duration': 'Длительность (MM:SS)',
|
'sessionTimer.duration': 'Длительность (MM:SS)',
|
||||||
@@ -668,6 +669,37 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'sessionTimer.showToPlayers': 'Показать игрокам',
|
'sessionTimer.showToPlayers': 'Показать игрокам',
|
||||||
'sessionTimer.positionHint':
|
'sessionTimer.positionHint':
|
||||||
'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
|
'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
|
||||||
|
|
||||||
|
'initiative.next': 'Следующий',
|
||||||
|
'initiative.queue': 'Очередь',
|
||||||
|
'initiative.queueEmpty': 'Очередь пуста — добавьте вручную или разместите НПС на сцене',
|
||||||
|
'initiative.reserve': 'Запас',
|
||||||
|
'initiative.reserveEmpty': 'Запас пуст',
|
||||||
|
'initiative.addManual': 'Добавить',
|
||||||
|
'initiative.manualPlaceholder': 'Имя вручную',
|
||||||
|
'initiative.toReserve': 'В запас',
|
||||||
|
'initiative.restore': 'Вернуть',
|
||||||
|
'initiative.delete': 'Удалить',
|
||||||
|
'initiative.conditions': 'Состояния',
|
||||||
|
'initiative.conditionsEmpty': 'Все состояния уже добавлены',
|
||||||
|
'initiative.removeCondition': 'Убрать состояние',
|
||||||
|
'initiative.condition.frightened': 'Испуган',
|
||||||
|
'initiative.condition.exhaustion': 'Истощение',
|
||||||
|
'initiative.condition.concentration': 'Концентрация',
|
||||||
|
'initiative.condition.invisible': 'Невидимый',
|
||||||
|
'initiative.condition.incapacitated': 'Недееспособен',
|
||||||
|
'initiative.condition.petrified': 'Окаменевший',
|
||||||
|
'initiative.condition.restrained': 'Опутан',
|
||||||
|
'initiative.condition.blinded': 'Ослеплён',
|
||||||
|
'initiative.condition.poisoned': 'Отравлен',
|
||||||
|
'initiative.condition.charmed': 'Очарован',
|
||||||
|
'initiative.condition.stunned': 'Ошеломлён',
|
||||||
|
'initiative.condition.paralyzed': 'Парализован',
|
||||||
|
'initiative.condition.prone': 'Сбит с ног',
|
||||||
|
'initiative.condition.grappled': 'Схвачен',
|
||||||
|
'initiative.condition.unconscious': 'Без сознания',
|
||||||
|
'initiative.condition.deafened': 'Оглохший',
|
||||||
|
|
||||||
'control.descriptionMissing': 'Описание отсутствует',
|
'control.descriptionMissing': 'Описание отсутствует',
|
||||||
'control.effects': 'ЭФФЕКТЫ',
|
'control.effects': 'ЭФФЕКТЫ',
|
||||||
'control.tools': 'Очистка',
|
'control.tools': 'Очистка',
|
||||||
@@ -1359,6 +1391,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.materialsTool': 'Materials',
|
'control.materialsTool': 'Materials',
|
||||||
'control.playlistsTool': 'Playlists',
|
'control.playlistsTool': 'Playlists',
|
||||||
'control.timerTool': 'Timer',
|
'control.timerTool': 'Timer',
|
||||||
|
'control.initiativeTool': 'Initiative',
|
||||||
'control.npcsTool': 'NPCs',
|
'control.npcsTool': 'NPCs',
|
||||||
|
|
||||||
'sessionTimer.duration': 'Duration (MM:SS)',
|
'sessionTimer.duration': 'Duration (MM:SS)',
|
||||||
@@ -1368,6 +1401,37 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'sessionTimer.showToPlayers': 'Show to players',
|
'sessionTimer.showToPlayers': 'Show to players',
|
||||||
'sessionTimer.positionHint':
|
'sessionTimer.positionHint':
|
||||||
'When shown, drag the timer on the control preview; corners resize, the top handle rotates.',
|
'When shown, drag the timer on the control preview; corners resize, the top handle rotates.',
|
||||||
|
|
||||||
|
'initiative.next': 'Next',
|
||||||
|
'initiative.queue': 'Queue',
|
||||||
|
'initiative.queueEmpty': 'Queue is empty — add manually or place NPCs on the scene',
|
||||||
|
'initiative.reserve': 'Reserve',
|
||||||
|
'initiative.reserveEmpty': 'Reserve is empty',
|
||||||
|
'initiative.addManual': 'Add',
|
||||||
|
'initiative.manualPlaceholder': 'Manual name',
|
||||||
|
'initiative.toReserve': 'To reserve',
|
||||||
|
'initiative.restore': 'Restore',
|
||||||
|
'initiative.delete': 'Delete',
|
||||||
|
'initiative.conditions': 'Conditions',
|
||||||
|
'initiative.conditionsEmpty': 'All conditions already added',
|
||||||
|
'initiative.removeCondition': 'Remove condition',
|
||||||
|
'initiative.condition.frightened': 'Frightened',
|
||||||
|
'initiative.condition.exhaustion': 'Exhaustion',
|
||||||
|
'initiative.condition.concentration': 'Concentration',
|
||||||
|
'initiative.condition.invisible': 'Invisible',
|
||||||
|
'initiative.condition.incapacitated': 'Incapacitated',
|
||||||
|
'initiative.condition.petrified': 'Petrified',
|
||||||
|
'initiative.condition.restrained': 'Restrained',
|
||||||
|
'initiative.condition.blinded': 'Blinded',
|
||||||
|
'initiative.condition.poisoned': 'Poisoned',
|
||||||
|
'initiative.condition.charmed': 'Charmed',
|
||||||
|
'initiative.condition.stunned': 'Stunned',
|
||||||
|
'initiative.condition.paralyzed': 'Paralyzed',
|
||||||
|
'initiative.condition.prone': 'Prone',
|
||||||
|
'initiative.condition.grappled': 'Grappled',
|
||||||
|
'initiative.condition.unconscious': 'Unconscious',
|
||||||
|
'initiative.condition.deafened': 'Deafened',
|
||||||
|
|
||||||
'control.descriptionMissing': 'No description',
|
'control.descriptionMissing': 'No description',
|
||||||
'control.effects': 'EFFECTS',
|
'control.effects': 'EFFECTS',
|
||||||
'control.tools': 'Cleanup',
|
'control.tools': 'Cleanup',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/initiative/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { PresentationApp } from './PresentationApp';
|
import { PresentationApp } from './PresentationApp';
|
||||||
@@ -14,7 +15,9 @@ if (!rootEl) {
|
|||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<WindowErrorBoundary title="Презентация">
|
<WindowErrorBoundary title="Презентация">
|
||||||
<PresentationApp />
|
<EditorI18nProvider>
|
||||||
|
<PresentationApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
</WindowErrorBoundary>
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,27 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.initiativeActive {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
|
||||||
|
.initiativeActive::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #f59e0b;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.resizeHandle {
|
.resizeHandle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import { useAssetUrl } from '../useAssetImageUrl';
|
|||||||
|
|
||||||
import { PlayerTokenView } from './PlayerTokenView';
|
import { PlayerTokenView } from './PlayerTokenView';
|
||||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||||
|
import {
|
||||||
|
TokenInitiativeDecor,
|
||||||
|
initiativeConditionsForNpc,
|
||||||
|
initiativeHighlightForNpc,
|
||||||
|
} from '../initiative/TokenInitiativeDecor';
|
||||||
|
import { useInitiativeState } from '../initiative/useInitiativeState';
|
||||||
import styles from './SceneNpcTokensOverlay.module.css';
|
import styles from './SceneNpcTokensOverlay.module.css';
|
||||||
|
|
||||||
type Viewport = { x: number; y: number; w: number; h: number };
|
type Viewport = { x: number; y: number; w: number; h: number };
|
||||||
@@ -48,6 +54,8 @@ function NpcSprite({
|
|||||||
resizeTitle,
|
resizeTitle,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
snapNorm,
|
snapNorm,
|
||||||
|
initiativeActive = false,
|
||||||
|
initiativeConditions = [],
|
||||||
}: {
|
}: {
|
||||||
placement: SceneNpcToken;
|
placement: SceneNpcToken;
|
||||||
npc: ProjectNpc;
|
npc: ProjectNpc;
|
||||||
@@ -66,6 +74,8 @@ function NpcSprite({
|
|||||||
resizeTitle?: string;
|
resizeTitle?: string;
|
||||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
|
initiativeActive?: boolean;
|
||||||
|
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
||||||
}) {
|
}) {
|
||||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||||
@@ -123,8 +133,15 @@ function NpcSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
styles.token,
|
||||||
|
canDrag ? styles.editable : '',
|
||||||
|
initiativeActive ? styles.initiativeActive : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
data-testid={`session-npc-token-${placement.id}`}
|
data-testid={`session-npc-token-${placement.id}`}
|
||||||
|
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||||
style={{
|
style={{
|
||||||
left: viewport.x + pos.nx * viewport.w,
|
left: viewport.x + pos.nx * viewport.w,
|
||||||
top: viewport.y + pos.ny * viewport.h,
|
top: viewport.y + pos.ny * viewport.h,
|
||||||
@@ -189,6 +206,7 @@ function NpcSprite({
|
|||||||
sizePx={sizePx}
|
sizePx={sizePx}
|
||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
/>
|
/>
|
||||||
|
<TokenInitiativeDecor conditions={initiativeConditions} />
|
||||||
{canResize ? (
|
{canResize ? (
|
||||||
<div
|
<div
|
||||||
className={styles.resizeHandle}
|
className={styles.resizeHandle}
|
||||||
@@ -258,6 +276,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||||
dragEnabledById?: Record<string, boolean> | null;
|
dragEnabledById?: Record<string, boolean> | null;
|
||||||
}) {
|
}) {
|
||||||
|
const [initiative] = useInitiativeState();
|
||||||
if (!viewport) return null;
|
if (!viewport) return null;
|
||||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||||
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
||||||
@@ -288,6 +307,8 @@ export function SceneNpcTokensOverlay({
|
|||||||
gridFit={gridFit}
|
gridFit={gridFit}
|
||||||
disposition={disposition}
|
disposition={disposition}
|
||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
|
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
||||||
|
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
{...(onResize ? { onResize } : {})}
|
{...(onResize ? { onResize } : {})}
|
||||||
{...(resizeTitle ? { resizeTitle } : {})}
|
{...(resizeTitle ? { resizeTitle } : {})}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { usePlayerImageUrl } from './usePlayerImageUrl';
|
|||||||
|
|
||||||
import { PlayerTokenView } from './PlayerTokenView';
|
import { PlayerTokenView } from './PlayerTokenView';
|
||||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||||
|
import {
|
||||||
|
TokenInitiativeDecor,
|
||||||
|
initiativeConditionsForPlayer,
|
||||||
|
initiativeHighlightForPlayer,
|
||||||
|
} from '../initiative/TokenInitiativeDecor';
|
||||||
|
import { useInitiativeState } from '../initiative/useInitiativeState';
|
||||||
import styles from './SceneNpcTokensOverlay.module.css';
|
import styles from './SceneNpcTokensOverlay.module.css';
|
||||||
|
|
||||||
type Viewport = { x: number; y: number; w: number; h: number };
|
type Viewport = { x: number; y: number; w: number; h: number };
|
||||||
@@ -31,6 +37,8 @@ function PlayerSprite({
|
|||||||
onResize,
|
onResize,
|
||||||
resizeTitle,
|
resizeTitle,
|
||||||
snapNorm,
|
snapNorm,
|
||||||
|
initiativeActive = false,
|
||||||
|
initiativeConditions = [],
|
||||||
}: {
|
}: {
|
||||||
player: AppPlayer;
|
player: AppPlayer;
|
||||||
nx: number;
|
nx: number;
|
||||||
@@ -44,6 +52,8 @@ function PlayerSprite({
|
|||||||
onResize?: (playerId: string, sizeN: number) => void;
|
onResize?: (playerId: string, sizeN: number) => void;
|
||||||
resizeTitle?: string;
|
resizeTitle?: string;
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
|
initiativeActive?: boolean;
|
||||||
|
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
||||||
}) {
|
}) {
|
||||||
const imageUrl = usePlayerImageUrl(player.id);
|
const imageUrl = usePlayerImageUrl(player.id);
|
||||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||||
@@ -100,8 +110,15 @@ function PlayerSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
styles.token,
|
||||||
|
editable ? styles.editable : '',
|
||||||
|
initiativeActive ? styles.initiativeActive : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
data-testid={`session-player-token-${player.id}`}
|
data-testid={`session-player-token-${player.id}`}
|
||||||
|
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||||
style={{
|
style={{
|
||||||
left: viewport.x + pos.nx * viewport.w,
|
left: viewport.x + pos.nx * viewport.w,
|
||||||
top: viewport.y + pos.ny * viewport.h,
|
top: viewport.y + pos.ny * viewport.h,
|
||||||
@@ -155,6 +172,7 @@ function PlayerSprite({
|
|||||||
imageScale={player.imageScale}
|
imageScale={player.imageScale}
|
||||||
sizePx={sizePx}
|
sizePx={sizePx}
|
||||||
/>
|
/>
|
||||||
|
<TokenInitiativeDecor conditions={initiativeConditions} />
|
||||||
{canResize ? (
|
{canResize ? (
|
||||||
<div
|
<div
|
||||||
className={styles.resizeHandle}
|
className={styles.resizeHandle}
|
||||||
@@ -218,6 +236,7 @@ export function ScenePlayerTokensOverlay({
|
|||||||
resizeTitle?: string;
|
resizeTitle?: string;
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
|
const [initiative] = useInitiativeState();
|
||||||
if (!viewport || !session?.visible) return null;
|
if (!viewport || !session?.visible) return null;
|
||||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||||
const gridFit = sceneGridTokenFitFactor(grid);
|
const gridFit = sceneGridTokenFitFactor(grid);
|
||||||
@@ -238,6 +257,8 @@ export function ScenePlayerTokensOverlay({
|
|||||||
editable={editable}
|
editable={editable}
|
||||||
displayScale={displayScale}
|
displayScale={displayScale}
|
||||||
gridFit={gridFit}
|
gridFit={gridFit}
|
||||||
|
initiativeActive={initiativeHighlightForPlayer(initiative, playerId)}
|
||||||
|
initiativeConditions={initiativeConditionsForPlayer(initiative, playerId)}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
{...(onResize ? { onResize } : {})}
|
{...(onResize ? { onResize } : {})}
|
||||||
{...(resizeTitle ? { resizeTitle } : {})}
|
{...(resizeTitle ? { resizeTitle } : {})}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const SECONDARY_WINDOW_MAINS = [
|
|||||||
'materials/main.tsx',
|
'materials/main.tsx',
|
||||||
'playlists/main.tsx',
|
'playlists/main.tsx',
|
||||||
'timer/main.tsx',
|
'timer/main.tsx',
|
||||||
|
'initiative/main.tsx',
|
||||||
'sceneEditor/main.tsx',
|
'sceneEditor/main.tsx',
|
||||||
'sceneDescription/main.tsx',
|
'sceneDescription/main.tsx',
|
||||||
'control/main.tsx',
|
'control/main.tsx',
|
||||||
@@ -148,6 +149,12 @@ void test('ControlApp: Timer tool opens window', () => {
|
|||||||
assert.ok(src.includes('SessionTimerLayer'));
|
assert.ok(src.includes('SessionTimerLayer'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: Initiative tool opens window', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes('data-testid="control-initiative-tool"'));
|
||||||
|
assert.ok(src.includes('windows.openInitiative'));
|
||||||
|
});
|
||||||
|
|
||||||
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
||||||
const css = fs.readFileSync(
|
const css = fs.readFileSync(
|
||||||
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type AppWindowKind =
|
|||||||
| 'materials'
|
| 'materials'
|
||||||
| 'playlists'
|
| 'playlists'
|
||||||
| 'timer'
|
| 'timer'
|
||||||
|
| 'initiative'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
@@ -38,6 +39,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
|||||||
materials: { ru: 'Материалы', en: 'Materials' },
|
materials: { ru: 'Материалы', en: 'Materials' },
|
||||||
playlists: { ru: 'Плейлисты', en: 'Playlists' },
|
playlists: { ru: 'Плейлисты', en: 'Playlists' },
|
||||||
timer: { ru: 'Таймер', en: 'Timer' },
|
timer: { ru: 'Таймер', en: 'Timer' },
|
||||||
|
initiative: { ru: 'Инициатива', en: 'Initiative' },
|
||||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import type {
|
|||||||
SceneViewState,
|
SceneViewState,
|
||||||
SessionTimerEvent,
|
SessionTimerEvent,
|
||||||
SessionTimerState,
|
SessionTimerState,
|
||||||
|
InitiativeEvent,
|
||||||
|
InitiativeState,
|
||||||
TokenId,
|
TokenId,
|
||||||
TokenPathSessionEvent,
|
TokenPathSessionEvent,
|
||||||
TokenPathSessionState,
|
TokenPathSessionState,
|
||||||
@@ -167,6 +169,8 @@ export const ipcChannels = {
|
|||||||
closePlaylists: 'windows.closePlaylists',
|
closePlaylists: 'windows.closePlaylists',
|
||||||
openTimer: 'windows.openTimer',
|
openTimer: 'windows.openTimer',
|
||||||
closeTimer: 'windows.closeTimer',
|
closeTimer: 'windows.closeTimer',
|
||||||
|
openInitiative: 'windows.openInitiative',
|
||||||
|
closeInitiative: 'windows.closeInitiative',
|
||||||
openNpcsEditor: 'windows.openNpcsEditor',
|
openNpcsEditor: 'windows.openNpcsEditor',
|
||||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||||
openNpcs: 'windows.openNpcs',
|
openNpcs: 'windows.openNpcs',
|
||||||
@@ -209,6 +213,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'sessionTimer.dispatch',
|
dispatch: 'sessionTimer.dispatch',
|
||||||
stateChanged: 'sessionTimer.stateChanged',
|
stateChanged: 'sessionTimer.stateChanged',
|
||||||
},
|
},
|
||||||
|
initiative: {
|
||||||
|
getState: 'initiative.getState',
|
||||||
|
dispatch: 'initiative.dispatch',
|
||||||
|
stateChanged: 'initiative.stateChanged',
|
||||||
|
},
|
||||||
sceneDarkness: {
|
sceneDarkness: {
|
||||||
getState: 'sceneDarkness.getState',
|
getState: 'sceneDarkness.getState',
|
||||||
dispatch: 'sceneDarkness.dispatch',
|
dispatch: 'sceneDarkness.dispatch',
|
||||||
@@ -367,6 +376,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
||||||
|
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||||
@@ -804,6 +814,14 @@ export type IpcInvokeMap = {
|
|||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.windows.openNpcsEditor]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
@@ -868,6 +886,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: SessionTimerEvent };
|
req: { event: SessionTimerEvent };
|
||||||
res: { state: SessionTimerState };
|
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]: {
|
[ipcChannels.effects.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: EffectsState };
|
res: { state: EffectsState };
|
||||||
@@ -1121,6 +1147,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
||||||
|
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ export * from './presentationSettings';
|
|||||||
export * from './sceneTraps';
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
export * from './sessionTimer';
|
export * from './sessionTimer';
|
||||||
|
export * from './initiative';
|
||||||
export * from './videoPlayback';
|
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}`;
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||||
playlists: path.resolve(__dirname, 'app/renderer/playlists.html'),
|
playlists: path.resolve(__dirname, 'app/renderer/playlists.html'),
|
||||||
timer: path.resolve(__dirname, 'app/renderer/timer.html'),
|
timer: path.resolve(__dirname, 'app/renderer/timer.html'),
|
||||||
|
initiative: path.resolve(__dirname, 'app/renderer/initiative.html'),
|
||||||
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
||||||
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
||||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||||
|
|||||||
Reference in New Issue
Block a user