t2-mapper/src/state/engineStore.ts
2026-03-09 12:38:40 -07:00

478 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type { StreamRecording, StreamSnapshot } from "../stream/types";
import type {
RuntimeMutationEvent,
TorqueObject,
TorqueRuntime,
} from "../torqueScript";
import {
buildSequenceAliasMap,
type SequenceAliasMap,
} from "../torqueScript/shapeConstructor";
export type PlaybackStatus = "stopped" | "playing" | "paused";
export interface RuntimeSliceState {
runtime: TorqueRuntime | null;
sequenceAliases: SequenceAliasMap;
objectVersionById: Record<number, number>;
globalVersionByName: Record<string, number>;
objectIdsByName: Record<string, number>;
datablockIdsByName: Record<string, number>;
lastRuntimeTick: number;
}
export interface PlaybackSliceState {
recording: StreamRecording | null;
status: PlaybackStatus;
timeMs: number;
rate: number;
durationMs: number;
streamSnapshot: StreamSnapshot | 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;
setRecording(recording: StreamRecording | null): void;
setPlaybackTime(ms: number): void;
setPlaybackStatus(status: PlaybackStatus): void;
setPlaybackRate(rate: number): void;
setPlaybackStreamSnapshot(snapshot: StreamSnapshot | 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"
| "setRecording"
| "setPlaybackTime"
| "setPlaybackStatus"
| "setPlaybackRate"
| "setPlaybackStreamSnapshot"
> = {
runtime: {
runtime: null,
sequenceAliases: new Map(),
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);
const sequenceAliases = buildSequenceAliasMap(runtime);
set((state) => ({
...state,
runtime: {
runtime,
sequenceAliases,
objectVersionById: indexes.objectVersionById,
globalVersionByName: indexes.globalVersionByName,
objectIdsByName: indexes.objectIdsByName,
datablockIdsByName: indexes.datablockIdsByName,
lastRuntimeTick: 0,
},
}));
},
clearRuntime() {
set((state) => ({
...state,
runtime: {
runtime: null,
sequenceAliases: new Map(),
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,
},
};
});
},
setRecording(recording: StreamRecording | null) {
const durationMs = Math.max(0, (recording?.duration ?? 0) * 1000);
set((state) => ({
...state,
playback: {
recording,
status: "stopped",
timeMs: 0,
rate: 1,
durationMs,
streamSnapshot: null,
},
}));
},
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: StreamSnapshot | 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 effectNow() 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 DemoPlaybackController 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 effectNow(): number {
return _effectClockMs;
}
/**
* Advance the effect clock. Called once per frame from
* DemoPlaybackController 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();
}
},
);
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);
}