Files
DndGamePlayer/app/main/effects/effectsStore.ts
T
Ivan Fontosh 9d82e74272 fix: control UX — no default tool, launch loader, NPC layout
Deselect effect tools by default and on re-click, block the editor during session launch, and fix NPC overlay/window empty-state layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:41:27 +08:00

128 lines
3.8 KiB
TypeScript

import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
function nowMs(): number {
return Date.now();
}
function defaultTool(): EffectToolState {
return { tool: 'none', radiusN: 0.08, intensity: 0.6 };
}
/** Сброс выбранного инструмента (при старте сессии). */
export function effectsDefaultTool(): EffectToolState {
return defaultTool();
}
export class EffectsStore {
private state: EffectsState = {
revision: 1,
serverNowMs: nowMs(),
tool: defaultTool(),
instances: [],
};
getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
return { ...this.state, serverNowMs: nowMs() };
}
clear(): EffectsState {
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: nowMs(),
instances: [],
};
return this.state;
}
dispatch(event: EffectsEvent): EffectsState {
const s = this.state;
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next;
return next;
}
/** Удаляет истёкшие (по lifetime) эффекты, чтобы state не разрастался бесконечно. */
pruneExpired(): boolean {
const now = nowMs();
const before = this.state.instances.length;
const kept = this.state.instances.filter((i) => {
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
if (i.type === 'ice' || i.type === 'shadow') return true;
if (
i.type === 'lightning' ||
i.type === 'sunbeam' ||
i.type === 'poisonCloud' ||
i.type === 'explosion' ||
i.type === 'darkness'
) {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'scorch') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') {
if (i.lifetimeMs === null) return true;
return now - i.createdAtMs < i.lifetimeMs;
}
return true;
});
if (kept.length === before) return false;
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: now,
instances: kept,
};
return true;
}
makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
}
}
function applyEvent(
state: EffectsState,
event: EffectsEvent,
makeId: (prefix: string) => string,
): EffectsState {
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
...patch,
revision: state.revision + 1,
serverNowMs: nowMs(),
});
switch (event.kind) {
case 'tool.set':
return bump({ ...state, tool: event.tool });
case 'instances.clear':
return bump({ ...state, instances: [] });
case 'instance.add':
return bump({ ...state, instances: [...state.instances, event.instance] });
case 'instance.remove':
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
case 'field.erase':
return bump({
...state,
instances: applyFieldEffectEraserStroke(
state.instances,
event.points,
event.radiusN,
makeId,
event.types,
event.hitMode ?? 'inclusive',
),
});
default: {
// Exhaustiveness
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return state;
}
}
}