Files
DndGamePlayer/app/main/effects/effectsStore.ts
T
Ivan Fontosh 20c838da7d feat(effects): вода, облако яда, луч света; пульт и окна демонстрации
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики.

- Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/.

- Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика.

- Окно управления: дочернее от окна просмотра (Z-order).

- Типы эффектов, effectsStore prune, hit-test ластика.

Made-with: Cursor
2026-04-20 11:03:57 +08:00

100 lines
3.0 KiB
TypeScript

import crypto from 'node:crypto';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
function nowMs(): number {
return Date.now();
}
function defaultTool(): EffectToolState {
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
}
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);
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') return true;
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'scorch') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'fog' || 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): 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) });
default: {
// Exhaustiveness
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return state;
}
}
}