@arcane-engine/runtime 0.1.0 → 0.2.0

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.
Files changed (47) hide show
  1. package/package.json +4 -2
  2. package/src/agent/protocol.ts +35 -1
  3. package/src/agent/types.ts +98 -13
  4. package/src/particles/emitter.test.ts +323 -0
  5. package/src/particles/emitter.ts +409 -0
  6. package/src/particles/index.ts +25 -0
  7. package/src/particles/types.ts +236 -0
  8. package/src/pathfinding/astar.ts +27 -0
  9. package/src/pathfinding/types.ts +39 -0
  10. package/src/physics/aabb.ts +55 -8
  11. package/src/rendering/animation.ts +73 -0
  12. package/src/rendering/audio.ts +29 -9
  13. package/src/rendering/camera.ts +28 -4
  14. package/src/rendering/input.ts +45 -9
  15. package/src/rendering/lighting.ts +29 -3
  16. package/src/rendering/loop.ts +16 -3
  17. package/src/rendering/sprites.ts +24 -1
  18. package/src/rendering/text.ts +52 -6
  19. package/src/rendering/texture.ts +22 -4
  20. package/src/rendering/tilemap.ts +36 -4
  21. package/src/rendering/types.ts +37 -19
  22. package/src/rendering/validate.ts +48 -3
  23. package/src/state/error.ts +21 -2
  24. package/src/state/observe.ts +40 -9
  25. package/src/state/prng.ts +88 -10
  26. package/src/state/query.ts +115 -15
  27. package/src/state/store.ts +42 -11
  28. package/src/state/transaction.ts +116 -12
  29. package/src/state/types.ts +31 -5
  30. package/src/systems/system.ts +77 -5
  31. package/src/systems/types.ts +52 -6
  32. package/src/testing/harness.ts +103 -5
  33. package/src/testing/mock-renderer.test.ts +16 -20
  34. package/src/tweening/chain.test.ts +191 -0
  35. package/src/tweening/chain.ts +103 -0
  36. package/src/tweening/easing.test.ts +134 -0
  37. package/src/tweening/easing.ts +288 -0
  38. package/src/tweening/helpers.test.ts +185 -0
  39. package/src/tweening/helpers.ts +166 -0
  40. package/src/tweening/index.ts +76 -0
  41. package/src/tweening/tween.test.ts +322 -0
  42. package/src/tweening/tween.ts +296 -0
  43. package/src/tweening/types.ts +134 -0
  44. package/src/ui/colors.ts +129 -0
  45. package/src/ui/index.ts +1 -0
  46. package/src/ui/primitives.ts +44 -5
  47. package/src/ui/types.ts +41 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcane-engine/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-native 2D game engine runtime - TypeScript APIs for state management, rendering, and game logic",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -14,7 +14,9 @@
14
14
  "./pathfinding": "./src/pathfinding/index.ts",
15
15
  "./systems": "./src/systems/index.ts",
16
16
  "./agent": "./src/agent/index.ts",
17
- "./testing": "./src/testing/harness.ts"
17
+ "./testing": "./src/testing/harness.ts",
18
+ "./tweening": "./src/tweening/index.ts",
19
+ "./particles": "./src/particles/index.ts"
18
20
  },
19
21
  "files": [
20
22
  "src",
@@ -17,7 +17,41 @@ function deepClone<T>(value: T): T {
17
17
  return JSON.parse(JSON.stringify(value));
18
18
  }
19
19
 
20
- /** Register an agent protocol, installing it on globalThis.__arcaneAgent */
20
+ /**
21
+ * Register this game with Arcane's agent protocol.
22
+ *
23
+ * Must be called once at startup. Installs a protocol object on
24
+ * `globalThis.__arcaneAgent` that enables:
25
+ * - `arcane describe <entry.ts>` — text description of game state.
26
+ * - `arcane inspect <entry.ts> <path>` — query specific state values.
27
+ * - `arcane dev <entry.ts> --inspector <port>` — HTTP inspector for live interaction.
28
+ *
29
+ * The protocol supports executing/simulating actions, rewinding to initial state,
30
+ * and capturing snapshots.
31
+ *
32
+ * @typeParam S - The game state type.
33
+ * @param config - Agent configuration with name, state accessors, optional actions, and describe function.
34
+ * Provide either a `store` (GameStore) or `getState`/`setState` functions.
35
+ * @returns The created {@link AgentProtocol} instance (also installed on globalThis).
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * let state = { score: 0, player: { x: 0, y: 0, hp: 100 } };
40
+ *
41
+ * registerAgent({
42
+ * name: "my-game",
43
+ * getState: () => state,
44
+ * setState: (s) => { state = s; },
45
+ * describe: (s, opts) => `Score: ${s.score}, HP: ${s.player.hp}`,
46
+ * actions: {
47
+ * heal: {
48
+ * handler: (s) => ({ ...s, player: { ...s.player, hp: 100 } }),
49
+ * description: "Restore player to full health",
50
+ * },
51
+ * },
52
+ * });
53
+ * ```
54
+ */
21
55
  export function registerAgent<S>(config: AgentConfig<S>): AgentProtocol<S> {
22
56
  const getState = "store" in config
23
57
  ? () => config.store.getState() as S
@@ -1,73 +1,158 @@
1
1
  import type { GameStore } from "../state/store.ts";
2
2
 
3
- /** Verbosity levels for describe() output */
3
+ /**
4
+ * Verbosity levels for the describe() output.
5
+ * - `"minimal"` — one-line summary (e.g., for logs).
6
+ * - `"normal"` — standard detail (default).
7
+ * - `"detailed"` — full state dump for debugging.
8
+ */
4
9
  export type Verbosity = "minimal" | "normal" | "detailed";
5
10
 
6
- /** Options passed to describe() */
11
+ /**
12
+ * Options passed to the agent's describe function.
13
+ */
7
14
  export type DescribeOptions = Readonly<{
15
+ /** Detail level for the description. Default: "normal". */
8
16
  verbosity?: Verbosity;
17
+ /** Optional dot-path to focus description on a sub-tree of state (e.g., "player.inventory"). */
9
18
  path?: string;
10
19
  }>;
11
20
 
12
- /** Information about a registered action */
21
+ /**
22
+ * Metadata about a registered agent action, returned by `listActions()`.
23
+ * Used by AI agents and the HTTP inspector to discover available commands.
24
+ */
13
25
  export type ActionInfo = Readonly<{
26
+ /** Action name used to invoke via executeAction(). */
14
27
  name: string;
28
+ /** Human-readable description of what the action does. */
15
29
  description: string;
30
+ /** Optional argument schema for the action. */
16
31
  args?: readonly ArgInfo[];
17
32
  }>;
18
33
 
19
- /** Describes a single argument to an action */
34
+ /**
35
+ * Describes a single argument accepted by an agent action.
36
+ */
20
37
  export type ArgInfo = Readonly<{
38
+ /** Argument name (used as key in the args JSON object). */
21
39
  name: string;
40
+ /** Type hint (e.g., "string", "number", "EntityId"). */
22
41
  type: string;
42
+ /** Optional description of the argument's purpose and valid values. */
23
43
  description?: string;
24
44
  }>;
25
45
 
26
- /** Result of executing an action */
46
+ /**
47
+ * Result of executing an action via the agent protocol.
48
+ * @typeParam S - The game state type.
49
+ */
27
50
  export type ActionResult<S> = Readonly<{
51
+ /** True if the action executed successfully. */
28
52
  ok: boolean;
53
+ /** The game state after execution (unchanged if ok is false). */
29
54
  state: S;
55
+ /** Error message if ok is false. */
30
56
  error?: string;
31
57
  }>;
32
58
 
33
- /** Result of simulating an action (state not committed) */
59
+ /**
60
+ * Result of simulating an action without committing the state change.
61
+ * The original game state is not modified.
62
+ * @typeParam S - The game state type.
63
+ */
34
64
  export type SimulateResult<S> = Readonly<{
65
+ /** True if the simulation executed successfully. */
35
66
  ok: boolean;
67
+ /** The hypothetical state after the action (original state is untouched). */
36
68
  state: S;
69
+ /** Error message if ok is false. */
37
70
  error?: string;
38
71
  }>;
39
72
 
40
- /** A captured state snapshot for rewind */
73
+ /**
74
+ * A captured snapshot of game state at a point in time, used for rewind.
75
+ * @typeParam S - The game state type.
76
+ */
41
77
  export type SnapshotData<S> = Readonly<{
78
+ /** Deep clone of the game state at capture time. */
42
79
  state: S;
80
+ /** Unix timestamp (ms) when the snapshot was captured. */
43
81
  timestamp: number;
44
82
  }>;
45
83
 
46
- /** An action handler: takes current state and parsed args, returns new state */
84
+ /**
85
+ * A pure function that handles an agent action.
86
+ * Takes current state and parsed arguments, returns new state.
87
+ * Must not mutate the input state.
88
+ *
89
+ * @typeParam S - The game state type.
90
+ * @param state - Current game state.
91
+ * @param args - Parsed arguments from the action invocation.
92
+ * @returns New game state.
93
+ */
47
94
  export type ActionHandler<S> = (state: S, args: Record<string, unknown>) => S;
48
95
 
49
- /** Custom describe function */
96
+ /**
97
+ * Custom function for generating a text description of the game state.
98
+ * Used by `arcane describe` and the HTTP inspector.
99
+ *
100
+ * @typeParam S - The game state type.
101
+ * @param state - Current game state.
102
+ * @param options - Verbosity and optional path focus.
103
+ * @returns Human-readable text description.
104
+ */
50
105
  export type DescribeFn<S> = (state: S, options: DescribeOptions) => string;
51
106
 
52
- /** Agent configuration — either store-backed or get/setState-backed */
107
+ /**
108
+ * Configuration for registering an agent via {@link registerAgent}.
109
+ *
110
+ * Supports two state access patterns:
111
+ * - **Store-backed**: provide a `store` (GameStore) — state access is automatic.
112
+ * - **Manual**: provide `getState()` and `setState()` functions.
113
+ *
114
+ * @typeParam S - The game state type.
115
+ */
53
116
  export type AgentConfig<S> = {
117
+ /** Display name for the game/agent (shown in CLI and inspector). */
54
118
  name: string;
119
+ /**
120
+ * Optional map of named actions the agent can execute.
121
+ * Each action has a handler, description, and optional argument schema.
122
+ */
55
123
  actions?: Record<string, { handler: ActionHandler<S>; description: string; args?: ArgInfo[] }>;
124
+ /** Optional custom describe function. Falls back to defaultDescribe() if not provided. */
56
125
  describe?: DescribeFn<S>;
57
126
  } & (
58
- | { store: GameStore<S> }
59
- | { getState: () => S; setState: (s: S) => void }
127
+ | { /** GameStore instance for automatic state access. */ store: GameStore<S> }
128
+ | { /** Function to get current state. */ getState: () => S; /** Function to replace current state. */ setState: (s: S) => void }
60
129
  );
61
130
 
62
- /** The protocol object installed on globalThis */
131
+ /**
132
+ * The agent protocol object installed on `globalThis.__arcaneAgent`.
133
+ *
134
+ * Provides the interface that Rust CLI commands (`describe`, `inspect`, `dev --inspector`)
135
+ * use to interact with the game. Created by {@link registerAgent}.
136
+ *
137
+ * @typeParam S - The game state type.
138
+ */
63
139
  export type AgentProtocol<S> = Readonly<{
140
+ /** Game/agent display name. */
64
141
  name: string;
142
+ /** Get a deep reference to the current game state. */
65
143
  getState: () => S;
144
+ /** Query a value at a dot-separated path in the state tree. */
66
145
  inspect: (path: string) => unknown;
146
+ /** Generate a text description of the current state. */
67
147
  describe: (options?: DescribeOptions) => string;
148
+ /** List all registered actions with their metadata. */
68
149
  listActions: () => readonly ActionInfo[];
150
+ /** Execute a named action with optional JSON arguments. Commits state changes. */
69
151
  executeAction: (name: string, argsJson?: string) => ActionResult<S>;
152
+ /** Simulate a named action without committing. Returns hypothetical state. */
70
153
  simulate: (name: string, argsJson?: string) => SimulateResult<S>;
154
+ /** Reset state to the initial snapshot captured at registerAgent() time. */
71
155
  rewind: () => S;
156
+ /** Capture a deep clone of the current state as a snapshot. */
72
157
  captureSnapshot: () => SnapshotData<S>;
73
158
  }>;
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tests for particle system
3
+ */
4
+
5
+ import { describe, it, assert } from "../testing/harness.ts";
6
+ import {
7
+ createEmitter,
8
+ removeEmitter,
9
+ updateParticles,
10
+ getAllParticles,
11
+ addAffector,
12
+ clearEmitters,
13
+ getEmitterCount,
14
+ } from "./emitter.ts";
15
+ import type { EmitterConfig } from "./types.ts";
16
+
17
+ describe("Particle System", () => {
18
+ it("should create an emitter", () => {
19
+ clearEmitters();
20
+
21
+ const config: EmitterConfig = {
22
+ shape: "point",
23
+ x: 100,
24
+ y: 100,
25
+ mode: "burst",
26
+ burstCount: 10,
27
+ lifetime: [1, 2],
28
+ velocityX: [-50, 50],
29
+ velocityY: [-50, 50],
30
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
31
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
32
+ textureId: 1,
33
+ };
34
+
35
+ const emitter = createEmitter(config);
36
+ assert.ok(emitter.id);
37
+ assert.equal(getEmitterCount(), 1);
38
+ });
39
+
40
+ it("should emit particles in burst mode", () => {
41
+ clearEmitters();
42
+
43
+ const config: EmitterConfig = {
44
+ shape: "point",
45
+ x: 100,
46
+ y: 100,
47
+ mode: "burst",
48
+ burstCount: 10,
49
+ lifetime: [1, 1],
50
+ velocityX: [0, 0],
51
+ velocityY: [0, 0],
52
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
53
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
54
+ textureId: 1,
55
+ };
56
+
57
+ createEmitter(config);
58
+ updateParticles(0.1);
59
+
60
+ const particles = getAllParticles();
61
+ assert.equal(particles.length, 10);
62
+ });
63
+
64
+ it("should emit particles continuously", () => {
65
+ clearEmitters();
66
+
67
+ const config: EmitterConfig = {
68
+ shape: "point",
69
+ x: 100,
70
+ y: 100,
71
+ mode: "continuous",
72
+ rate: 10, // 10 particles per second
73
+ lifetime: [10, 10], // Long lifetime so they don't die during test
74
+ velocityX: [0, 0],
75
+ velocityY: [0, 0],
76
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
77
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
78
+ textureId: 1,
79
+ };
80
+
81
+ createEmitter(config);
82
+
83
+ // After 1 second, should have ~10 particles
84
+ updateParticles(1.0);
85
+ const particles = getAllParticles();
86
+ assert.ok(particles.length >= 9 && particles.length <= 11, `Expected ~10 particles, got ${particles.length}`);
87
+ });
88
+
89
+ it("should update particle positions", () => {
90
+ clearEmitters();
91
+
92
+ const config: EmitterConfig = {
93
+ shape: "point",
94
+ x: 0,
95
+ y: 0,
96
+ mode: "one-shot",
97
+ lifetime: [10, 10], // Long lifetime
98
+ velocityX: [100, 100], // Constant velocity
99
+ velocityY: [0, 0],
100
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
101
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
102
+ textureId: 1,
103
+ };
104
+
105
+ createEmitter(config);
106
+ updateParticles(0); // Spawn particle
107
+
108
+ const particles1 = getAllParticles();
109
+ assert.equal(particles1.length, 1);
110
+ const initialX = particles1[0].x;
111
+
112
+ // Update for 1 second
113
+ updateParticles(1.0);
114
+
115
+ const particles2 = getAllParticles();
116
+ assert.equal(particles2.length, 1);
117
+
118
+ // Should have moved 100 pixels to the right
119
+ const expectedX = initialX + 100;
120
+ assert.ok(Math.abs(particles2[0].x - expectedX) < 1, `Expected x ~${expectedX}, got ${particles2[0].x}`);
121
+ });
122
+
123
+ it("should kill particles after lifetime", () => {
124
+ clearEmitters();
125
+
126
+ const config: EmitterConfig = {
127
+ shape: "point",
128
+ x: 0,
129
+ y: 0,
130
+ mode: "burst",
131
+ burstCount: 5,
132
+ lifetime: [0.5, 0.5],
133
+ velocityX: [0, 0],
134
+ velocityY: [0, 0],
135
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
136
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
137
+ textureId: 1,
138
+ };
139
+
140
+ createEmitter(config);
141
+ updateParticles(0);
142
+
143
+ assert.equal(getAllParticles().length, 5);
144
+
145
+ // After 0.6 seconds, all should be dead
146
+ updateParticles(0.6);
147
+ assert.equal(getAllParticles().length, 0);
148
+ });
149
+
150
+ it("should interpolate colors over lifetime", () => {
151
+ clearEmitters();
152
+
153
+ const config: EmitterConfig = {
154
+ shape: "point",
155
+ x: 0,
156
+ y: 0,
157
+ mode: "one-shot",
158
+ lifetime: [1, 1],
159
+ velocityX: [0, 0],
160
+ velocityY: [0, 0],
161
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
162
+ endColor: { r: 0, g: 1, b: 0, a: 0 },
163
+ textureId: 1,
164
+ };
165
+
166
+ createEmitter(config);
167
+ updateParticles(0);
168
+
169
+ const particles = getAllParticles();
170
+ assert.equal(particles.length, 1);
171
+
172
+ // At start, color should be red
173
+ assert.ok(Math.abs(particles[0].color.r - 1) < 0.1);
174
+ assert.ok(Math.abs(particles[0].color.g - 0) < 0.1);
175
+
176
+ // After 0.5 seconds, color should be halfway
177
+ updateParticles(0.5);
178
+ assert.ok(Math.abs(particles[0].color.r - 0.5) < 0.1);
179
+ assert.ok(Math.abs(particles[0].color.g - 0.5) < 0.1);
180
+ });
181
+
182
+ it("should apply gravity affector", () => {
183
+ clearEmitters();
184
+
185
+ const config: EmitterConfig = {
186
+ shape: "point",
187
+ x: 0,
188
+ y: 0,
189
+ mode: "one-shot",
190
+ lifetime: [10, 10],
191
+ velocityX: [0, 0],
192
+ velocityY: [0, 0],
193
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
194
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
195
+ textureId: 1,
196
+ };
197
+
198
+ const emitter = createEmitter(config);
199
+ addAffector(emitter, {
200
+ type: "gravity",
201
+ forceX: 0,
202
+ forceY: 100, // Downward gravity
203
+ });
204
+
205
+ updateParticles(0);
206
+
207
+ const particles = getAllParticles();
208
+ const initialY = particles[0].y;
209
+
210
+ // After 1 second with gravity, should have fallen
211
+ updateParticles(1.0);
212
+ assert.ok(particles[0].y > initialY, "Particle should have fallen");
213
+ });
214
+
215
+ it("should remove emitters", () => {
216
+ clearEmitters();
217
+
218
+ const config: EmitterConfig = {
219
+ shape: "point",
220
+ x: 0,
221
+ y: 0,
222
+ mode: "burst",
223
+ burstCount: 5,
224
+ lifetime: [1, 1],
225
+ velocityX: [0, 0],
226
+ velocityY: [0, 0],
227
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
228
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
229
+ textureId: 1,
230
+ };
231
+
232
+ const emitter = createEmitter(config);
233
+ assert.equal(getEmitterCount(), 1);
234
+
235
+ removeEmitter(emitter);
236
+ assert.equal(getEmitterCount(), 0);
237
+ });
238
+
239
+ it("should respect maxParticles limit", () => {
240
+ clearEmitters();
241
+
242
+ const config: EmitterConfig = {
243
+ shape: "point",
244
+ x: 0,
245
+ y: 0,
246
+ mode: "continuous",
247
+ rate: 100,
248
+ lifetime: [10, 10], // Long lifetime so they don't die
249
+ velocityX: [0, 0],
250
+ velocityY: [0, 0],
251
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
252
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
253
+ textureId: 1,
254
+ maxParticles: 10,
255
+ };
256
+
257
+ createEmitter(config);
258
+ updateParticles(1.0); // Try to spawn 100 particles
259
+
260
+ const particles = getAllParticles();
261
+ assert.ok(particles.length <= 10, `Should not exceed maxParticles, got ${particles.length}`);
262
+ });
263
+
264
+ it("should spawn particles in area shape", () => {
265
+ clearEmitters();
266
+
267
+ const config: EmitterConfig = {
268
+ shape: "area",
269
+ x: 100,
270
+ y: 100,
271
+ shapeParams: { width: 50, height: 50 },
272
+ mode: "burst",
273
+ burstCount: 10,
274
+ lifetime: [1, 1],
275
+ velocityX: [0, 0],
276
+ velocityY: [0, 0],
277
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
278
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
279
+ textureId: 1,
280
+ };
281
+
282
+ createEmitter(config);
283
+ updateParticles(0);
284
+
285
+ const particles = getAllParticles();
286
+
287
+ // All particles should be within the area
288
+ for (const p of particles) {
289
+ assert.ok(p.x >= 100 && p.x <= 150);
290
+ assert.ok(p.y >= 100 && p.y <= 150);
291
+ }
292
+ });
293
+
294
+ it("should spawn particles in ring shape", () => {
295
+ clearEmitters();
296
+
297
+ const config: EmitterConfig = {
298
+ shape: "ring",
299
+ x: 0,
300
+ y: 0,
301
+ shapeParams: { innerRadius: 10, outerRadius: 20 },
302
+ mode: "burst",
303
+ burstCount: 10,
304
+ lifetime: [1, 1],
305
+ velocityX: [0, 0],
306
+ velocityY: [0, 0],
307
+ startColor: { r: 1, g: 0, b: 0, a: 1 },
308
+ endColor: { r: 1, g: 1, b: 0, a: 0 },
309
+ textureId: 1,
310
+ };
311
+
312
+ createEmitter(config);
313
+ updateParticles(0);
314
+
315
+ const particles = getAllParticles();
316
+
317
+ // All particles should be within the ring
318
+ for (const p of particles) {
319
+ const dist = Math.sqrt(p.x * p.x + p.y * p.y);
320
+ assert.ok(dist >= 10 && dist <= 20, `Particle at distance ${dist} should be in ring [10, 20]`);
321
+ }
322
+ });
323
+ });