@codehz/ecs 0.7.2 → 0.7.4
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/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +60 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- /package/{builder.d.mts → dist/builder.d.mts} +0 -0
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
- /package/{world.mjs → dist/world.mjs} +0 -0
- /package/{world.mjs.map → dist/world.mjs.map} +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { pipeline } from "@codehz/pipeline";
|
|
2
|
+
import { World, component, type Query } from "../src";
|
|
3
|
+
|
|
4
|
+
// ── Component Type Definitions ───────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
type State = { current: "idle" | "patrol" | "flee"; timer: number };
|
|
7
|
+
type Health = { value: number; maxValue: number };
|
|
8
|
+
type Speed = { value: number };
|
|
9
|
+
type Target = { x: number; y: number };
|
|
10
|
+
type Position = { x: number; y: number };
|
|
11
|
+
|
|
12
|
+
// Void component: presence of this marks an entity as AI-controlled
|
|
13
|
+
type AIEnabled = void;
|
|
14
|
+
|
|
15
|
+
// ── Component ID Registration ────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const State = component<State>({ name: "State" });
|
|
18
|
+
const Health = component<Health>({ name: "Health" });
|
|
19
|
+
const Speed = component<Speed>({ name: "Speed" });
|
|
20
|
+
const Target = component<Target>({ name: "Target" });
|
|
21
|
+
const Position = component<Position>({ name: "Position" });
|
|
22
|
+
const AIEnabled = component<AIEnabled>({ name: "AIEnabled" });
|
|
23
|
+
|
|
24
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function distance(a: Position, b: Target): number {
|
|
27
|
+
const dx = b.x - a.x;
|
|
28
|
+
const dy = b.y - a.y;
|
|
29
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function randomTarget(): Target {
|
|
33
|
+
return {
|
|
34
|
+
x: Math.round((Math.random() * 20 - 10) * 100) / 100,
|
|
35
|
+
y: Math.round((Math.random() * 20 - 10) * 100) / 100,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function main() {
|
|
42
|
+
console.log("ECS State Machine & AI Demo - Lifecycle Hooks");
|
|
43
|
+
console.log("=============================================\n");
|
|
44
|
+
|
|
45
|
+
const world = new World();
|
|
46
|
+
|
|
47
|
+
// ── Queries (pre-cached for performance) ─────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const aiQuery: Query = world.createQuery([State, Position, Speed, Target, AIEnabled]);
|
|
50
|
+
const healthQuery: Query = world.createQuery([Health]);
|
|
51
|
+
|
|
52
|
+
// ── Lifecycle Hooks ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
// Log health changes
|
|
55
|
+
const unsubHealth = world.hook([Health], (type, entityId, health) => {
|
|
56
|
+
const marker = type === "init" ? "[INIT]" : type === "set" ? "[SET]" : "[REMOVE]";
|
|
57
|
+
console.log(` ${marker} Health | entity=${entityId} | value=${health.value.toFixed(1)}/${health.maxValue}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Log state transitions
|
|
61
|
+
const unsubState = world.hook([State], (type, entityId, state) => {
|
|
62
|
+
const marker = type === "init" ? "[INIT]" : type === "set" ? "[SET]" : "[REMOVE]";
|
|
63
|
+
console.log(` ${marker} State | entity=${entityId} | current=${state.current} | timer=${state.timer.toFixed(2)}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Pass 1: AI State Machine ────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const aiStatePass = (env: { deltaTime: number }) => {
|
|
69
|
+
const dt = env.deltaTime;
|
|
70
|
+
|
|
71
|
+
aiQuery.forEach([State, Position, Speed, Target], (entity, state, position, speed, target) => {
|
|
72
|
+
switch (state.current) {
|
|
73
|
+
case "idle": {
|
|
74
|
+
state.timer -= dt;
|
|
75
|
+
if (state.timer <= 0) {
|
|
76
|
+
// Switch to patrol with a fresh random target
|
|
77
|
+
state.current = "patrol";
|
|
78
|
+
state.timer = 0;
|
|
79
|
+
const newTarget = randomTarget();
|
|
80
|
+
world.set(entity, Target, newTarget);
|
|
81
|
+
console.log(` [AI] Entity ${entity}: idle → patrol | new target (${newTarget.x}, ${newTarget.y})`);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case "patrol": {
|
|
87
|
+
const dist = distance(position, target);
|
|
88
|
+
const moveSpeed = speed.value * dt;
|
|
89
|
+
|
|
90
|
+
if (dist <= moveSpeed) {
|
|
91
|
+
// Arrived at target → switch back to idle
|
|
92
|
+
position.x = target.x;
|
|
93
|
+
position.y = target.y;
|
|
94
|
+
world.set(entity, Position, { x: position.x, y: position.y });
|
|
95
|
+
state.current = "idle";
|
|
96
|
+
state.timer = 1.0 + Math.random() * 2.0; // idle for 1–3 seconds
|
|
97
|
+
console.log(
|
|
98
|
+
` [AI] Entity ${entity}: patrol → idle | arrived at (${target.x}, ${target.y}) | idle ${state.timer.toFixed(2)}s`,
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
// Move toward target
|
|
102
|
+
const dx = (target.x - position.x) / dist;
|
|
103
|
+
const dy = (target.y - position.y) / dist;
|
|
104
|
+
position.x += dx * moveSpeed;
|
|
105
|
+
position.y += dy * moveSpeed;
|
|
106
|
+
world.set(entity, Position, { x: position.x, y: position.y });
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "flee": {
|
|
112
|
+
// Move away from target at 2x speed (panicked retreat)
|
|
113
|
+
const dist = distance(position, target);
|
|
114
|
+
const fleeSpeed = speed.value * 2.0 * dt;
|
|
115
|
+
|
|
116
|
+
if (dist > 0.001) {
|
|
117
|
+
const dx = (position.x - target.x) / dist;
|
|
118
|
+
const dy = (position.y - target.y) / dist;
|
|
119
|
+
position.x += dx * fleeSpeed;
|
|
120
|
+
position.y += dy * fleeSpeed;
|
|
121
|
+
} else {
|
|
122
|
+
// Exactly on target — pick arbitrary direction
|
|
123
|
+
position.x += fleeSpeed;
|
|
124
|
+
}
|
|
125
|
+
world.set(entity, Position, { x: position.x, y: position.y });
|
|
126
|
+
|
|
127
|
+
state.timer += dt;
|
|
128
|
+
// Flee for at least 3 seconds, then try going back to idle
|
|
129
|
+
if (state.timer >= 3.0) {
|
|
130
|
+
state.current = "idle";
|
|
131
|
+
state.timer = 1.0;
|
|
132
|
+
console.log(` [AI] Entity ${entity}: flee → idle | recovered after ${state.timer.toFixed(2)}s`);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Commit state changes (timer always updates)
|
|
139
|
+
world.set(entity, State, { current: state.current, timer: state.timer });
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ── Pass 2: Health Check → Trigger Flee ─────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const healthCheckPass = () => {
|
|
146
|
+
healthQuery.forEach([Health], (entity, health) => {
|
|
147
|
+
if (health.value < health.maxValue * 0.3) {
|
|
148
|
+
// Entity is low-health; switch to flee
|
|
149
|
+
const state = world.get(entity, State);
|
|
150
|
+
if (state && state.current !== "flee") {
|
|
151
|
+
console.log(` [HealthCheck] Entity ${entity}: health ${health.value.toFixed(1)} < 30% → switching to flee`);
|
|
152
|
+
world.set(entity, State, { current: "flee", timer: 0 });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ── Pass 3: Status Log ──────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const statusLogPass = () => {
|
|
161
|
+
console.log(" --- Status ---");
|
|
162
|
+
aiQuery.forEach([State, Position, Health], (entity, state, position, health) => {
|
|
163
|
+
// Health is optional for AI entities (some may not have it)
|
|
164
|
+
const hp = health ? `${health.value.toFixed(1)}/${health.maxValue}` : "N/A";
|
|
165
|
+
console.log(
|
|
166
|
+
` Entity ${entity}: state=${state.current} | pos=(${position.x.toFixed(2)}, ${position.y.toFixed(2)}) | hp=${hp}`,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
console.log("");
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── Pass 4: Sync ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const syncPass = () => {
|
|
175
|
+
world.sync();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ── Build Pipeline ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const gameLoop = pipeline<{ deltaTime: number }>()
|
|
181
|
+
.addPass(aiStatePass)
|
|
182
|
+
.addPass(healthCheckPass)
|
|
183
|
+
.addPass(statusLogPass)
|
|
184
|
+
.addPass(syncPass)
|
|
185
|
+
.build();
|
|
186
|
+
|
|
187
|
+
// ── Setup Entities ──────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
// Entity 1: Starts in "idle", healthy
|
|
190
|
+
const e1 = world
|
|
191
|
+
.spawn()
|
|
192
|
+
.with(State, { current: "idle", timer: 0.5 })
|
|
193
|
+
.with(Position, { x: 0, y: 0 })
|
|
194
|
+
.with(Speed, { value: 3.0 })
|
|
195
|
+
.with(Target, { x: 0, y: 0 })
|
|
196
|
+
.with(Health, { value: 100, maxValue: 100 })
|
|
197
|
+
.with(AIEnabled)
|
|
198
|
+
.build();
|
|
199
|
+
|
|
200
|
+
// Entity 2: Starts in "patrol", already moving toward a target
|
|
201
|
+
const e2 = world
|
|
202
|
+
.spawn()
|
|
203
|
+
.with(State, { current: "patrol", timer: 0 })
|
|
204
|
+
.with(Position, { x: 10, y: 0 })
|
|
205
|
+
.with(Speed, { value: 5.0 })
|
|
206
|
+
.with(Target, { x: 10, y: 10 })
|
|
207
|
+
.with(Health, { value: 40, maxValue: 100 }) // will drop below 30% soon
|
|
208
|
+
.with(AIEnabled)
|
|
209
|
+
.build();
|
|
210
|
+
|
|
211
|
+
// Entity 3: Starts in "idle", somewhat wounded
|
|
212
|
+
const e3 = world
|
|
213
|
+
.spawn()
|
|
214
|
+
.with(State, { current: "idle", timer: 1.0 })
|
|
215
|
+
.with(Position, { x: -5, y: 5 })
|
|
216
|
+
.with(Speed, { value: 2.0 })
|
|
217
|
+
.with(Target, { x: -5, y: 5 })
|
|
218
|
+
.with(Health, { value: 25, maxValue: 100 }) // already below 30%
|
|
219
|
+
.with(AIEnabled)
|
|
220
|
+
.build();
|
|
221
|
+
|
|
222
|
+
// Initial sync — applies all buffered commands and fires "init" hooks
|
|
223
|
+
console.log("--- Initial Sync (init hooks fire) ---\n");
|
|
224
|
+
world.sync();
|
|
225
|
+
|
|
226
|
+
// ── Run Frames ──────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
console.log("\n=== Frame 1 (dt=1.0) ===");
|
|
229
|
+
gameLoop({ deltaTime: 1.0 });
|
|
230
|
+
|
|
231
|
+
console.log("=== Frame 2 (dt=1.0) ===");
|
|
232
|
+
gameLoop({ deltaTime: 1.0 });
|
|
233
|
+
|
|
234
|
+
console.log("=== Frame 3 (dt=1.0) ===");
|
|
235
|
+
// Deplete entity 2's health to trigger flee
|
|
236
|
+
{
|
|
237
|
+
const h2 = world.get(e2, Health);
|
|
238
|
+
world.set(e2, Health, { value: h2.value - 20, maxValue: h2.maxValue });
|
|
239
|
+
const h3 = world.get(e3, Health);
|
|
240
|
+
world.set(e3, Health, { value: h3.value - 5, maxValue: h3.maxValue });
|
|
241
|
+
}
|
|
242
|
+
gameLoop({ deltaTime: 1.0 });
|
|
243
|
+
|
|
244
|
+
console.log("=== Frame 4 (dt=1.0) ===");
|
|
245
|
+
// Further deplete entity 1 to get it low too
|
|
246
|
+
{
|
|
247
|
+
const h1 = world.get(e1, Health);
|
|
248
|
+
world.set(e1, Health, { value: h1.value - 40, maxValue: h1.maxValue });
|
|
249
|
+
}
|
|
250
|
+
gameLoop({ deltaTime: 1.0 });
|
|
251
|
+
|
|
252
|
+
console.log("=== Frame 5 (dt=1.0) ===");
|
|
253
|
+
// Entity 1 now gets very low
|
|
254
|
+
{
|
|
255
|
+
const h1 = world.get(e1, Health);
|
|
256
|
+
world.set(e1, Health, { value: h1.value - 40, maxValue: h1.maxValue });
|
|
257
|
+
}
|
|
258
|
+
gameLoop({ deltaTime: 1.0 });
|
|
259
|
+
|
|
260
|
+
console.log("=== Frame 6 (dt=1.0) ===");
|
|
261
|
+
gameLoop({ deltaTime: 1.0 });
|
|
262
|
+
|
|
263
|
+
// ── Cleanup ─────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
unsubHealth();
|
|
266
|
+
unsubState();
|
|
267
|
+
|
|
268
|
+
console.log("Demo completed!");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Entry Point ──────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
main();
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { pipeline } from "@codehz/pipeline";
|
|
2
|
+
import { World, component, type EntityId, type Query } from "../src";
|
|
3
|
+
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Component type definitions
|
|
6
|
+
// =============================================================================
|
|
7
|
+
type Position = { x: number; y: number };
|
|
8
|
+
type Health = { value: number };
|
|
9
|
+
type Damage = { value: number };
|
|
10
|
+
type Team = { id: number }; // 1 = ally, 2 = enemy
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Component IDs — void components (tags) use component() with no type arg
|
|
14
|
+
// =============================================================================
|
|
15
|
+
const Position = component<Position>({ name: "Position" });
|
|
16
|
+
const Health = component<Health>({ name: "Health" });
|
|
17
|
+
const Damage = component<Damage>({ name: "Damage" });
|
|
18
|
+
const Team = component<Team>({ name: "Team" });
|
|
19
|
+
const Alive = component({ name: "Alive" }); // void tag — living entities
|
|
20
|
+
const Stunned = component({ name: "Stunned" }); // void tag — CC'd entities
|
|
21
|
+
const Invisible = component({ name: "Invisible" }); // void tag — stealthed entities
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// World & pre-cached queries
|
|
25
|
+
// =============================================================================
|
|
26
|
+
const world = new World();
|
|
27
|
+
|
|
28
|
+
// livingAllies: [Position, Health, Team] — allies that are alive but NOT stunned
|
|
29
|
+
const livingAllies: Query = world.createQuery([Position, Health, Team], {
|
|
30
|
+
negativeComponentTypes: [Stunned],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// visibleEnemies: [Position, Team] — all enemies (for detection/rendering)
|
|
34
|
+
const visibleEnemies: Query = world.createQuery([Position, Team]);
|
|
35
|
+
|
|
36
|
+
// damageableAllies: [Position, Health, Team] — allies that can be healed
|
|
37
|
+
const damageableAllies: Query = world.createQuery([Position, Health, Team]);
|
|
38
|
+
|
|
39
|
+
// threats: [Position, Damage, Team] — enemies that deal damage
|
|
40
|
+
const threats: Query = world.createQuery([Position, Damage, Team]);
|
|
41
|
+
|
|
42
|
+
// ccTargets: [Position, Team] — valid CC targets (exclude stunned + invisible)
|
|
43
|
+
const ccTargets: Query = world.createQuery([Position, Team], {
|
|
44
|
+
negativeComponentTypes: [Stunned, Invisible],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Helper: distance between two positions
|
|
49
|
+
// =============================================================================
|
|
50
|
+
function dist(a: Position, b: Position): number {
|
|
51
|
+
const dx = a.x - b.x;
|
|
52
|
+
const dy = a.y - b.y;
|
|
53
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Game loop built with @codehz/pipeline
|
|
58
|
+
// =============================================================================
|
|
59
|
+
const gameLoop = pipeline()
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// DamagePass: threats find nearest livingAlly and deal damage
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
.addPass(() => {
|
|
64
|
+
// Collect all living ally positions for target selection
|
|
65
|
+
const allyEntries: Array<{ entity: EntityId; pos: Position; hp: Health }> = [];
|
|
66
|
+
livingAllies.forEach([Position, Health, Team], (entity, pos, hp, team) => {
|
|
67
|
+
if (team.id === 1) {
|
|
68
|
+
allyEntries.push({ entity, pos, hp });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (allyEntries.length === 0) return;
|
|
73
|
+
|
|
74
|
+
threats.forEach([Position, Damage, Team], (entity, pos, dmg, team) => {
|
|
75
|
+
if (team.id !== 2) return; // only enemies
|
|
76
|
+
|
|
77
|
+
// Find nearest ally
|
|
78
|
+
let nearest: (typeof allyEntries)[0] | null = null;
|
|
79
|
+
let nearestDist = Infinity;
|
|
80
|
+
for (const ally of allyEntries) {
|
|
81
|
+
const d = dist(pos, ally.pos);
|
|
82
|
+
if (d < nearestDist) {
|
|
83
|
+
nearestDist = d;
|
|
84
|
+
nearest = ally;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (nearest) {
|
|
89
|
+
nearest.hp.value -= dmg.value;
|
|
90
|
+
console.log(
|
|
91
|
+
`[DamagePass] Enemy ${entity} hits Ally ${nearest.entity} ` +
|
|
92
|
+
`for ${dmg.value} damage (HP: ${nearest.hp.value})`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// If health depleted, mark for cleanup: remove Alive, add Invisible
|
|
96
|
+
if (nearest.hp.value <= 0) {
|
|
97
|
+
nearest.hp.value = 0;
|
|
98
|
+
world.remove(nearest.entity, Alive);
|
|
99
|
+
world.set(nearest.entity, Invisible);
|
|
100
|
+
console.log(`[DamagePass] Ally ${nearest.entity} has fallen! (Alive removed, Invisible added)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// HealPass: heal all damageable allies for a small amount
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
.addPass(() => {
|
|
110
|
+
damageableAllies.forEach([Position, Health, Team], (entity, _pos, hp, team) => {
|
|
111
|
+
if (team.id !== 1) return;
|
|
112
|
+
const healAmount = 5;
|
|
113
|
+
const oldHp = hp.value;
|
|
114
|
+
hp.value = Math.min(hp.value + healAmount, 100);
|
|
115
|
+
if (hp.value !== oldHp) {
|
|
116
|
+
console.log(`[HealPass] Ally ${entity} healed by ${healAmount} ` + `(HP: ${oldHp} -> ${hp.value})`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// CCApplicationPass: apply Stunned to a random valid ccTarget (if any exist)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
.addPass(() => {
|
|
125
|
+
const targets: Array<{ entity: EntityId; team: Team }> = [];
|
|
126
|
+
ccTargets.forEach([Position, Team], (entity, _pos, team) => {
|
|
127
|
+
targets.push({ entity, team });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const target = targets[0];
|
|
131
|
+
if (target) {
|
|
132
|
+
// Pick the first valid target (deterministic for demo)
|
|
133
|
+
world.set(target.entity, Stunned);
|
|
134
|
+
console.log(`[CCApplicationPass] Stunned applied to Entity ${target.entity} ` + `(Team ${target.team.id})`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(`[CCApplicationPass] No valid CC targets available`);
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// CCExpiryPass: remove Stunned from all currently-stunned entities
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
.addPass(() => {
|
|
144
|
+
// Use getEntities on a query that just checks for Stunned presence.
|
|
145
|
+
// We don't have a dedicated "stunnedOnly" query, so iterate via allTeams.
|
|
146
|
+
// Better: iterate all livingAllies + visibleEnemies and check has(Stunned).
|
|
147
|
+
const allEntities = world.createQuery([Position]);
|
|
148
|
+
const stunnedEntities: EntityId[] = [];
|
|
149
|
+
allEntities.forEach([Position], (entity) => {
|
|
150
|
+
if (world.has(entity, Stunned)) {
|
|
151
|
+
stunnedEntities.push(entity);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
allEntities.dispose(); // one-shot query — release immediately
|
|
155
|
+
|
|
156
|
+
for (const entity of stunnedEntities) {
|
|
157
|
+
world.remove(entity, Stunned);
|
|
158
|
+
console.log(`[CCExpiryPass] Stunned expired on Entity ${entity}`);
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// CleanupPass: delete entities that are dead (no Alive tag)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
.addPass(() => {
|
|
166
|
+
const deadEntities: EntityId[] = [];
|
|
167
|
+
// Use a temporary query to find entities with Position (our marker for
|
|
168
|
+
// "active entity") that lack the Alive tag.
|
|
169
|
+
const allPosQuery = world.createQuery([Position]);
|
|
170
|
+
allPosQuery.forEach([Position], (entity) => {
|
|
171
|
+
if (!world.has(entity, Alive)) {
|
|
172
|
+
deadEntities.push(entity);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
allPosQuery.dispose();
|
|
176
|
+
|
|
177
|
+
for (const entity of deadEntities) {
|
|
178
|
+
world.delete(entity);
|
|
179
|
+
console.log(`[CleanupPass] Deleted dead Entity ${entity}`);
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// StatusPass: log counts of each query result set
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
.addPass(() => {
|
|
187
|
+
console.log(`[StatusPass] === Frame Summary ===`);
|
|
188
|
+
console.log(` livingAllies: ${livingAllies.getEntities().length}`);
|
|
189
|
+
console.log(` visibleEnemies: ${visibleEnemies.getEntities().length}`);
|
|
190
|
+
console.log(` damageableAllies: ${damageableAllies.getEntities().length}`);
|
|
191
|
+
console.log(` threats: ${threats.getEntities().length}`);
|
|
192
|
+
console.log(` ccTargets: ${ccTargets.getEntities().length}`);
|
|
193
|
+
console.log(`[StatusPass] ========================`);
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// SyncPass: materialise all deferred structural changes
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
.addPass(() => {
|
|
200
|
+
world.sync();
|
|
201
|
+
})
|
|
202
|
+
.build();
|
|
203
|
+
|
|
204
|
+
// =============================================================================
|
|
205
|
+
// Setup: create entities with different tag/team combinations
|
|
206
|
+
// =============================================================================
|
|
207
|
+
function setup() {
|
|
208
|
+
console.log("=== Tag Filtering Demo Setup ===\n");
|
|
209
|
+
|
|
210
|
+
// --- 2 ally soldiers (Team 1, Alive, Position, Health=100) ---
|
|
211
|
+
world.spawn().with(Position, { x: 0, y: 0 }).with(Health, { value: 100 }).with(Team, { id: 1 }).with(Alive).build();
|
|
212
|
+
|
|
213
|
+
world.spawn().with(Position, { x: 5, y: 5 }).with(Health, { value: 100 }).with(Team, { id: 1 }).with(Alive).build();
|
|
214
|
+
|
|
215
|
+
// --- 3 enemy soldiers (Team 2, Alive, Position, Health=80, Damage=10) ---
|
|
216
|
+
for (let i = 0; i < 3; i++) {
|
|
217
|
+
world
|
|
218
|
+
.spawn()
|
|
219
|
+
.with(Position, { x: 20 + i * 10, y: 20 + i * 5 })
|
|
220
|
+
.with(Health, { value: 80 })
|
|
221
|
+
.with(Damage, { value: 10 })
|
|
222
|
+
.with(Team, { id: 2 })
|
|
223
|
+
.with(Alive)
|
|
224
|
+
.build();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- 1 enemy mage (Team 2, Alive, Position, Health=50, Damage=25, Invisible) ---
|
|
228
|
+
world
|
|
229
|
+
.spawn()
|
|
230
|
+
.with(Position, { x: 50, y: 50 })
|
|
231
|
+
.with(Health, { value: 50 })
|
|
232
|
+
.with(Damage, { value: 25 })
|
|
233
|
+
.with(Team, { id: 2 })
|
|
234
|
+
.with(Alive)
|
|
235
|
+
.with(Invisible) // starts stealthed — excluded from ccTargets
|
|
236
|
+
.build();
|
|
237
|
+
|
|
238
|
+
// --- 1 pre-stunned ally (Team 1, Alive, Position, Health=60, Stunned) ---
|
|
239
|
+
world
|
|
240
|
+
.spawn()
|
|
241
|
+
.with(Position, { x: -10, y: -10 })
|
|
242
|
+
.with(Health, { value: 60 })
|
|
243
|
+
.with(Team, { id: 1 })
|
|
244
|
+
.with(Alive)
|
|
245
|
+
.with(Stunned) // starts CC'd — excluded from livingAllies until expiry
|
|
246
|
+
.build();
|
|
247
|
+
|
|
248
|
+
world.sync();
|
|
249
|
+
console.log("Setup complete. Starting simulation...\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// Main entry point
|
|
254
|
+
// =============================================================================
|
|
255
|
+
function main() {
|
|
256
|
+
setup();
|
|
257
|
+
|
|
258
|
+
for (let frame = 1; frame <= 4; frame++) {
|
|
259
|
+
console.log(`\n--- Frame ${frame} ---`);
|
|
260
|
+
gameLoop({});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log("\n=== Demo completed! ===");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,23 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codehz/ecs",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
},
|
|
3
|
+
"version": "0.7.4",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"module": "src/index.ts",
|
|
7
6
|
"type": "module",
|
|
8
|
-
"main": "./index.mjs",
|
|
9
|
-
"types": "./index.d.mts",
|
|
7
|
+
"main": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
10
9
|
"exports": {
|
|
11
10
|
".": {
|
|
12
|
-
"types": "./index.d.mts",
|
|
13
|
-
"import": "./index.mjs"
|
|
11
|
+
"types": "./dist/index.d.mts",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"perry": "./src/index.ts"
|
|
14
14
|
},
|
|
15
15
|
"./testing": {
|
|
16
|
-
"types": "./testing.d.mts",
|
|
17
|
-
"import": "./testing.mjs"
|
|
16
|
+
"types": "./dist/testing.d.mts",
|
|
17
|
+
"import": "./dist/testing.mjs",
|
|
18
|
+
"perry": "./src/testing/index.ts"
|
|
18
19
|
}
|
|
19
20
|
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"examples",
|
|
24
|
+
"src",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"README.md",
|
|
27
|
+
"README.en.md"
|
|
28
|
+
],
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@codehz/pipeline": "^0.3.0",
|
|
31
|
+
"@types/bun": "1.3.2",
|
|
32
|
+
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
|
33
|
+
"@typescript-eslint/parser": "^8.48.1",
|
|
34
|
+
"eslint": "^9.39.1",
|
|
35
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
36
|
+
"husky": "^9.1.7",
|
|
37
|
+
"lint-staged": "^16.2.6",
|
|
38
|
+
"prettier": "^3.6.2",
|
|
39
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
40
|
+
"tsdown": "^0.16.6"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"url": "https://github.com/codehz/ecs"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"demo": "bun run examples/simple.ts",
|
|
48
|
+
"release": "bun run scripts/build.ts",
|
|
49
|
+
"prepare": "husky",
|
|
50
|
+
"lint": "eslint --ext .ts,.tsx,.js --quiet",
|
|
51
|
+
"lint:fix": "eslint --ext .ts,.tsx,.js --fix",
|
|
52
|
+
"format": "prettier --write ."
|
|
53
|
+
},
|
|
20
54
|
"peerDependencies": {
|
|
21
|
-
"typescript": "^
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"lint-staged": {
|
|
58
|
+
"*.{ts,tsx}": [
|
|
59
|
+
"prettier --write",
|
|
60
|
+
"eslint --fix"
|
|
61
|
+
],
|
|
62
|
+
"*.{json,md}": [
|
|
63
|
+
"prettier --write"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"perry": {
|
|
67
|
+
"compilePackages": [
|
|
68
|
+
"@codehz/pipeline"
|
|
69
|
+
]
|
|
22
70
|
}
|
|
23
|
-
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("CommandBuffer iteration limit", () => {
|
|
6
|
+
it("should throw when exceeding MAX_ITERATIONS", () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const Counter = component<{ value: number }>();
|
|
9
|
+
|
|
10
|
+
// Create a hook that recursively increments a counter
|
|
11
|
+
world.hook([Counter], {
|
|
12
|
+
on_set: (entityId, data) => {
|
|
13
|
+
// Keep triggering new set commands beyond the iteration limit
|
|
14
|
+
if (data.value < 200) {
|
|
15
|
+
world.set(entityId, Counter, { value: data.value + 1 });
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const entity = world.new();
|
|
21
|
+
world.set(entity, Counter, { value: 0 });
|
|
22
|
+
|
|
23
|
+
// Executing should exceed iteration limit and throw
|
|
24
|
+
expect(() => world.sync()).toThrow(/maximum.*iterations|exceeded/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should handle multiple entities with cascading commands", () => {
|
|
28
|
+
const world = new World();
|
|
29
|
+
const Increment = component<{ count: number }>();
|
|
30
|
+
|
|
31
|
+
let hookCalls = 0;
|
|
32
|
+
world.hook([Increment], {
|
|
33
|
+
on_set: (entityId, data) => {
|
|
34
|
+
hookCalls++;
|
|
35
|
+
// Only trigger new commands on first few calls to avoid exceeding limit
|
|
36
|
+
// We have 2 entities, each initial set + cascading calls
|
|
37
|
+
if (hookCalls < 25) {
|
|
38
|
+
world.set(entityId, Increment, { count: data.count + 1 });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const entity1 = world.new();
|
|
44
|
+
const entity2 = world.new();
|
|
45
|
+
|
|
46
|
+
world.set(entity1, Increment, { count: 0 });
|
|
47
|
+
world.set(entity2, Increment, { count: 0 });
|
|
48
|
+
|
|
49
|
+
// This should complete without reaching the iteration limit (100)
|
|
50
|
+
world.sync();
|
|
51
|
+
expect(hookCalls).toBeGreaterThan(0);
|
|
52
|
+
expect(hookCalls).toBeLessThan(100);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should execute all commands within iteration limit", () => {
|
|
56
|
+
const world = new World();
|
|
57
|
+
const Value = component<{ num: number }>();
|
|
58
|
+
|
|
59
|
+
const entity = world.new();
|
|
60
|
+
|
|
61
|
+
// Queue multiple commands before sync
|
|
62
|
+
for (let i = 0; i < 50; i++) {
|
|
63
|
+
world.set(entity, Value, { num: i });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Should complete without throwing
|
|
67
|
+
world.sync();
|
|
68
|
+
|
|
69
|
+
// Final value should be the last one set
|
|
70
|
+
expect(world.get(entity, Value)).toEqual({ num: 49 });
|
|
71
|
+
});
|
|
72
|
+
});
|