automatick 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  useStableCallback
3
3
  } from "../chunk-SK5SHIWY.js";
4
+ import {
5
+ createSimWorker
6
+ } from "../chunk-VPS3ZXWI.js";
7
+ import {
8
+ createWorkerRunner
9
+ } from "../chunk-LMHH7YPE.js";
4
10
  import {
5
11
  createEngine
6
- } from "../chunk-UU5V7HGM.js";
12
+ } from "../chunk-A375T3UD.js";
7
13
  import "../chunk-IKR53C2U.js";
8
14
  import {
9
15
  EngineContext
@@ -16,14 +22,29 @@ import {
16
22
  import React from "react";
17
23
  import { jsx } from "react/jsx-runtime";
18
24
  function LocalSimulation(props) {
19
- const { sim, params: paramsProp, children, autoplay } = props;
25
+ const {
26
+ init,
27
+ step,
28
+ shouldStop,
29
+ defaultParams,
30
+ params: paramsProp,
31
+ children,
32
+ autoplay
33
+ } = props;
20
34
  const engineRef = React.useRef(null);
21
35
  if (!engineRef.current) {
22
- const initialParams = paramsProp ? { ...sim.defaultParams, ...paramsProp } : sim.defaultParams;
36
+ let initialParams;
37
+ if (defaultParams && paramsProp) {
38
+ initialParams = { ...defaultParams, ...paramsProp };
39
+ } else if (defaultParams) {
40
+ initialParams = defaultParams;
41
+ } else if (paramsProp) {
42
+ initialParams = paramsProp;
43
+ }
23
44
  engineRef.current = createEngine({
24
- init: sim.init,
25
- step: sim.step,
26
- shouldStop: sim.shouldStop,
45
+ init,
46
+ step,
47
+ shouldStop,
27
48
  initialParams,
28
49
  maxTime: props.maxTime,
29
50
  delayMs: props.delayMs,
@@ -65,119 +86,70 @@ function LocalSimulation(props) {
65
86
  }, [engine]);
66
87
  return /* @__PURE__ */ jsx(SimulationProvider, { snapshot, backend: engine, children });
67
88
  }
89
+ function engineUrl() {
90
+ return new URL("../standalone/engine.js", import.meta.url).href;
91
+ }
68
92
  function WorkerSimulation(props) {
69
93
  const { children, autoplay } = props;
70
- const [backend, setBackend] = React.useState(
94
+ const [runner, setRunner] = React.useState(
71
95
  null
72
96
  );
73
97
  const [snapshot, setSnapshot] = React.useState(
74
98
  null
75
99
  );
76
- const propsRef = React.useRef(props);
77
- propsRef.current = props;
78
100
  React.useEffect(() => {
79
- let cancelled = false;
80
- let runner = null;
81
- (async () => {
82
- const mod = await propsRef.current.worker();
83
- const simModule = mod.default;
84
- if (cancelled) return;
85
- const p = propsRef.current;
86
- const initialParams = p.params ? { ...simModule.defaultParams, ...p.params } : simModule.defaultParams;
87
- const engine = createEngine({
88
- init: simModule.init,
89
- step: simModule.step,
90
- shouldStop: simModule.shouldStop,
91
- initialParams,
92
- maxTime: p.maxTime,
93
- autoFrame: false
94
- });
95
- if (cancelled) {
96
- engine.destroy();
97
- return;
98
- }
99
- const delayMs = p.delayMs ?? 0;
100
- const ticksPerFrame = p.ticksPerFrame ?? 1;
101
- let loopTimer = null;
102
- function stopLoop() {
103
- if (loopTimer !== null) {
104
- clearTimeout(loopTimer);
105
- loopTimer = null;
106
- }
107
- }
108
- function tickLoop() {
109
- if (engine.getStatus() !== "playing") return;
110
- for (let i = 0; i < ticksPerFrame; i++) {
111
- engine.advance(1);
112
- const s = engine.getStatus();
113
- if (s === "stopped") return;
114
- if (s !== "paused") break;
115
- }
116
- if (engine.getStatus() === "paused") {
117
- engine.play();
118
- }
119
- loopTimer = setTimeout(tickLoop, delayMs);
101
+ const moduleUrl = props.worker.toString();
102
+ const initialParams = props.params ?? {};
103
+ const worker = createSimWorker({
104
+ moduleUrl,
105
+ engineUrl: engineUrl(),
106
+ initialParams,
107
+ config: {
108
+ maxTime: props.maxTime,
109
+ delayMs: props.delayMs,
110
+ ticksPerFrame: props.ticksPerFrame,
111
+ snapshotIntervalMs: props.snapshotIntervalMs
120
112
  }
121
- runner = {
122
- getSnapshot: () => engine.getSnapshot(),
123
- subscribe: (listener) => engine.subscribe(listener),
124
- play: () => {
125
- engine.play();
126
- stopLoop();
127
- loopTimer = setTimeout(tickLoop, 0);
128
- },
129
- pause: () => {
130
- stopLoop();
131
- engine.pause();
132
- },
133
- stop: () => {
134
- stopLoop();
135
- engine.stop();
136
- },
137
- seek: (tick) => {
138
- stopLoop();
139
- engine.seek(tick);
140
- },
141
- advance: (count) => engine.advance(count),
142
- setParams: (patch) => engine.setParams(patch),
143
- resetWith: (patch) => {
144
- stopLoop();
145
- engine.resetWith(patch);
146
- },
147
- destroy: () => {
148
- stopLoop();
149
- engine.destroy();
150
- },
151
- recordDrawTime: (tick, ms) => engine.recordDrawTime(tick, ms),
152
- getPerformance: () => engine.getPerformance()
153
- };
154
- if (!cancelled) {
155
- setBackend(runner);
156
- setSnapshot(engine.getSnapshot());
113
+ });
114
+ const r = createWorkerRunner(worker, {
115
+ initialParams,
116
+ config: {
117
+ maxTime: props.maxTime,
118
+ delayMs: props.delayMs,
119
+ ticksPerFrame: props.ticksPerFrame,
120
+ snapshotIntervalMs: props.snapshotIntervalMs
157
121
  }
158
- })();
122
+ });
123
+ const unsub = r.subscribe((next) => setSnapshot(next));
124
+ setRunner(r);
159
125
  return () => {
160
- cancelled = true;
161
- runner?.destroy();
126
+ unsub();
127
+ r.destroy();
162
128
  };
163
129
  }, []);
164
130
  React.useEffect(() => {
165
- if (!backend) return;
166
- return backend.subscribe((next) => setSnapshot(next));
167
- }, [backend]);
168
- React.useEffect(() => {
169
- if (autoplay && backend) backend.play();
170
- }, [backend, autoplay]);
131
+ if (autoplay && runner) runner.play();
132
+ }, [runner, autoplay]);
171
133
  const isFirstRender = React.useRef(true);
172
134
  React.useEffect(() => {
173
135
  if (isFirstRender.current) {
174
136
  isFirstRender.current = false;
175
137
  return;
176
138
  }
177
- if (props.params && backend) backend.setParams(props.params);
178
- }, [backend, props.params]);
179
- if (!snapshot || !backend) return null;
180
- return /* @__PURE__ */ jsx(SimulationProvider, { snapshot, backend, children });
139
+ if (props.params && runner) runner.setParams(props.params);
140
+ }, [runner, props.params]);
141
+ React.useEffect(() => {
142
+ if (!runner) return;
143
+ if (props.delayMs === void 0 && props.ticksPerFrame === void 0 && props.snapshotIntervalMs === void 0)
144
+ return;
145
+ runner.setConfig({
146
+ delayMs: props.delayMs,
147
+ ticksPerFrame: props.ticksPerFrame,
148
+ snapshotIntervalMs: props.snapshotIntervalMs
149
+ });
150
+ }, [runner, props.delayMs, props.ticksPerFrame, props.snapshotIntervalMs]);
151
+ if (!snapshot || !runner || snapshot.data === void 0) return null;
152
+ return /* @__PURE__ */ jsx(SimulationProvider, { snapshot, backend: runner, children });
181
153
  }
182
154
  function SimulationProvider({
183
155
  snapshot,
@@ -236,6 +208,19 @@ function Simulation(props) {
236
208
  if ("worker" in props && props.worker != null) {
237
209
  return /* @__PURE__ */ jsx(WorkerSimulation, { ...props });
238
210
  }
211
+ if ("sim" in props && props.sim != null) {
212
+ const { sim, ...rest } = props;
213
+ return /* @__PURE__ */ jsx(
214
+ LocalSimulation,
215
+ {
216
+ ...rest,
217
+ init: sim.init,
218
+ step: sim.step,
219
+ shouldStop: sim.shouldStop,
220
+ defaultParams: sim.defaultParams
221
+ }
222
+ );
223
+ }
239
224
  return /* @__PURE__ */ jsx(LocalSimulation, { ...props });
240
225
  }
241
226
  export {
package/dist/sim.d.cts CHANGED
@@ -17,7 +17,7 @@ type SimInit<Data, Params> = ((params: Params) => Data) | Data;
17
17
  */
18
18
  declare function isInitFn<Data, Params>(init: SimInit<Data, Params>): init is (params: Params) => Data;
19
19
  /** A simulation module: the pure business logic that automatick drives. */
20
- type SimModule<Data, Params> = {
20
+ type SimModule<Data, Params = Record<string, never>> = {
21
21
  /**
22
22
  * Initial simulation state — value or `(params) => Data`. When a value is
23
23
  * passed, the engine takes a fresh `structuredClone` on every (re)init so
@@ -28,8 +28,12 @@ type SimModule<Data, Params> = {
28
28
  step: (state: State<Data, Params>) => Data;
29
29
  /** Optional termination predicate. Checked after each step. If it returns true, the simulation stops. */
30
30
  shouldStop?: (data: Data, params: Params) => boolean;
31
- /** Default parameter values. Used at engine creation if no params override is provided. */
32
- defaultParams: Params;
31
+ /**
32
+ * Default parameter values. Optional — sims without tweakable params can
33
+ * omit this. When omitted, `Params` defaults to `Record<string, never>` and
34
+ * the engine seeds an empty params object.
35
+ */
36
+ defaultParams?: Params;
33
37
  };
34
38
  /**
35
39
  * Define a simulation module with full type inference.
@@ -47,7 +51,7 @@ type SimModule<Data, Params> = {
47
51
  * });
48
52
  * ```
49
53
  */
50
- declare function defineSim<Data, Params>(sim: SimModule<Data, Params>): SimModule<Data, Params>;
54
+ declare function defineSim<Data, Params = Record<string, never>>(sim: SimModule<Data, Params>): SimModule<Data, Params>;
51
55
  /** Extract the Data type from a SimModule. */
52
56
  type SimData<M> = M extends SimModule<infer D, infer _P> ? D : never;
53
57
  /** Extract the Params type from a SimModule. */
package/dist/sim.d.ts CHANGED
@@ -17,7 +17,7 @@ type SimInit<Data, Params> = ((params: Params) => Data) | Data;
17
17
  */
18
18
  declare function isInitFn<Data, Params>(init: SimInit<Data, Params>): init is (params: Params) => Data;
19
19
  /** A simulation module: the pure business logic that automatick drives. */
20
- type SimModule<Data, Params> = {
20
+ type SimModule<Data, Params = Record<string, never>> = {
21
21
  /**
22
22
  * Initial simulation state — value or `(params) => Data`. When a value is
23
23
  * passed, the engine takes a fresh `structuredClone` on every (re)init so
@@ -28,8 +28,12 @@ type SimModule<Data, Params> = {
28
28
  step: (state: State<Data, Params>) => Data;
29
29
  /** Optional termination predicate. Checked after each step. If it returns true, the simulation stops. */
30
30
  shouldStop?: (data: Data, params: Params) => boolean;
31
- /** Default parameter values. Used at engine creation if no params override is provided. */
32
- defaultParams: Params;
31
+ /**
32
+ * Default parameter values. Optional — sims without tweakable params can
33
+ * omit this. When omitted, `Params` defaults to `Record<string, never>` and
34
+ * the engine seeds an empty params object.
35
+ */
36
+ defaultParams?: Params;
33
37
  };
34
38
  /**
35
39
  * Define a simulation module with full type inference.
@@ -47,7 +51,7 @@ type SimModule<Data, Params> = {
47
51
  * });
48
52
  * ```
49
53
  */
50
- declare function defineSim<Data, Params>(sim: SimModule<Data, Params>): SimModule<Data, Params>;
54
+ declare function defineSim<Data, Params = Record<string, never>>(sim: SimModule<Data, Params>): SimModule<Data, Params>;
51
55
  /** Extract the Data type from a SimModule. */
52
56
  type SimData<M> = M extends SimModule<infer D, infer _P> ? D : never;
53
57
  /** Extract the Params type from a SimModule. */
@@ -0,0 +1,242 @@
1
+ // src/sim.ts
2
+ function isInitFn(init) {
3
+ return typeof init === "function";
4
+ }
5
+
6
+ // src/engine.ts
7
+ var PERF_BUFFER_SIZE = 120;
8
+ var SimulationEngine = class {
9
+ data;
10
+ params;
11
+ tick = 0;
12
+ status = "idle";
13
+ lastUpdateMs = null;
14
+ lastStepMs = 0;
15
+ initFn;
16
+ stepFn;
17
+ shouldStopFn;
18
+ maxTime;
19
+ delayMs;
20
+ ticksPerFrame;
21
+ listeners = /* @__PURE__ */ new Set();
22
+ historyListeners = /* @__PURE__ */ new Set();
23
+ perfBuffer = [];
24
+ rafId = null;
25
+ rafCancel = null;
26
+ constructor(config) {
27
+ const init = config.init;
28
+ if (isInitFn(init)) {
29
+ this.initFn = init;
30
+ } else {
31
+ const seed = structuredClone(init);
32
+ this.initFn = () => structuredClone(seed);
33
+ }
34
+ this.stepFn = config.step;
35
+ this.shouldStopFn = config.shouldStop;
36
+ this.maxTime = config.maxTime;
37
+ this.delayMs = config.delayMs ?? 0;
38
+ this.ticksPerFrame = config.ticksPerFrame ?? 1;
39
+ this.params = config.initialParams ? { ...config.initialParams } : {};
40
+ this.data = this.initFn(this.params);
41
+ if (config.render) {
42
+ this.listeners.add(config.render);
43
+ config.render(this.getSnapshot());
44
+ }
45
+ const autoFrame = config.autoFrame ?? true;
46
+ if (autoFrame) {
47
+ const raf = globalThis.requestAnimationFrame;
48
+ const caf = globalThis.cancelAnimationFrame;
49
+ if (typeof raf === "function" && typeof caf === "function") {
50
+ const loop = (now) => {
51
+ this.handleAnimationFrame(now);
52
+ this.rafId = raf(loop);
53
+ };
54
+ this.rafCancel = caf;
55
+ this.rafId = raf(loop);
56
+ }
57
+ }
58
+ }
59
+ getSnapshot() {
60
+ return {
61
+ data: this.data,
62
+ params: this.params,
63
+ tick: this.tick,
64
+ status: this.status,
65
+ stepDurationMs: this.lastStepMs
66
+ };
67
+ }
68
+ getStatus() {
69
+ return this.status;
70
+ }
71
+ getPerformance() {
72
+ return this.perfBuffer;
73
+ }
74
+ setDelayMs(ms) {
75
+ this.delayMs = ms;
76
+ }
77
+ setTicksPerFrame(n) {
78
+ this.ticksPerFrame = n;
79
+ }
80
+ recordDrawTime(tick, ms) {
81
+ for (let i = this.perfBuffer.length - 1; i >= 0; i--) {
82
+ if (this.perfBuffer[i].tick === tick) {
83
+ this.perfBuffer[i].drawMs = ms;
84
+ return;
85
+ }
86
+ }
87
+ }
88
+ subscribe(listener) {
89
+ this.listeners.add(listener);
90
+ return () => {
91
+ this.listeners.delete(listener);
92
+ };
93
+ }
94
+ subscribeHistory(listener) {
95
+ this.historyListeners.add(listener);
96
+ return () => {
97
+ this.historyListeners.delete(listener);
98
+ };
99
+ }
100
+ play() {
101
+ if (this.status === "stopped") return;
102
+ if (this.status === "playing") return;
103
+ this.status = "playing";
104
+ this.lastUpdateMs = null;
105
+ this.emit();
106
+ }
107
+ pause() {
108
+ if (this.status !== "playing") return;
109
+ this.status = "paused";
110
+ this.emit();
111
+ }
112
+ stop() {
113
+ if (this.status === "idle" || this.status === "stopped") return;
114
+ this.status = "stopped";
115
+ this.emit();
116
+ }
117
+ seek(targetTick) {
118
+ if (this.status === "stopped") return;
119
+ if (targetTick <= this.tick) {
120
+ if (this.status !== "paused") {
121
+ this.status = "paused";
122
+ this.emit();
123
+ }
124
+ return;
125
+ }
126
+ this.status = "paused";
127
+ this.lastUpdateMs = null;
128
+ this.advanceTicks(targetTick - this.tick);
129
+ if (this.status === "paused") {
130
+ this.emit();
131
+ }
132
+ }
133
+ advance(count = 1) {
134
+ if (this.status === "stopped") return;
135
+ if (this.status === "playing" || this.status === "idle") {
136
+ this.status = "paused";
137
+ }
138
+ this.lastUpdateMs = null;
139
+ this.advanceTicks(count);
140
+ if (this.status === "paused") {
141
+ this.emit();
142
+ }
143
+ }
144
+ setParams(patch) {
145
+ this.params = { ...this.params, ...patch };
146
+ this.emit();
147
+ }
148
+ resetWith(patch) {
149
+ if (patch) {
150
+ this.params = { ...this.params, ...patch };
151
+ }
152
+ this.data = this.initFn(this.params);
153
+ this.tick = 0;
154
+ this.status = "idle";
155
+ this.lastUpdateMs = null;
156
+ this.lastStepMs = 0;
157
+ this.perfBuffer.length = 0;
158
+ this.emit();
159
+ }
160
+ handleAnimationFrame(nowMs) {
161
+ if (this.status !== "playing") return;
162
+ if (this.lastUpdateMs === null) {
163
+ this.lastUpdateMs = nowMs;
164
+ return;
165
+ }
166
+ if (this.delayMs > 0 && nowMs - this.lastUpdateMs < this.delayMs) return;
167
+ this.lastUpdateMs = nowMs;
168
+ this.advanceTicks(this.ticksPerFrame);
169
+ if (this.status === "playing") {
170
+ this.emit();
171
+ }
172
+ }
173
+ destroy() {
174
+ if (this.rafId !== null && this.rafCancel) {
175
+ this.rafCancel(this.rafId);
176
+ }
177
+ this.rafId = null;
178
+ this.rafCancel = null;
179
+ this.listeners.clear();
180
+ this.historyListeners.clear();
181
+ }
182
+ /**
183
+ * Run step up to `count` ticks. Returns true if all ticks completed
184
+ * without termination, false if stopped early.
185
+ */
186
+ advanceTicks(count) {
187
+ for (let i = 0; i < count; i++) {
188
+ if (this.maxTime !== void 0 && this.tick >= this.maxTime) {
189
+ this.status = "stopped";
190
+ this.emit();
191
+ return false;
192
+ }
193
+ this.tick += 1;
194
+ const t0 = performance.now();
195
+ this.data = this.stepFn({
196
+ data: this.data,
197
+ params: this.params,
198
+ tick: this.tick,
199
+ status: this.status,
200
+ stepDurationMs: this.lastStepMs
201
+ });
202
+ const t1 = performance.now();
203
+ this.lastStepMs = t1 - t0;
204
+ if (this.perfBuffer.length >= PERF_BUFFER_SIZE) {
205
+ this.perfBuffer.shift();
206
+ }
207
+ this.perfBuffer.push({ tick: this.tick, stepMs: this.lastStepMs });
208
+ this.emitHistory();
209
+ if (this.shouldStopFn?.(this.data, this.params)) {
210
+ this.status = "stopped";
211
+ this.emit();
212
+ return false;
213
+ }
214
+ if (this.maxTime !== void 0 && this.tick >= this.maxTime) {
215
+ this.status = "stopped";
216
+ this.emit();
217
+ return false;
218
+ }
219
+ }
220
+ return true;
221
+ }
222
+ emit() {
223
+ const snap = this.getSnapshot();
224
+ for (const l of this.listeners) {
225
+ l(snap);
226
+ }
227
+ }
228
+ emitHistory() {
229
+ if (this.historyListeners.size === 0) return;
230
+ const entry = { tick: this.tick, data: this.data };
231
+ for (const l of this.historyListeners) {
232
+ l(entry);
233
+ }
234
+ }
235
+ };
236
+ function createEngine(config) {
237
+ return new SimulationEngine(config);
238
+ }
239
+ export {
240
+ SimulationEngine,
241
+ createEngine
242
+ };
@@ -0,0 +1,11 @@
1
+ // src/sim.ts
2
+ function isInitFn(init) {
3
+ return typeof init === "function";
4
+ }
5
+ function defineSim(sim) {
6
+ return sim;
7
+ }
8
+ export {
9
+ defineSim,
10
+ isInitFn
11
+ };
@@ -72,16 +72,18 @@ self.onmessage = async (event) => {
72
72
  import(msg.engineUrl),
73
73
  ]);
74
74
  const sim = simMod.default;
75
+ // Merge sim.defaultParams under the patch sent from main \u2014 the main
76
+ // thread sees the sim module via a URL, so it can't apply defaults.
77
+ const initialParams = { ...(sim.defaultParams || {}), ...(msg.params || {}) };
75
78
  engine = engineMod.createEngine({
76
79
  init: sim.init,
77
80
  step: sim.step,
78
81
  shouldStop: sim.shouldStop,
79
- initialParams: msg.params,
82
+ initialParams,
80
83
  maxTime: msg.config.maxTime,
81
84
  // Worker host owns its own setTimeout-driven loop; rAF wouldn't exist here anyway.
82
85
  autoFrame: false,
83
86
  });
84
- postMessage({ kind: 'ready' });
85
87
  emitSnapshot();
86
88
  break;
87
89
  }
@@ -113,6 +115,11 @@ self.onmessage = async (event) => {
113
115
  if (!engine) return;
114
116
  stopLoop(); engine.resetWith(msg.patch); emitSnapshot();
115
117
  break;
118
+ case 'setConfig':
119
+ if (msg.patch.delayMs !== undefined) delayMs = msg.patch.delayMs;
120
+ if (msg.patch.ticksPerFrame !== undefined) ticksPerFrame = msg.patch.ticksPerFrame;
121
+ if (msg.patch.snapshotIntervalMs !== undefined) snapshotIntervalMs = msg.patch.snapshotIntervalMs;
122
+ break;
116
123
  case 'destroy':
117
124
  stopLoop();
118
125
  if (engine) { engine.destroy(); engine = null; }
@@ -129,7 +136,7 @@ function createSimWorker(options) {
129
136
  const blobUrl = URL.createObjectURL(blob);
130
137
  const worker = new Worker(blobUrl, { type: "module" });
131
138
  worker.addEventListener("message", function cleanup(event) {
132
- if (event.data?.kind === "ready" || event.data?.kind === "error") {
139
+ if (event.data?.kind === "snapshot" || event.data?.kind === "error") {
133
140
  URL.revokeObjectURL(blobUrl);
134
141
  worker.removeEventListener("message", cleanup);
135
142
  }