t2-mapper/src/state/engineStore.ts

479 lines
13 KiB
TypeScript
Raw Normal View History

2026-02-28 17:58:09 -08:00
import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";
import { useStoreWithEqualityFn } from "zustand/traditional";
2026-03-04 12:15:24 -08:00
import type { DemoRecording, DemoStreamSnapshot } from "../demo/types";
2026-02-28 17:58:09 -08:00
import type {
RuntimeMutationEvent,
TorqueObject,
TorqueRuntime,
} from "../torqueScript";
2026-03-02 22:57:58 -08:00
import {
buildSequenceAliasMap,
type SequenceAliasMap,
} from "../torqueScript/shapeConstructor";
2026-02-28 17:58:09 -08:00
export type PlaybackStatus = "stopped" | "playing" | "paused";
export interface RuntimeSliceState {
runtime: TorqueRuntime | null;
2026-03-02 22:57:58 -08:00
sequenceAliases: SequenceAliasMap;
2026-02-28 17:58:09 -08:00
objectVersionById: Record<number, number>;
globalVersionByName: Record<string, number>;
objectIdsByName: Record<string, number>;
datablockIdsByName: Record<string, number>;
lastRuntimeTick: number;
}
export interface PlaybackSliceState {
recording: DemoRecording | null;
status: PlaybackStatus;
timeMs: number;
rate: number;
durationMs: number;
streamSnapshot: DemoStreamSnapshot | null;
}
export interface RuntimeTickInfo {
tick?: number;
}
export interface EngineStoreState {
runtime: RuntimeSliceState;
playback: PlaybackSliceState;
setRuntime(runtime: TorqueRuntime): void;
clearRuntime(): void;
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo): void;
setDemoRecording(recording: DemoRecording | null): void;
setPlaybackTime(ms: number): void;
setPlaybackStatus(status: PlaybackStatus): void;
setPlaybackRate(rate: number): void;
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null): void;
}
function normalizeName(name: string): string {
return name.toLowerCase();
}
function normalizeGlobalName(name: string): string {
const normalized = normalizeName(name.trim());
return normalized.startsWith("$") ? normalized.slice(1) : normalized;
}
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
function buildRuntimeIndexes(runtime: TorqueRuntime): Pick<
RuntimeSliceState,
| "objectVersionById"
| "globalVersionByName"
| "objectIdsByName"
| "datablockIdsByName"
> {
const objectVersionById: Record<number, number> = {};
const globalVersionByName: Record<string, number> = {};
const objectIdsByName: Record<string, number> = {};
const datablockIdsByName: Record<string, number> = {};
for (const object of runtime.state.objectsById.values()) {
objectVersionById[object._id] = 0;
if (object._name) {
objectIdsByName[normalizeName(object._name)] = object._id;
if (object._isDatablock) {
datablockIdsByName[normalizeName(object._name)] = object._id;
}
}
}
for (const globalName of runtime.state.globals.keys()) {
globalVersionByName[normalizeGlobalName(globalName)] = 0;
}
return {
objectVersionById,
globalVersionByName,
objectIdsByName,
datablockIdsByName,
};
}
const initialState: Omit<
EngineStoreState,
| "setRuntime"
| "clearRuntime"
| "applyRuntimeBatch"
| "setDemoRecording"
| "setPlaybackTime"
| "setPlaybackStatus"
| "setPlaybackRate"
| "setPlaybackStreamSnapshot"
> = {
runtime: {
runtime: null,
2026-03-02 22:57:58 -08:00
sequenceAliases: new Map(),
2026-02-28 17:58:09 -08:00
objectVersionById: {},
globalVersionByName: {},
objectIdsByName: {},
datablockIdsByName: {},
lastRuntimeTick: 0,
},
playback: {
recording: null,
status: "stopped",
timeMs: 0,
rate: 1,
durationMs: 0,
streamSnapshot: null,
},
};
export const engineStore = createStore<EngineStoreState>()(
subscribeWithSelector((set) => ({
...initialState,
setRuntime(runtime: TorqueRuntime) {
const indexes = buildRuntimeIndexes(runtime);
2026-03-02 22:57:58 -08:00
const sequenceAliases = buildSequenceAliasMap(runtime);
2026-02-28 17:58:09 -08:00
set((state) => ({
...state,
runtime: {
runtime,
2026-03-02 22:57:58 -08:00
sequenceAliases,
2026-02-28 17:58:09 -08:00
objectVersionById: indexes.objectVersionById,
globalVersionByName: indexes.globalVersionByName,
objectIdsByName: indexes.objectIdsByName,
datablockIdsByName: indexes.datablockIdsByName,
lastRuntimeTick: 0,
},
}));
},
clearRuntime() {
set((state) => ({
...state,
runtime: {
runtime: null,
2026-03-02 22:57:58 -08:00
sequenceAliases: new Map(),
2026-02-28 17:58:09 -08:00
objectVersionById: {},
globalVersionByName: {},
objectIdsByName: {},
datablockIdsByName: {},
lastRuntimeTick: 0,
},
}));
},
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo) {
if (events.length === 0) {
return;
}
set((state) => {
const objectVersionById = { ...state.runtime.objectVersionById };
const globalVersionByName = { ...state.runtime.globalVersionByName };
const objectIdsByName = { ...state.runtime.objectIdsByName };
const datablockIdsByName = { ...state.runtime.datablockIdsByName };
const bumpVersion = (id: number | undefined | null) => {
if (id == null) return;
objectVersionById[id] = (objectVersionById[id] ?? 0) + 1;
};
for (const event of events) {
if (event.type === "object.created") {
const object = event.object;
bumpVersion(event.objectId);
if (object._name) {
const key = normalizeName(object._name);
objectIdsByName[key] = event.objectId;
if (object._isDatablock) {
datablockIdsByName[key] = event.objectId;
}
}
bumpVersion(object._parent?._id);
continue;
}
if (event.type === "object.deleted") {
const object = event.object;
delete objectVersionById[event.objectId];
if (object?._name) {
const key = normalizeName(object._name);
delete objectIdsByName[key];
if (object._isDatablock) {
delete datablockIdsByName[key];
}
}
bumpVersion(object?._parent?._id);
continue;
}
if (event.type === "field.changed") {
bumpVersion(event.objectId);
continue;
}
if (event.type === "global.changed") {
const globalName = normalizeGlobalName(event.name);
globalVersionByName[globalName] =
(globalVersionByName[globalName] ?? 0) + 1;
continue;
}
}
const tick =
tickInfo?.tick ??
(state.runtime.lastRuntimeTick > 0
? state.runtime.lastRuntimeTick + 1
: 1);
return {
...state,
runtime: {
...state.runtime,
objectVersionById,
globalVersionByName,
objectIdsByName,
datablockIdsByName,
lastRuntimeTick: tick,
},
};
});
},
setDemoRecording(recording: DemoRecording | null) {
const durationMs = Math.max(0, (recording?.duration ?? 0) * 1000);
2026-03-04 12:15:24 -08:00
set((state) => ({
...state,
playback: {
recording,
status: "stopped",
timeMs: 0,
rate: 1,
durationMs,
streamSnapshot: null,
},
}));
2026-02-28 17:58:09 -08:00
},
setPlaybackTime(ms: number) {
set((state) => {
const clamped = clamp(ms, 0, state.playback.durationMs);
return {
...state,
playback: {
...state.playback,
timeMs: clamped,
},
};
});
},
setPlaybackStatus(status: PlaybackStatus) {
set((state) => ({
...state,
playback: {
...state.playback,
status,
},
}));
},
setPlaybackRate(rate: number) {
const nextRate = Number.isFinite(rate) ? clamp(rate, 0.01, 16) : 1;
set((state) => ({
...state,
playback: {
...state.playback,
rate: nextRate,
},
}));
},
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null) {
set((state) => ({
...state,
playback: {
...state.playback,
streamSnapshot: snapshot,
},
}));
},
})),
);
// ── Rate-scaled effect clock ──
//
// A monotonic clock that advances by (frameDelta × playbackRate) each frame.
// Components use demoEffectNow() instead of performance.now() so that effect
// timers (explosions, particles, shockwaves, animation threads) automatically
// pause when the demo is paused and speed up / slow down with the playback
// rate. The main DemoPlaybackStreaming component calls advanceEffectClock()
// once per frame.
let _effectClockMs = 0;
/**
* Returns the current effect clock value in milliseconds.
* Analogous to performance.now() but only advances when playing,
* scaled by the playback rate.
*/
export function demoEffectNow(): number {
return _effectClockMs;
}
/**
* Advance the effect clock. Called once per frame from
* DemoPlaybackStreaming before other useFrame callbacks run.
*/
export function advanceEffectClock(deltaSec: number, rate: number): void {
_effectClockMs += deltaSec * rate * 1000;
}
/** Reset the effect clock (call when demo recording changes or stops). */
export function resetEffectClock(): void {
_effectClockMs = 0;
}
// Reset on stop.
engineStore.subscribe(
(state) => state.playback.status,
(status) => {
if (status === "stopped") {
resetEffectClock();
}
},
);
2026-02-28 17:58:09 -08:00
export function useEngineStoreApi() {
return engineStore;
}
export function useEngineSelector<T>(
selector: (state: EngineStoreState) => T,
equality?: (a: T, b: T) => boolean,
): T {
return useStoreWithEqualityFn(engineStore, selector, equality);
}
export function useRuntimeObjectById(
id: number | undefined,
): TorqueObject | undefined {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const version = useEngineSelector((state) =>
id == null ? -1 : (state.runtime.objectVersionById[id] ?? -1),
);
if (id == null || !runtime || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(id);
return object ? { ...object } : undefined;
}
export function useRuntimeObjectField<T = any>(
id: number | undefined,
fieldName: string,
): T | undefined {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const normalizedField = normalizeName(fieldName);
useEngineSelector((state) =>
id == null ? -1 : (state.runtime.objectVersionById[id] ?? -1),
);
if (id == null || !runtime) {
return undefined;
}
const object = runtime.state.objectsById.get(id);
if (!object) {
return undefined;
}
return object[normalizedField] as T;
}
export function useRuntimeGlobal<T = any>(
name: string | undefined,
): T | undefined {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const normalizedName = name ? normalizeGlobalName(name) : "";
useEngineSelector((state) =>
normalizedName
? (state.runtime.globalVersionByName[normalizedName] ?? -1)
: -1,
);
if (!runtime || !normalizedName) {
return undefined;
}
return runtime.$g.get(normalizedName) as T;
}
export function useRuntimeObjectByName(
name: string | undefined,
): TorqueObject | undefined {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const normalizedName = name ? normalizeName(name) : "";
const objectId = useEngineSelector((state) =>
normalizedName ? state.runtime.objectIdsByName[normalizedName] : undefined,
);
const version = useEngineSelector((state) =>
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
);
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
}
export function useDatablockByName(
name: string | undefined,
): TorqueObject | undefined {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const normalizedName = name ? normalizeName(name) : "";
const objectId = useEngineSelector((state) =>
normalizedName
? state.runtime.datablockIdsByName[normalizedName]
: undefined,
);
const version = useEngineSelector((state) =>
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
);
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
}
export function useRuntimeChildIds(
parentId: number | undefined,
fallbackChildren: readonly TorqueObject[] = [],
): number[] {
const runtime = useEngineSelector((state) => state.runtime.runtime);
const version = useEngineSelector((state) =>
parentId == null ? -1 : (state.runtime.objectVersionById[parentId] ?? -1),
);
if (parentId == null) {
return fallbackChildren.map((child) => child._id);
}
if (!runtime || version === -1) {
return fallbackChildren.map((child) => child._id);
}
const parent = runtime.state.objectsById.get(parentId);
if (!parent?._children) {
return [];
}
return parent._children.map((child) => child._id);
}