877fbaa8e7
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates. Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
import crypto from 'node:crypto';
|
|
|
|
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
|
import {
|
|
isPersistentFieldEffect,
|
|
type EffectInstance,
|
|
type EffectsEvent,
|
|
type EffectsState,
|
|
type 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: [],
|
|
};
|
|
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
|
|
private cache = new Map<string, EffectInstance[]>();
|
|
private currentKey: string | null = null;
|
|
|
|
getState(): EffectsState {
|
|
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
|
|
return { ...this.state, serverNowMs: nowMs() };
|
|
}
|
|
|
|
clear(): EffectsState {
|
|
this.state = {
|
|
...this.state,
|
|
revision: this.state.revision + 1,
|
|
serverNowMs: nowMs(),
|
|
instances: [],
|
|
};
|
|
this.commitCurrent();
|
|
return this.state;
|
|
}
|
|
|
|
/** Новый показ: и текущая карточка, и кэш остальных. */
|
|
resetSession(): EffectsState {
|
|
this.cache.clear();
|
|
this.currentKey = null;
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
serverNowMs: nowMs(),
|
|
tool: this.state.tool,
|
|
instances: [],
|
|
};
|
|
return this.getState();
|
|
}
|
|
|
|
/**
|
|
* Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей
|
|
* и поднимаются на новой. Разовые эффекты действий сбрасываются.
|
|
*/
|
|
switchScene(cacheKey: string | null): EffectsState {
|
|
this.commitCurrent();
|
|
this.currentKey = cacheKey;
|
|
const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
|
this.state = {
|
|
...this.state,
|
|
revision: this.state.revision + 1,
|
|
serverNowMs: nowMs(),
|
|
instances,
|
|
};
|
|
return this.getState();
|
|
}
|
|
|
|
exportByCard(): Record<string, EffectInstance[]> {
|
|
this.commitCurrent();
|
|
const out: Record<string, EffectInstance[]> = {};
|
|
for (const [key, instances] of this.cache) {
|
|
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
|
if (kept.length > 0) out[key] = kept.map((i) => ({ ...i }));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
importByCard(byCard: Record<string, EffectInstance[]>): void {
|
|
this.cache.clear();
|
|
this.currentKey = null;
|
|
this.state = {
|
|
...this.state,
|
|
revision: this.state.revision + 1,
|
|
serverNowMs: nowMs(),
|
|
instances: [],
|
|
};
|
|
for (const [key, instances] of Object.entries(byCard)) {
|
|
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
|
if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i })));
|
|
}
|
|
}
|
|
|
|
dispatch(event: EffectsEvent): EffectsState {
|
|
const s = this.state;
|
|
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
|
this.state = next;
|
|
this.commitCurrent();
|
|
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,
|
|
};
|
|
this.commitCurrent();
|
|
return true;
|
|
}
|
|
|
|
private commitCurrent(): void {
|
|
if (this.currentKey === null) return;
|
|
this.cache.set(
|
|
this.currentKey,
|
|
this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|