@arcane-engine/runtime 0.1.0 → 0.2.1
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.
- package/package.json +4 -2
- package/src/agent/protocol.ts +35 -1
- package/src/agent/types.ts +98 -13
- package/src/particles/emitter.test.ts +323 -0
- package/src/particles/emitter.ts +409 -0
- package/src/particles/index.ts +25 -0
- package/src/particles/types.ts +236 -0
- package/src/pathfinding/astar.ts +27 -0
- package/src/pathfinding/types.ts +39 -0
- package/src/physics/aabb.ts +55 -8
- package/src/rendering/animation.ts +73 -0
- package/src/rendering/audio.ts +29 -9
- package/src/rendering/camera.ts +28 -4
- package/src/rendering/input.ts +45 -9
- package/src/rendering/lighting.ts +29 -3
- package/src/rendering/loop.ts +16 -3
- package/src/rendering/sprites.ts +24 -1
- package/src/rendering/text.ts +52 -6
- package/src/rendering/texture.ts +22 -4
- package/src/rendering/tilemap.ts +36 -4
- package/src/rendering/types.ts +37 -19
- package/src/rendering/validate.ts +48 -3
- package/src/state/error.ts +21 -2
- package/src/state/observe.ts +40 -9
- package/src/state/prng.ts +88 -10
- package/src/state/query.ts +115 -15
- package/src/state/store.ts +42 -11
- package/src/state/transaction.ts +116 -12
- package/src/state/types.ts +31 -5
- package/src/systems/system.ts +77 -5
- package/src/systems/types.ts +52 -6
- package/src/testing/harness.ts +103 -5
- package/src/testing/mock-renderer.test.ts +16 -20
- package/src/tweening/chain.test.ts +191 -0
- package/src/tweening/chain.ts +103 -0
- package/src/tweening/easing.test.ts +134 -0
- package/src/tweening/easing.ts +288 -0
- package/src/tweening/helpers.test.ts +185 -0
- package/src/tweening/helpers.ts +166 -0
- package/src/tweening/index.ts +76 -0
- package/src/tweening/tween.test.ts +322 -0
- package/src/tweening/tween.ts +296 -0
- package/src/tweening/types.ts +134 -0
- package/src/ui/colors.ts +129 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/primitives.ts +44 -5
- 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
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
package/src/agent/protocol.ts
CHANGED
|
@@ -17,7 +17,41 @@ function deepClone<T>(value: T): T {
|
|
|
17
17
|
return JSON.parse(JSON.stringify(value));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
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
|
package/src/agent/types.ts
CHANGED
|
@@ -1,73 +1,158 @@
|
|
|
1
1
|
import type { GameStore } from "../state/store.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
+
});
|