murow 0.0.31 → 0.0.41
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/dist/ecs/component-store.d.ts +79 -0
- package/dist/ecs/component-store.js +161 -0
- package/dist/ecs/component.d.ts +48 -0
- package/dist/ecs/component.js +43 -0
- package/dist/ecs/example.d.ts +6 -0
- package/dist/ecs/example.js +125 -0
- package/dist/ecs/index.d.ts +3 -0
- package/dist/ecs/index.js +3 -0
- package/dist/ecs/world.d.ts +208 -0
- package/dist/ecs/world.js +440 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
- package/src/ecs/README.md +352 -0
- package/src/ecs/benchmark.test.ts +640 -0
- package/src/ecs/component-store.ts +211 -0
- package/src/ecs/component.ts +87 -0
- package/src/ecs/example.ts +152 -0
- package/src/ecs/index.ts +3 -0
- package/src/ecs/world.test.ts +309 -0
- package/src/ecs/world.ts +541 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Component } from "./component";
|
|
2
|
+
/**
|
|
3
|
+
* Stores component data for entities using typed arrays.
|
|
4
|
+
* Provides efficient packed storage with O(1) access by entity ID.
|
|
5
|
+
*
|
|
6
|
+
* Key optimizations:
|
|
7
|
+
* - ArrayBuffer storage (exact byte size, no waste)
|
|
8
|
+
* - Single reusable DataView (no allocations)
|
|
9
|
+
* - Single reusable object for get() (zero allocations)
|
|
10
|
+
* - Pre-computed field offsets (no runtime calculations)
|
|
11
|
+
*/
|
|
12
|
+
export declare class ComponentStore<T extends object> {
|
|
13
|
+
private buffer;
|
|
14
|
+
private view;
|
|
15
|
+
private stride;
|
|
16
|
+
private component;
|
|
17
|
+
private maxEntities;
|
|
18
|
+
private reusableObject;
|
|
19
|
+
private fieldOffsets;
|
|
20
|
+
private fields;
|
|
21
|
+
private fieldKeys;
|
|
22
|
+
private fieldIndexMap;
|
|
23
|
+
constructor(component: Component<T>, maxEntities: number);
|
|
24
|
+
/**
|
|
25
|
+
* Get component data for an entity.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ IMPORTANT: Returns a REUSED object that is overwritten on the next get() call.
|
|
28
|
+
* Use immediately or copy the data. For safe access, use getMutable() or copyTo().
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // ✅ CORRECT: Use immediately
|
|
32
|
+
* const t = store.get(entity);
|
|
33
|
+
* console.log(t.x, t.y);
|
|
34
|
+
*
|
|
35
|
+
* // ❌ WRONG: Storing reference
|
|
36
|
+
* const t1 = store.get(entity1);
|
|
37
|
+
* const t2 = store.get(entity2); // t1 is now corrupted!
|
|
38
|
+
*
|
|
39
|
+
* // ✅ CORRECT: Copy if you need multiple
|
|
40
|
+
* const t1 = { ...store.get(entity1) };
|
|
41
|
+
* const t2 = { ...store.get(entity2) };
|
|
42
|
+
*/
|
|
43
|
+
get(entityId: number): Readonly<T>;
|
|
44
|
+
/**
|
|
45
|
+
* Get a mutable copy of component data.
|
|
46
|
+
* Use this when you need to modify and keep the data.
|
|
47
|
+
*
|
|
48
|
+
* Note: This allocates a new object. Use sparingly in hot paths.
|
|
49
|
+
*/
|
|
50
|
+
getMutable(entityId: number): T;
|
|
51
|
+
/**
|
|
52
|
+
* Copy component data into a provided object.
|
|
53
|
+
* Use this when you need to keep multiple components at once.
|
|
54
|
+
*/
|
|
55
|
+
copyTo(entityId: number, target: T): void;
|
|
56
|
+
/**
|
|
57
|
+
* Set component data for an entity.
|
|
58
|
+
* Writes the data directly into the typed array.
|
|
59
|
+
*/
|
|
60
|
+
set(entityId: number, data: T): void;
|
|
61
|
+
/**
|
|
62
|
+
* Update specific fields of a component without reading the whole component first.
|
|
63
|
+
* Optimized to only iterate over the fields being updated.
|
|
64
|
+
*/
|
|
65
|
+
update(entityId: number, partial: Partial<T>): void;
|
|
66
|
+
/**
|
|
67
|
+
* Clear component data for an entity (set to default values)
|
|
68
|
+
*/
|
|
69
|
+
clear(entityId: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Get direct access to the underlying buffer.
|
|
72
|
+
* Advanced use only - for SIMD operations, GPU uploads, zero-copy networking, etc.
|
|
73
|
+
*/
|
|
74
|
+
getRawBuffer(): ArrayBuffer;
|
|
75
|
+
/**
|
|
76
|
+
* Get the stride in bytes.
|
|
77
|
+
*/
|
|
78
|
+
getStride(): number;
|
|
79
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stores component data for entities using typed arrays.
|
|
3
|
+
* Provides efficient packed storage with O(1) access by entity ID.
|
|
4
|
+
*
|
|
5
|
+
* Key optimizations:
|
|
6
|
+
* - ArrayBuffer storage (exact byte size, no waste)
|
|
7
|
+
* - Single reusable DataView (no allocations)
|
|
8
|
+
* - Single reusable object for get() (zero allocations)
|
|
9
|
+
* - Pre-computed field offsets (no runtime calculations)
|
|
10
|
+
*/
|
|
11
|
+
export class ComponentStore {
|
|
12
|
+
constructor(component, maxEntities) {
|
|
13
|
+
this.component = component;
|
|
14
|
+
this.maxEntities = maxEntities;
|
|
15
|
+
// Use exact byte size (no waste like Float32Array)
|
|
16
|
+
this.stride = component.size;
|
|
17
|
+
// Allocate exact memory needed
|
|
18
|
+
this.buffer = new ArrayBuffer(maxEntities * this.stride);
|
|
19
|
+
this.view = new DataView(this.buffer);
|
|
20
|
+
// Pre-compute field metadata once
|
|
21
|
+
this.fieldKeys = component.fieldNames;
|
|
22
|
+
this.fieldOffsets = [];
|
|
23
|
+
this.fields = [];
|
|
24
|
+
this.fieldIndexMap = new Map();
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (let i = 0; i < this.fieldKeys.length; i++) {
|
|
27
|
+
const key = this.fieldKeys[i];
|
|
28
|
+
const field = component.schema[key];
|
|
29
|
+
this.fieldOffsets.push(offset);
|
|
30
|
+
this.fields.push(field);
|
|
31
|
+
this.fieldIndexMap.set(key, i);
|
|
32
|
+
offset += field.size;
|
|
33
|
+
}
|
|
34
|
+
// Create single reusable object
|
|
35
|
+
this.reusableObject = {};
|
|
36
|
+
for (let i = 0; i < this.fieldKeys.length; i++) {
|
|
37
|
+
this.reusableObject[this.fieldKeys[i]] = this.fields[i].toNil();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get component data for an entity.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ IMPORTANT: Returns a REUSED object that is overwritten on the next get() call.
|
|
44
|
+
* Use immediately or copy the data. For safe access, use getMutable() or copyTo().
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // ✅ CORRECT: Use immediately
|
|
48
|
+
* const t = store.get(entity);
|
|
49
|
+
* console.log(t.x, t.y);
|
|
50
|
+
*
|
|
51
|
+
* // ❌ WRONG: Storing reference
|
|
52
|
+
* const t1 = store.get(entity1);
|
|
53
|
+
* const t2 = store.get(entity2); // t1 is now corrupted!
|
|
54
|
+
*
|
|
55
|
+
* // ✅ CORRECT: Copy if you need multiple
|
|
56
|
+
* const t1 = { ...store.get(entity1) };
|
|
57
|
+
* const t2 = { ...store.get(entity2) };
|
|
58
|
+
*/
|
|
59
|
+
get(entityId) {
|
|
60
|
+
const baseOffset = entityId * this.stride;
|
|
61
|
+
const length = this.fields.length;
|
|
62
|
+
// Unrolled loop for common cases
|
|
63
|
+
if (length === 2) {
|
|
64
|
+
this.reusableObject[this.fieldKeys[0]] = this.fields[0].read(this.view, baseOffset + this.fieldOffsets[0]);
|
|
65
|
+
this.reusableObject[this.fieldKeys[1]] = this.fields[1].read(this.view, baseOffset + this.fieldOffsets[1]);
|
|
66
|
+
}
|
|
67
|
+
else if (length === 3) {
|
|
68
|
+
this.reusableObject[this.fieldKeys[0]] = this.fields[0].read(this.view, baseOffset + this.fieldOffsets[0]);
|
|
69
|
+
this.reusableObject[this.fieldKeys[1]] = this.fields[1].read(this.view, baseOffset + this.fieldOffsets[1]);
|
|
70
|
+
this.reusableObject[this.fieldKeys[2]] = this.fields[2].read(this.view, baseOffset + this.fieldOffsets[2]);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// Generic loop for other sizes
|
|
74
|
+
for (let i = 0; i < length; i++) {
|
|
75
|
+
this.reusableObject[this.fieldKeys[i]] = this.fields[i].read(this.view, baseOffset + this.fieldOffsets[i]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return this.reusableObject;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get a mutable copy of component data.
|
|
82
|
+
* Use this when you need to modify and keep the data.
|
|
83
|
+
*
|
|
84
|
+
* Note: This allocates a new object. Use sparingly in hot paths.
|
|
85
|
+
*/
|
|
86
|
+
getMutable(entityId) {
|
|
87
|
+
const copy = {};
|
|
88
|
+
this.copyTo(entityId, copy);
|
|
89
|
+
return copy;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Copy component data into a provided object.
|
|
93
|
+
* Use this when you need to keep multiple components at once.
|
|
94
|
+
*/
|
|
95
|
+
copyTo(entityId, target) {
|
|
96
|
+
const baseOffset = entityId * this.stride;
|
|
97
|
+
for (let i = 0; i < this.fields.length; i++) {
|
|
98
|
+
target[this.fieldKeys[i]] = this.fields[i].read(this.view, baseOffset + this.fieldOffsets[i]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Set component data for an entity.
|
|
103
|
+
* Writes the data directly into the typed array.
|
|
104
|
+
*/
|
|
105
|
+
set(entityId, data) {
|
|
106
|
+
const baseOffset = entityId * this.stride;
|
|
107
|
+
const length = this.fields.length;
|
|
108
|
+
// Unrolled loop for common cases
|
|
109
|
+
if (length === 2) {
|
|
110
|
+
this.fields[0].write(this.view, baseOffset + this.fieldOffsets[0], data[this.fieldKeys[0]]);
|
|
111
|
+
this.fields[1].write(this.view, baseOffset + this.fieldOffsets[1], data[this.fieldKeys[1]]);
|
|
112
|
+
}
|
|
113
|
+
else if (length === 3) {
|
|
114
|
+
this.fields[0].write(this.view, baseOffset + this.fieldOffsets[0], data[this.fieldKeys[0]]);
|
|
115
|
+
this.fields[1].write(this.view, baseOffset + this.fieldOffsets[1], data[this.fieldKeys[1]]);
|
|
116
|
+
this.fields[2].write(this.view, baseOffset + this.fieldOffsets[2], data[this.fieldKeys[2]]);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// Generic loop for other sizes
|
|
120
|
+
for (let i = 0; i < length; i++) {
|
|
121
|
+
this.fields[i].write(this.view, baseOffset + this.fieldOffsets[i], data[this.fieldKeys[i]]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Update specific fields of a component without reading the whole component first.
|
|
127
|
+
* Optimized to only iterate over the fields being updated.
|
|
128
|
+
*/
|
|
129
|
+
update(entityId, partial) {
|
|
130
|
+
const baseOffset = entityId * this.stride;
|
|
131
|
+
// Use Object.keys for faster iteration than for...in
|
|
132
|
+
const keys = Object.keys(partial);
|
|
133
|
+
for (let j = 0; j < keys.length; j++) {
|
|
134
|
+
const key = keys[j];
|
|
135
|
+
const i = this.fieldIndexMap.get(key);
|
|
136
|
+
this.fields[i].write(this.view, baseOffset + this.fieldOffsets[i], partial[key]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Clear component data for an entity (set to default values)
|
|
141
|
+
*/
|
|
142
|
+
clear(entityId) {
|
|
143
|
+
const baseOffset = entityId * this.stride;
|
|
144
|
+
for (let i = 0; i < this.fields.length; i++) {
|
|
145
|
+
this.fields[i].write(this.view, baseOffset + this.fieldOffsets[i], this.fields[i].toNil());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get direct access to the underlying buffer.
|
|
150
|
+
* Advanced use only - for SIMD operations, GPU uploads, zero-copy networking, etc.
|
|
151
|
+
*/
|
|
152
|
+
getRawBuffer() {
|
|
153
|
+
return this.buffer;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get the stride in bytes.
|
|
157
|
+
*/
|
|
158
|
+
getStride() {
|
|
159
|
+
return this.stride;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Schema } from "../core/binary-codec";
|
|
2
|
+
import { ArrayField } from "../core/pooled-codec";
|
|
3
|
+
/**
|
|
4
|
+
* Metadata for a component definition
|
|
5
|
+
*/
|
|
6
|
+
export interface ComponentMeta<T extends object> {
|
|
7
|
+
/** Schema defining the component's binary layout */
|
|
8
|
+
schema: Schema<T>;
|
|
9
|
+
/** Unique name for this component type */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Size of the component in bytes */
|
|
12
|
+
size: number;
|
|
13
|
+
/** Number of fields in the schema */
|
|
14
|
+
fieldCount: number;
|
|
15
|
+
/** Field names in order */
|
|
16
|
+
fieldNames: (keyof T)[];
|
|
17
|
+
/** Codec for array serialization */
|
|
18
|
+
arrayCodec: ArrayField<T>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Component type returned by defineComponent
|
|
22
|
+
*/
|
|
23
|
+
export type Component<T extends object = any> = ComponentMeta<T> & {
|
|
24
|
+
/** Type marker for TypeScript inference */
|
|
25
|
+
__type?: T;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Infer the data type from a Component
|
|
29
|
+
*/
|
|
30
|
+
export type InferComponentType<C> = C extends Component<infer T> ? T : never;
|
|
31
|
+
/**
|
|
32
|
+
* Define a component type with its binary schema.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const Transform = defineComponent('Transform', {
|
|
37
|
+
* x: BinaryCodec.f32,
|
|
38
|
+
* y: BinaryCodec.f32,
|
|
39
|
+
* rotation: BinaryCodec.f32,
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* const Health = defineComponent('Health', {
|
|
43
|
+
* current: BinaryCodec.u16,
|
|
44
|
+
* max: BinaryCodec.u16,
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function defineComponent<T extends object>(name: string, schema: Schema<T>): Component<T>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PooledCodec } from "../core/pooled-codec";
|
|
2
|
+
/**
|
|
3
|
+
* Calculate the byte size of a schema
|
|
4
|
+
*/
|
|
5
|
+
function calculateSchemaSize(schema) {
|
|
6
|
+
let size = 0;
|
|
7
|
+
for (const key of Object.keys(schema)) {
|
|
8
|
+
size += schema[key].size;
|
|
9
|
+
}
|
|
10
|
+
return size;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Define a component type with its binary schema.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* const Transform = defineComponent('Transform', {
|
|
18
|
+
* x: BinaryCodec.f32,
|
|
19
|
+
* y: BinaryCodec.f32,
|
|
20
|
+
* rotation: BinaryCodec.f32,
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const Health = defineComponent('Health', {
|
|
24
|
+
* current: BinaryCodec.u16,
|
|
25
|
+
* max: BinaryCodec.u16,
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function defineComponent(name, schema) {
|
|
30
|
+
const size = calculateSchemaSize(schema);
|
|
31
|
+
const fieldNames = Object.keys(schema);
|
|
32
|
+
const fieldCount = fieldNames.length;
|
|
33
|
+
// Create PooledCodec for array serialization
|
|
34
|
+
const arrayCodec = PooledCodec.array(schema);
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
schema,
|
|
38
|
+
size,
|
|
39
|
+
fieldCount,
|
|
40
|
+
fieldNames,
|
|
41
|
+
arrayCodec,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECS Usage Example
|
|
3
|
+
*
|
|
4
|
+
* This example demonstrates how to use the ECS for a simple game.
|
|
5
|
+
*/
|
|
6
|
+
import { defineComponent, World } from "./index";
|
|
7
|
+
import { BinaryCodec } from "../core/binary-codec";
|
|
8
|
+
// 1. Define components using BinaryCodec schemas
|
|
9
|
+
const Transform = defineComponent("Transform", {
|
|
10
|
+
x: BinaryCodec.f32,
|
|
11
|
+
y: BinaryCodec.f32,
|
|
12
|
+
rotation: BinaryCodec.f32,
|
|
13
|
+
});
|
|
14
|
+
const Velocity = defineComponent("Velocity", {
|
|
15
|
+
vx: BinaryCodec.f32,
|
|
16
|
+
vy: BinaryCodec.f32,
|
|
17
|
+
});
|
|
18
|
+
const Health = defineComponent("Health", {
|
|
19
|
+
current: BinaryCodec.u16,
|
|
20
|
+
max: BinaryCodec.u16,
|
|
21
|
+
});
|
|
22
|
+
const Damage = defineComponent("Damage", {
|
|
23
|
+
amount: BinaryCodec.u16,
|
|
24
|
+
});
|
|
25
|
+
// 2. Create systems
|
|
26
|
+
class MovementSystem {
|
|
27
|
+
update(world, deltaTime) {
|
|
28
|
+
for (const entity of world.query(Transform, Velocity)) {
|
|
29
|
+
const transform = world.get(entity, Transform); // Readonly!
|
|
30
|
+
const velocity = world.get(entity, Velocity); // Readonly!
|
|
31
|
+
// Update position using update() (efficient partial update)
|
|
32
|
+
world.update(entity, Transform, {
|
|
33
|
+
x: transform.x + velocity.vx * deltaTime,
|
|
34
|
+
y: transform.y + velocity.vy * deltaTime,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class HealthSystem {
|
|
40
|
+
update(world) {
|
|
41
|
+
for (const entity of world.query(Health)) {
|
|
42
|
+
const health = world.get(entity, Health);
|
|
43
|
+
// Despawn dead entities
|
|
44
|
+
if (health.current <= 0) {
|
|
45
|
+
console.log(`Entity ${entity} died`);
|
|
46
|
+
world.despawn(entity);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class CombatSystem {
|
|
52
|
+
update(world) {
|
|
53
|
+
// Simple collision-based damage (in real game, use spatial partitioning)
|
|
54
|
+
const combatants = Array.from(world.query(Transform, Health, Damage));
|
|
55
|
+
for (let i = 0; i < combatants.length; i++) {
|
|
56
|
+
for (let j = i + 1; j < combatants.length; j++) {
|
|
57
|
+
const e1 = combatants[i];
|
|
58
|
+
const e2 = combatants[j];
|
|
59
|
+
const t1 = world.get(e1, Transform);
|
|
60
|
+
const t2 = world.get(e2, Transform);
|
|
61
|
+
// Check collision (simple distance check)
|
|
62
|
+
const dx = t1.x - t2.x;
|
|
63
|
+
const dy = t1.y - t2.y;
|
|
64
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
65
|
+
if (dist < 10) {
|
|
66
|
+
// Apply damage
|
|
67
|
+
const d1 = world.get(e1, Damage);
|
|
68
|
+
const d2 = world.get(e2, Damage);
|
|
69
|
+
world.update(e1, Health, { current: world.get(e1, Health).current - d2.amount });
|
|
70
|
+
world.update(e2, Health, { current: world.get(e2, Health).current - d1.amount });
|
|
71
|
+
console.log(`Entities ${e1} and ${e2} collided! Applying damage.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 3. Create world and spawn entities
|
|
78
|
+
function runExample() {
|
|
79
|
+
const world = new World({
|
|
80
|
+
maxEntities: 1000,
|
|
81
|
+
components: [Transform, Velocity, Health, Damage],
|
|
82
|
+
});
|
|
83
|
+
// Spawn player
|
|
84
|
+
const player = world.spawn();
|
|
85
|
+
world.add(player, Transform, { x: 0, y: 0, rotation: 0 });
|
|
86
|
+
world.add(player, Velocity, { vx: 100, vy: 0 });
|
|
87
|
+
world.add(player, Health, { current: 100, max: 100 });
|
|
88
|
+
world.add(player, Damage, { amount: 10 });
|
|
89
|
+
// Spawn enemies
|
|
90
|
+
for (let i = 0; i < 5; i++) {
|
|
91
|
+
const enemy = world.spawn();
|
|
92
|
+
world.add(enemy, Transform, { x: 200 + i * 50, y: 0, rotation: 0 });
|
|
93
|
+
world.add(enemy, Velocity, { vx: -50, vy: 0 });
|
|
94
|
+
world.add(enemy, Health, { current: 50, max: 50 });
|
|
95
|
+
world.add(enemy, Damage, { amount: 5 });
|
|
96
|
+
}
|
|
97
|
+
console.log(`Spawned ${world.getEntityCount()} entities`);
|
|
98
|
+
// 4. Create systems
|
|
99
|
+
const movementSystem = new MovementSystem();
|
|
100
|
+
const combatSystem = new CombatSystem();
|
|
101
|
+
const healthSystem = new HealthSystem();
|
|
102
|
+
// 5. Game loop
|
|
103
|
+
const deltaTime = 0.016; // 60 FPS
|
|
104
|
+
let tick = 0;
|
|
105
|
+
const interval = setInterval(() => {
|
|
106
|
+
tick++;
|
|
107
|
+
// Update systems
|
|
108
|
+
movementSystem.update(world, deltaTime);
|
|
109
|
+
combatSystem.update(world);
|
|
110
|
+
healthSystem.update(world);
|
|
111
|
+
// Log stats every 10 ticks
|
|
112
|
+
if (tick % 10 === 0) {
|
|
113
|
+
console.log(`Tick ${tick}: ${world.getEntityCount()} entities alive`);
|
|
114
|
+
}
|
|
115
|
+
// Stop after 100 ticks or when all entities are dead
|
|
116
|
+
if (tick >= 100 || world.getEntityCount() === 0) {
|
|
117
|
+
clearInterval(interval);
|
|
118
|
+
console.log("Simulation ended");
|
|
119
|
+
}
|
|
120
|
+
}, deltaTime * 1000);
|
|
121
|
+
}
|
|
122
|
+
// Run the example
|
|
123
|
+
if (import.meta.main) {
|
|
124
|
+
runExample();
|
|
125
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Component } from "./component";
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for creating a World
|
|
4
|
+
*/
|
|
5
|
+
export interface WorldConfig {
|
|
6
|
+
/** Maximum number of entities that can exist simultaneously */
|
|
7
|
+
maxEntities?: number;
|
|
8
|
+
/** Component types to register */
|
|
9
|
+
components: Component<any>[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Entity ID type (just a number, indexing into component arrays)
|
|
13
|
+
*/
|
|
14
|
+
export type Entity = number;
|
|
15
|
+
/**
|
|
16
|
+
* World manages entities and their components.
|
|
17
|
+
* Provides efficient ECS storage using typed arrays.
|
|
18
|
+
*
|
|
19
|
+
* Performance optimizations:
|
|
20
|
+
* - Array iteration instead of Set for 2-5x faster queries
|
|
21
|
+
* - Query bitmask caching for repeated queries
|
|
22
|
+
* - Array-indexed component stores for O(1) access
|
|
23
|
+
* - Pre-allocated ring buffer for entity ID reuse
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const world = new World({
|
|
28
|
+
* maxEntities: 10000,
|
|
29
|
+
* components: [Transform, Health, Velocity]
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* const entity = world.spawn();
|
|
33
|
+
* world.add(entity, Transform, { x: 100, y: 200, rotation: 0 });
|
|
34
|
+
* world.add(entity, Health, { current: 100, max: 100 });
|
|
35
|
+
*
|
|
36
|
+
* // Query entities
|
|
37
|
+
* for (const entity of world.query(Transform, Velocity)) {
|
|
38
|
+
* const transform = world.get(entity, Transform);
|
|
39
|
+
* const velocity = world.get(entity, Velocity);
|
|
40
|
+
* // transform is readonly, use update() to modify
|
|
41
|
+
* world.update(entity, Transform, {
|
|
42
|
+
* x: transform.x + velocity.vx,
|
|
43
|
+
* y: transform.y + velocity.vy
|
|
44
|
+
* });
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare class World {
|
|
49
|
+
private maxEntities;
|
|
50
|
+
private nextEntityId;
|
|
51
|
+
private freeEntityIds;
|
|
52
|
+
private freeEntityHead;
|
|
53
|
+
private freeEntityTail;
|
|
54
|
+
private freeEntityCount;
|
|
55
|
+
private freeEntityMask;
|
|
56
|
+
private aliveEntitiesArray;
|
|
57
|
+
private aliveEntitiesIndices;
|
|
58
|
+
private aliveEntityFlags;
|
|
59
|
+
private componentStoresArray;
|
|
60
|
+
private componentMasks;
|
|
61
|
+
private componentMap;
|
|
62
|
+
private components;
|
|
63
|
+
private queryResultBuffers;
|
|
64
|
+
private worldId;
|
|
65
|
+
constructor(config: WorldConfig);
|
|
66
|
+
/**
|
|
67
|
+
* Get component index (with caching via Map)
|
|
68
|
+
*/
|
|
69
|
+
private getComponentIndex;
|
|
70
|
+
/**
|
|
71
|
+
* Get or compute query bitmask (optimized - computes mask without caching)
|
|
72
|
+
*/
|
|
73
|
+
private getQueryMask;
|
|
74
|
+
/**
|
|
75
|
+
* Spawn a new entity.
|
|
76
|
+
* Returns the entity ID.
|
|
77
|
+
*/
|
|
78
|
+
spawn(): Entity;
|
|
79
|
+
/**
|
|
80
|
+
* Despawn an entity, removing all its components.
|
|
81
|
+
* The entity ID will be reused.
|
|
82
|
+
*/
|
|
83
|
+
despawn(entity: Entity): void;
|
|
84
|
+
/**
|
|
85
|
+
* Check if an entity is alive
|
|
86
|
+
*/
|
|
87
|
+
isAlive(entity: Entity): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Add a component to an entity with initial data.
|
|
90
|
+
*/
|
|
91
|
+
add<T extends object>(entity: Entity, component: Component<T>, data: T): void;
|
|
92
|
+
/**
|
|
93
|
+
* Remove a component from an entity.
|
|
94
|
+
*/
|
|
95
|
+
remove<T extends object>(entity: Entity, component: Component<T>): void;
|
|
96
|
+
/**
|
|
97
|
+
* Check if an entity has a component.
|
|
98
|
+
*/
|
|
99
|
+
has<T extends object>(entity: Entity, component: Component<T>): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Get a component's data for an entity.
|
|
102
|
+
* Returns a READONLY reusable object (zero allocations).
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ IMPORTANT: The returned object is reused and will be overwritten on the next get().
|
|
105
|
+
* To modify, use set() or update() instead.
|
|
106
|
+
* To keep multiple components, use getMutable() or spread operator.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // ✅ CORRECT: Use immediately
|
|
110
|
+
* const t = world.get(entity, Transform);
|
|
111
|
+
* console.log(t.x, t.y);
|
|
112
|
+
*
|
|
113
|
+
* // ❌ WRONG: Storing reference
|
|
114
|
+
* const t1 = world.get(entity1, Transform);
|
|
115
|
+
* const t2 = world.get(entity2, Transform); // t1 is now corrupted!
|
|
116
|
+
*
|
|
117
|
+
* // ✅ CORRECT: Copy if you need to keep
|
|
118
|
+
* const t1 = { ...world.get(entity1, Transform) };
|
|
119
|
+
* const t2 = { ...world.get(entity2, Transform) };
|
|
120
|
+
*/
|
|
121
|
+
get<T extends object>(entity: Entity, component: Component<T>): Readonly<T>;
|
|
122
|
+
/**
|
|
123
|
+
* Get a mutable copy of component data.
|
|
124
|
+
* Use this when you need to modify and keep the data.
|
|
125
|
+
*
|
|
126
|
+
* Note: This allocates a new object. Use sparingly in hot paths.
|
|
127
|
+
*/
|
|
128
|
+
getMutable<T extends object>(entity: Entity, component: Component<T>): T;
|
|
129
|
+
/**
|
|
130
|
+
* Set a component's data for an entity.
|
|
131
|
+
* Overwrites all fields.
|
|
132
|
+
*/
|
|
133
|
+
set<T extends object>(entity: Entity, component: Component<T>, data: T): void;
|
|
134
|
+
/**
|
|
135
|
+
* Update specific fields of a component.
|
|
136
|
+
* More efficient than get + modify + set.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* // ✅ GOOD: Partial update
|
|
140
|
+
* world.update(entity, Transform, { x: 150 });
|
|
141
|
+
*
|
|
142
|
+
* // ❌ BAD: Full get/set for single field
|
|
143
|
+
* const t = world.getMutable(entity, Transform);
|
|
144
|
+
* t.x = 150;
|
|
145
|
+
* world.set(entity, Transform, t);
|
|
146
|
+
*/
|
|
147
|
+
update<T extends object>(entity: Entity, component: Component<T>, partial: Partial<T>): void;
|
|
148
|
+
/**
|
|
149
|
+
* Query entities that have all specified components.
|
|
150
|
+
* Returns a readonly array for zero-allocation iteration.
|
|
151
|
+
*
|
|
152
|
+
* Uses reusable buffers and direct bitmask checks for maximum performance.
|
|
153
|
+
* The returned array is reused on subsequent queries with the same mask.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* for (const entity of world.query(Transform, Velocity)) {
|
|
158
|
+
* const t = world.get(entity, Transform);
|
|
159
|
+
* const v = world.get(entity, Velocity);
|
|
160
|
+
* world.update(entity, Transform, {
|
|
161
|
+
* x: t.x + v.vx * dt,
|
|
162
|
+
* y: t.y + v.vy * dt
|
|
163
|
+
* });
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
query(...components: Component<any>[]): readonly Entity[];
|
|
168
|
+
/**
|
|
169
|
+
* Get all alive entity IDs.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ WARNING: The returned array is a direct reference and should not be modified.
|
|
172
|
+
* For a safe copy, use [...world.getEntities()].
|
|
173
|
+
*/
|
|
174
|
+
getEntities(): readonly Entity[];
|
|
175
|
+
/**
|
|
176
|
+
* Get the number of alive entities.
|
|
177
|
+
*/
|
|
178
|
+
getEntityCount(): number;
|
|
179
|
+
/**
|
|
180
|
+
* Get the maximum number of entities.
|
|
181
|
+
*/
|
|
182
|
+
getMaxEntities(): number;
|
|
183
|
+
/**
|
|
184
|
+
* Get all registered components.
|
|
185
|
+
*/
|
|
186
|
+
getComponents(): readonly Component<any>[];
|
|
187
|
+
/**
|
|
188
|
+
* Get component names for an entity (for debugging)
|
|
189
|
+
*/
|
|
190
|
+
private getEntityComponentNames;
|
|
191
|
+
/**
|
|
192
|
+
* Serialize entities with specific components to binary.
|
|
193
|
+
* Uses PooledCodec internally for efficient encoding.
|
|
194
|
+
*
|
|
195
|
+
* @param components Components to include in the snapshot
|
|
196
|
+
* @param entities Optional list of entities to serialize (defaults to all)
|
|
197
|
+
* @returns Binary buffer with serialized data
|
|
198
|
+
*/
|
|
199
|
+
serialize(components: Component<any>[], entities?: Entity[]): Uint8Array;
|
|
200
|
+
/**
|
|
201
|
+
* Deserialize binary data into entities.
|
|
202
|
+
* Uses PooledCodec internally for efficient decoding.
|
|
203
|
+
*
|
|
204
|
+
* Note: This is a basic implementation. For production use,
|
|
205
|
+
* you'd want a more sophisticated format with component IDs, etc.
|
|
206
|
+
*/
|
|
207
|
+
deserialize(components: Component<any>[], buffer: Uint8Array): void;
|
|
208
|
+
}
|