@waica/engine 0.19.1 → 0.20.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.
- package/README.md +46 -0
- package/dist/component.d.ts +1 -1
- package/dist/components/particle-emitter.d.ts +167 -0
- package/dist/components/particle-emitter.js +219 -0
- package/dist/game.js +26 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/particle-batch.d.ts +54 -0
- package/dist/particle-batch.js +269 -0
- package/dist/particle-pool.d.ts +66 -0
- package/dist/particle-pool.js +204 -0
- package/dist/particle-sort-bounds.d.ts +3 -0
- package/dist/particle-sort-bounds.js +34 -0
- package/dist/render-sort.d.ts +6 -0
- package/dist/render-sort.js +5 -0
- package/dist/scene-drains.d.ts +18 -0
- package/dist/scene-drains.js +36 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -93,6 +93,52 @@ Invalid inputs fail closed without throwing: invalid `area` bodies and non-finit
|
|
|
93
93
|
|
|
94
94
|
Area and point intentionally use the collision system's polygonally approximated circle/ellipse outline. Ray queries intersect circle-shaped Solids as analytic ellipses and return their exact outward unit normal. Collision Layers and Masks never filter `area` or `point`; use a query filter when category-like eligibility is needed.
|
|
95
95
|
|
|
96
|
+
## CPU particle emitters
|
|
97
|
+
|
|
98
|
+
`ParticleEmitter` is the authorable 2D particle component. It simulates a fixed-capacity CPU batch in fixed-step time and renders the whole effect with one mesh, one geometry, and one material. Its complete authoring surface and defaults are:
|
|
99
|
+
|
|
100
|
+
| Props | Defaults |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| continuous | `rate: 0`, `emitting: true` |
|
|
103
|
+
| lifetime and spawn | `lifetime: 1`, `positionSpread: [0, 0]` |
|
|
104
|
+
| motion | `velocity: [0, 0]`, `velocitySpread: [0, 0]`, `gravity: [0, 0]` |
|
|
105
|
+
| coordinates and random stream | `space: 'world'`, `seed: 1` |
|
|
106
|
+
| storage | `capacity: 256`, `overflow: 'recycle-oldest'` |
|
|
107
|
+
| ownership | `destroyMode: 'clear'` |
|
|
108
|
+
| quad | `width: 1`, `height: 1`, `startScale: 1`, `endScale: 1` |
|
|
109
|
+
| tint | `startColor: 0xffffff`, `endColor: 0xffffff`, `startAlpha: 1`, `endAlpha: 0` |
|
|
110
|
+
| material | `texture: ''`, `pixelArt: false`, `blend: 'normal'` |
|
|
111
|
+
| draw band | `layer: 0` |
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { ParticleEmitter } from '@waica/engine'
|
|
115
|
+
|
|
116
|
+
const smoke = entity.add(ParticleEmitter, {
|
|
117
|
+
rate: 12,
|
|
118
|
+
lifetime: 0.8,
|
|
119
|
+
positionSpread: [0.4, 0.1],
|
|
120
|
+
velocity: [0, 2],
|
|
121
|
+
velocitySpread: [0.5, 0.5],
|
|
122
|
+
gravity: [0, -3],
|
|
123
|
+
startColor: 0xffffff,
|
|
124
|
+
endColor: 0x777777,
|
|
125
|
+
startAlpha: 0.8,
|
|
126
|
+
endAlpha: 0,
|
|
127
|
+
texture: 'art/smoke.png',
|
|
128
|
+
blend: 'additive',
|
|
129
|
+
capacity: 256,
|
|
130
|
+
})
|
|
131
|
+
smoke.emit(20) // deterministic burst; returns the accepted count
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A seeded Mulberry32 stream makes equivalent runs deterministic; `emit(count)` uses the same stream as continuous `rate` emission. `overflow: 'recycle-oldest'` (the default) replaces the oldest live particles at capacity, while `'drop-new'` rejects new ones without sampling. Resizing `capacity` clears the batch but does not reset the stream; changing `seed` or remounting does.
|
|
135
|
+
|
|
136
|
+
`space: 'world'` snapshots the entity's logical origin at spawn, while `'local'` keeps live particles attached to it. Logical simulation is projection-independent: orthographic and isometric scenes only change render projection. In `sort: 'y'` scenes every live particle contributes its own global depth entry alongside Sprites, AnimatedSprites, and draining batches; equal-Y ties preserve spawn order.
|
|
137
|
+
|
|
138
|
+
Textures use the Game's shared `AssetLoader`; `pixelArt` chooses nearest or linear filtering, and `blend` selects normal or additive blending. All three are reactive authoring props. A failed or empty texture falls back to the flat colored quad. Batching keeps one draw submission but does not promise order-independent or globally exact alpha composition between meshes. `destroyMode: 'clear'` removes the batch with its owner; `'drain'` transfers it to scene scope, stops emission, and simulates it until empty. Scene unload and `game.dispose()` always clear drains.
|
|
139
|
+
|
|
140
|
+
`active`, `capacity`, and `emitting` are the only fields exposed by Runtime Snapshot. Particle arrays remain private. This first CPU implementation is intended for bounded 2D effects, not collision, per-particle scripting, GPU simulation, trails, rotation, or sub-emitters.
|
|
141
|
+
|
|
96
142
|
## Component lifecycle
|
|
97
143
|
|
|
98
144
|
Waica keeps the lifecycle boundaries distinct:
|
package/dist/component.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ParamSpec {
|
|
|
8
8
|
max?: number;
|
|
9
9
|
step?: number;
|
|
10
10
|
/** Specialized editor control for values that are otherwise plain JSON. */
|
|
11
|
-
kind?: 'string-list';
|
|
11
|
+
kind?: 'string-list' | 'vector2' | 'color' | 'texture';
|
|
12
12
|
/** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
|
|
13
13
|
options?: string[];
|
|
14
14
|
/** Project value this string param names; rendered and validated as a typed reference. */
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Component } from '../component.js';
|
|
2
|
+
import type { YSortBatchParticipant, YSortEntry } from '../render-sort.js';
|
|
3
|
+
export type ParticleVector = [number, number];
|
|
4
|
+
export type ParticleSpace = 'world' | 'local';
|
|
5
|
+
export type ParticleOverflow = 'recycle-oldest' | 'drop-new';
|
|
6
|
+
export type ParticleDestroyMode = 'clear' | 'drain';
|
|
7
|
+
export type ParticleBlend = 'normal' | 'additive';
|
|
8
|
+
/** A fixed-capacity source of short-lived, batched 2D particles. */
|
|
9
|
+
export declare class ParticleEmitter extends Component implements YSortBatchParticipant {
|
|
10
|
+
static componentName: string;
|
|
11
|
+
static displayName: string;
|
|
12
|
+
static params: {
|
|
13
|
+
rate: {
|
|
14
|
+
label: string;
|
|
15
|
+
min: number;
|
|
16
|
+
};
|
|
17
|
+
emitting: {
|
|
18
|
+
label: string;
|
|
19
|
+
};
|
|
20
|
+
lifetime: {
|
|
21
|
+
label: string;
|
|
22
|
+
min: number;
|
|
23
|
+
};
|
|
24
|
+
positionSpread: {
|
|
25
|
+
label: string;
|
|
26
|
+
kind: "vector2";
|
|
27
|
+
};
|
|
28
|
+
velocity: {
|
|
29
|
+
label: string;
|
|
30
|
+
kind: "vector2";
|
|
31
|
+
};
|
|
32
|
+
velocitySpread: {
|
|
33
|
+
label: string;
|
|
34
|
+
kind: "vector2";
|
|
35
|
+
};
|
|
36
|
+
gravity: {
|
|
37
|
+
label: string;
|
|
38
|
+
kind: "vector2";
|
|
39
|
+
};
|
|
40
|
+
space: {
|
|
41
|
+
label: string;
|
|
42
|
+
options: string[];
|
|
43
|
+
};
|
|
44
|
+
seed: {
|
|
45
|
+
label: string;
|
|
46
|
+
step: number;
|
|
47
|
+
};
|
|
48
|
+
capacity: {
|
|
49
|
+
label: string;
|
|
50
|
+
min: number;
|
|
51
|
+
step: number;
|
|
52
|
+
};
|
|
53
|
+
overflow: {
|
|
54
|
+
label: string;
|
|
55
|
+
options: string[];
|
|
56
|
+
};
|
|
57
|
+
destroyMode: {
|
|
58
|
+
label: string;
|
|
59
|
+
options: string[];
|
|
60
|
+
};
|
|
61
|
+
width: {
|
|
62
|
+
label: string;
|
|
63
|
+
min: number;
|
|
64
|
+
};
|
|
65
|
+
height: {
|
|
66
|
+
label: string;
|
|
67
|
+
min: number;
|
|
68
|
+
};
|
|
69
|
+
startScale: {
|
|
70
|
+
label: string;
|
|
71
|
+
min: number;
|
|
72
|
+
};
|
|
73
|
+
endScale: {
|
|
74
|
+
label: string;
|
|
75
|
+
min: number;
|
|
76
|
+
};
|
|
77
|
+
startColor: {
|
|
78
|
+
label: string;
|
|
79
|
+
kind: "color";
|
|
80
|
+
};
|
|
81
|
+
endColor: {
|
|
82
|
+
label: string;
|
|
83
|
+
kind: "color";
|
|
84
|
+
};
|
|
85
|
+
startAlpha: {
|
|
86
|
+
label: string;
|
|
87
|
+
min: number;
|
|
88
|
+
max: number;
|
|
89
|
+
};
|
|
90
|
+
endAlpha: {
|
|
91
|
+
label: string;
|
|
92
|
+
min: number;
|
|
93
|
+
max: number;
|
|
94
|
+
};
|
|
95
|
+
texture: {
|
|
96
|
+
label: string;
|
|
97
|
+
kind: "texture";
|
|
98
|
+
};
|
|
99
|
+
pixelArt: {
|
|
100
|
+
label: string;
|
|
101
|
+
};
|
|
102
|
+
blend: {
|
|
103
|
+
label: string;
|
|
104
|
+
options: string[];
|
|
105
|
+
};
|
|
106
|
+
layer: {
|
|
107
|
+
label: string;
|
|
108
|
+
step: number;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
rate: number;
|
|
112
|
+
emitting: boolean;
|
|
113
|
+
lifetime: number;
|
|
114
|
+
positionSpread: ParticleVector;
|
|
115
|
+
velocity: ParticleVector;
|
|
116
|
+
velocitySpread: ParticleVector;
|
|
117
|
+
gravity: ParticleVector;
|
|
118
|
+
space: ParticleSpace;
|
|
119
|
+
private _seed;
|
|
120
|
+
get seed(): number;
|
|
121
|
+
set seed(value: number);
|
|
122
|
+
private _capacity;
|
|
123
|
+
get capacity(): number;
|
|
124
|
+
set capacity(value: number);
|
|
125
|
+
overflow: ParticleOverflow;
|
|
126
|
+
destroyMode: ParticleDestroyMode;
|
|
127
|
+
width: number;
|
|
128
|
+
height: number;
|
|
129
|
+
startScale: number;
|
|
130
|
+
endScale: number;
|
|
131
|
+
startColor: number;
|
|
132
|
+
endColor: number;
|
|
133
|
+
startAlpha: number;
|
|
134
|
+
endAlpha: number;
|
|
135
|
+
private _texture;
|
|
136
|
+
get texture(): string;
|
|
137
|
+
set texture(value: string);
|
|
138
|
+
private _pixelArt;
|
|
139
|
+
get pixelArt(): boolean;
|
|
140
|
+
set pixelArt(value: boolean);
|
|
141
|
+
private _blend;
|
|
142
|
+
get blend(): ParticleBlend;
|
|
143
|
+
set blend(value: ParticleBlend);
|
|
144
|
+
private _layer;
|
|
145
|
+
get layer(): number;
|
|
146
|
+
set layer(value: number);
|
|
147
|
+
private _batch?;
|
|
148
|
+
private _unsubscribeRenderSync?;
|
|
149
|
+
private _emissionRemainder;
|
|
150
|
+
private _destroyed;
|
|
151
|
+
get active(): number;
|
|
152
|
+
onReady(): void;
|
|
153
|
+
onUpdate(dt: number): void;
|
|
154
|
+
/** Emits a burst immediately and returns how many particles from it remain active. */
|
|
155
|
+
emit(value: number): number;
|
|
156
|
+
ySortEntries(): readonly YSortEntry[];
|
|
157
|
+
setSortZ(z: number): void;
|
|
158
|
+
setSortZs(values: readonly number[]): void;
|
|
159
|
+
inspectState(): {
|
|
160
|
+
active: number;
|
|
161
|
+
capacity: number;
|
|
162
|
+
emitting: boolean;
|
|
163
|
+
};
|
|
164
|
+
onDestroy(): void;
|
|
165
|
+
private renderContext;
|
|
166
|
+
private spawnConfig;
|
|
167
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Component } from '../component.js';
|
|
2
|
+
import { ParticleBatch, } from '../particle-batch.js';
|
|
3
|
+
import { sceneDrainsOf } from '../scene-drains.js';
|
|
4
|
+
/** A fixed-capacity source of short-lived, batched 2D particles. */
|
|
5
|
+
export class ParticleEmitter extends Component {
|
|
6
|
+
static componentName = 'ParticleEmitter';
|
|
7
|
+
static displayName = 'Particle Emitter';
|
|
8
|
+
static params = {
|
|
9
|
+
rate: { label: 'Rate', min: 0 },
|
|
10
|
+
emitting: { label: 'Emitting' },
|
|
11
|
+
lifetime: { label: 'Lifetime', min: 0 },
|
|
12
|
+
positionSpread: { label: 'Position spread', kind: 'vector2' },
|
|
13
|
+
velocity: { label: 'Velocity', kind: 'vector2' },
|
|
14
|
+
velocitySpread: { label: 'Velocity spread', kind: 'vector2' },
|
|
15
|
+
gravity: { label: 'Gravity', kind: 'vector2' },
|
|
16
|
+
space: { label: 'Space', options: ['world', 'local'] },
|
|
17
|
+
seed: { label: 'Seed', step: 1 },
|
|
18
|
+
capacity: { label: 'Capacity', min: 1, step: 1 },
|
|
19
|
+
overflow: { label: 'Overflow', options: ['recycle-oldest', 'drop-new'] },
|
|
20
|
+
destroyMode: { label: 'On destroy', options: ['clear', 'drain'] },
|
|
21
|
+
width: { label: 'Width', min: 0 },
|
|
22
|
+
height: { label: 'Height', min: 0 },
|
|
23
|
+
startScale: { label: 'Start scale', min: 0 },
|
|
24
|
+
endScale: { label: 'End scale', min: 0 },
|
|
25
|
+
startColor: { label: 'Start color', kind: 'color' },
|
|
26
|
+
endColor: { label: 'End color', kind: 'color' },
|
|
27
|
+
startAlpha: { label: 'Start alpha', min: 0, max: 1 },
|
|
28
|
+
endAlpha: { label: 'End alpha', min: 0, max: 1 },
|
|
29
|
+
texture: { label: 'Texture', kind: 'texture' },
|
|
30
|
+
pixelArt: { label: 'Pixel art' },
|
|
31
|
+
blend: { label: 'Blend', options: ['normal', 'additive'] },
|
|
32
|
+
layer: { label: 'Layer', step: 1 },
|
|
33
|
+
};
|
|
34
|
+
rate = 0;
|
|
35
|
+
emitting = true;
|
|
36
|
+
lifetime = 1;
|
|
37
|
+
positionSpread = [0, 0];
|
|
38
|
+
velocity = [0, 0];
|
|
39
|
+
velocitySpread = [0, 0];
|
|
40
|
+
gravity = [0, 0];
|
|
41
|
+
space = 'world';
|
|
42
|
+
_seed = 1;
|
|
43
|
+
get seed() {
|
|
44
|
+
return this._seed;
|
|
45
|
+
}
|
|
46
|
+
set seed(value) {
|
|
47
|
+
const next = Number.isFinite(value) ? Math.trunc(value) >>> 0 : 1;
|
|
48
|
+
if (next === this._seed)
|
|
49
|
+
return;
|
|
50
|
+
this._seed = next;
|
|
51
|
+
this._batch?.setSeed(next);
|
|
52
|
+
}
|
|
53
|
+
_capacity = 256;
|
|
54
|
+
get capacity() {
|
|
55
|
+
return this._capacity;
|
|
56
|
+
}
|
|
57
|
+
set capacity(value) {
|
|
58
|
+
const next = Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 256;
|
|
59
|
+
if (next === this._capacity)
|
|
60
|
+
return;
|
|
61
|
+
this._capacity = next;
|
|
62
|
+
this._batch?.resize(next);
|
|
63
|
+
}
|
|
64
|
+
overflow = 'recycle-oldest';
|
|
65
|
+
destroyMode = 'clear';
|
|
66
|
+
width = 1;
|
|
67
|
+
height = 1;
|
|
68
|
+
startScale = 1;
|
|
69
|
+
endScale = 1;
|
|
70
|
+
startColor = 0xffffff;
|
|
71
|
+
endColor = 0xffffff;
|
|
72
|
+
startAlpha = 1;
|
|
73
|
+
endAlpha = 0;
|
|
74
|
+
_texture = '';
|
|
75
|
+
get texture() {
|
|
76
|
+
return this._texture;
|
|
77
|
+
}
|
|
78
|
+
set texture(value) {
|
|
79
|
+
const next = typeof value === 'string' ? value : '';
|
|
80
|
+
if (next === this._texture)
|
|
81
|
+
return;
|
|
82
|
+
this._texture = next;
|
|
83
|
+
this._batch?.setTexture(next, this.pixelArt);
|
|
84
|
+
}
|
|
85
|
+
_pixelArt = false;
|
|
86
|
+
get pixelArt() {
|
|
87
|
+
return this._pixelArt;
|
|
88
|
+
}
|
|
89
|
+
set pixelArt(value) {
|
|
90
|
+
const next = value === true;
|
|
91
|
+
if (next === this._pixelArt)
|
|
92
|
+
return;
|
|
93
|
+
this._pixelArt = next;
|
|
94
|
+
this._batch?.setTexture(this.texture, next);
|
|
95
|
+
}
|
|
96
|
+
_blend = 'normal';
|
|
97
|
+
get blend() {
|
|
98
|
+
return this._blend;
|
|
99
|
+
}
|
|
100
|
+
set blend(value) {
|
|
101
|
+
const next = value === 'additive' ? 'additive' : 'normal';
|
|
102
|
+
if (next === this._blend)
|
|
103
|
+
return;
|
|
104
|
+
this._blend = next;
|
|
105
|
+
this._batch?.setBlend(next);
|
|
106
|
+
}
|
|
107
|
+
_layer = 0;
|
|
108
|
+
get layer() {
|
|
109
|
+
return this._layer;
|
|
110
|
+
}
|
|
111
|
+
set layer(value) {
|
|
112
|
+
const next = Number.isFinite(value) ? value : 0;
|
|
113
|
+
if (next === this._layer)
|
|
114
|
+
return;
|
|
115
|
+
this._layer = next;
|
|
116
|
+
this._batch?.setLayer(next);
|
|
117
|
+
this._batch?.sync(this.renderContext());
|
|
118
|
+
}
|
|
119
|
+
_batch;
|
|
120
|
+
_unsubscribeRenderSync;
|
|
121
|
+
_emissionRemainder = 0;
|
|
122
|
+
_destroyed = false;
|
|
123
|
+
get active() {
|
|
124
|
+
return this._batch?.active ?? 0;
|
|
125
|
+
}
|
|
126
|
+
onReady() {
|
|
127
|
+
const capacity = Number.isFinite(this.capacity) ? Math.max(1, Math.floor(this.capacity)) : 256;
|
|
128
|
+
this._batch = new ParticleBatch({
|
|
129
|
+
capacity,
|
|
130
|
+
seed: this.seed,
|
|
131
|
+
layer: this.layer,
|
|
132
|
+
blend: this.blend,
|
|
133
|
+
assets: this.game.assets,
|
|
134
|
+
});
|
|
135
|
+
this._batch.setTexture(this.texture, this.pixelArt);
|
|
136
|
+
this.game.scene.add(this._batch.mesh);
|
|
137
|
+
this._batch.sync(this.renderContext());
|
|
138
|
+
this._unsubscribeRenderSync = this.game.onUpdate(() => this._batch?.sync(this.renderContext()));
|
|
139
|
+
this._emissionRemainder = 0;
|
|
140
|
+
this._destroyed = false;
|
|
141
|
+
}
|
|
142
|
+
onUpdate(dt) {
|
|
143
|
+
if (this._destroyed)
|
|
144
|
+
return;
|
|
145
|
+
this._batch?.advance(dt, this.renderContext());
|
|
146
|
+
if (!this.emitting)
|
|
147
|
+
return;
|
|
148
|
+
const rate = Number.isFinite(this.rate) ? Math.max(0, this.rate) : 0;
|
|
149
|
+
if (rate === 0)
|
|
150
|
+
return;
|
|
151
|
+
this._emissionRemainder += rate * dt;
|
|
152
|
+
const count = Math.floor(this._emissionRemainder);
|
|
153
|
+
if (count === 0)
|
|
154
|
+
return;
|
|
155
|
+
this._emissionRemainder -= count;
|
|
156
|
+
this.emit(count);
|
|
157
|
+
}
|
|
158
|
+
/** Emits a burst immediately and returns how many particles from it remain active. */
|
|
159
|
+
emit(value) {
|
|
160
|
+
if (this._destroyed || !this._batch)
|
|
161
|
+
return 0;
|
|
162
|
+
const accepted = this._batch.emit(value, this.overflow, this.spawnConfig());
|
|
163
|
+
this._batch.sync(this.renderContext());
|
|
164
|
+
return accepted;
|
|
165
|
+
}
|
|
166
|
+
ySortEntries() {
|
|
167
|
+
return this._batch?.ySortEntries() ?? [];
|
|
168
|
+
}
|
|
169
|
+
setSortZ(z) {
|
|
170
|
+
this._batch?.setSortZ(z);
|
|
171
|
+
}
|
|
172
|
+
setSortZs(values) {
|
|
173
|
+
this._batch?.setSortZs(values);
|
|
174
|
+
}
|
|
175
|
+
inspectState() {
|
|
176
|
+
return { active: this.active, capacity: this.capacity, emitting: this.emitting };
|
|
177
|
+
}
|
|
178
|
+
onDestroy() {
|
|
179
|
+
this._destroyed = true;
|
|
180
|
+
this._unsubscribeRenderSync?.();
|
|
181
|
+
this._unsubscribeRenderSync = undefined;
|
|
182
|
+
const batch = this._batch;
|
|
183
|
+
this._batch = undefined;
|
|
184
|
+
if (!batch)
|
|
185
|
+
return;
|
|
186
|
+
if (this.destroyMode === 'drain' && batch.active > 0) {
|
|
187
|
+
batch.detach(this.renderContext());
|
|
188
|
+
sceneDrainsOf(this.game).add(batch);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
batch.dispose();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
renderContext() {
|
|
195
|
+
return {
|
|
196
|
+
owner: [this.entity.position.x, this.entity.position.y],
|
|
197
|
+
projection: this.game.projection,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
spawnConfig() {
|
|
201
|
+
return {
|
|
202
|
+
origin: [this.entity.position.x, this.entity.position.y],
|
|
203
|
+
positionSpread: this.positionSpread,
|
|
204
|
+
velocity: this.velocity,
|
|
205
|
+
velocitySpread: this.velocitySpread,
|
|
206
|
+
gravity: this.gravity,
|
|
207
|
+
space: this.space,
|
|
208
|
+
lifetime: this.lifetime,
|
|
209
|
+
width: this.width,
|
|
210
|
+
height: this.height,
|
|
211
|
+
startScale: this.startScale,
|
|
212
|
+
endScale: this.endScale,
|
|
213
|
+
startColor: this.startColor,
|
|
214
|
+
endColor: this.endColor,
|
|
215
|
+
startAlpha: this.startAlpha,
|
|
216
|
+
endAlpha: this.endAlpha,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
package/dist/game.js
CHANGED
|
@@ -15,8 +15,9 @@ import { Pointer } from './pointer.js';
|
|
|
15
15
|
import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
|
|
16
16
|
import { RuntimeInspector } from './runtime-inspection.js';
|
|
17
17
|
import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
18
|
-
import { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
18
|
+
import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
|
|
19
19
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
20
|
+
import { sceneDrainsOf } from './scene-drains.js';
|
|
20
21
|
import { createSpatialQuery } from './spatial-query.js';
|
|
21
22
|
import { Stats } from './stats.js';
|
|
22
23
|
import { anchoredPiecesOf, GameUi } from './ui.js';
|
|
@@ -202,6 +203,8 @@ export class Game {
|
|
|
202
203
|
// Pointer holds that array by reference, so it must never be reassigned.
|
|
203
204
|
for (const entity of [...this.entities])
|
|
204
205
|
entity.destroy();
|
|
206
|
+
// Drains transferred by the destruction cascade above are scene-scoped too.
|
|
207
|
+
sceneDrainsOf(this).clear();
|
|
205
208
|
this.registry = null;
|
|
206
209
|
this.renderSort = null;
|
|
207
210
|
this.sceneProjection = null;
|
|
@@ -394,6 +397,7 @@ export class Game {
|
|
|
394
397
|
this.time.cancelAll();
|
|
395
398
|
for (const entity of [...this.entities])
|
|
396
399
|
entity.destroy();
|
|
400
|
+
sceneDrainsOf(this).clear();
|
|
397
401
|
// After the entities: their clones go with the cascade above, the
|
|
398
402
|
// cached bases go here, exactly once (ADR 0019).
|
|
399
403
|
this.assets.dispose();
|
|
@@ -492,6 +496,9 @@ export class Game {
|
|
|
492
496
|
*/
|
|
493
497
|
simulateStep() {
|
|
494
498
|
advanceGameTime(this.time);
|
|
499
|
+
// Existing drains advance before entity updates, so one detached during
|
|
500
|
+
// this step begins on the next Simulation Step (ADR 0022).
|
|
501
|
+
sceneDrainsOf(this).advance(SIMULATION_STEP);
|
|
495
502
|
for (const entity of [...this.entities]) {
|
|
496
503
|
const schedule = this.componentUpdateSchedule(entity);
|
|
497
504
|
if (!schedule)
|
|
@@ -534,23 +541,37 @@ export class Game {
|
|
|
534
541
|
};
|
|
535
542
|
/** Under y-sort, re-derives every participant's z from layer band + entity Y. */
|
|
536
543
|
applyYSort() {
|
|
537
|
-
const
|
|
544
|
+
const singles = [];
|
|
545
|
+
const batches = [];
|
|
538
546
|
const entries = [];
|
|
539
547
|
for (const entity of this.entities) {
|
|
540
548
|
for (const component of entity.components) {
|
|
541
|
-
if (
|
|
542
|
-
|
|
549
|
+
if (isYSortBatchParticipant(component)) {
|
|
550
|
+
const batchEntries = component.ySortEntries();
|
|
551
|
+
batches.push({ participant: component, start: entries.length, count: batchEntries.length });
|
|
552
|
+
entries.push(...batchEntries);
|
|
553
|
+
}
|
|
554
|
+
else if (isYSortParticipant(component)) {
|
|
555
|
+
singles.push({ participant: component, index: entries.length });
|
|
543
556
|
entries.push({ layer: component.layer, y: entity.node.position.y });
|
|
544
557
|
}
|
|
545
558
|
}
|
|
546
559
|
}
|
|
560
|
+
for (const participant of sceneDrainsOf(this).participants()) {
|
|
561
|
+
const batchEntries = participant.ySortEntries();
|
|
562
|
+
batches.push({ participant, start: entries.length, count: batchEntries.length });
|
|
563
|
+
entries.push(...batchEntries);
|
|
564
|
+
}
|
|
547
565
|
const z = ySortZ(entries);
|
|
548
|
-
for (const
|
|
566
|
+
for (const { participant, index } of singles) {
|
|
549
567
|
const sortZ = z[index];
|
|
550
568
|
if (sortZ === undefined)
|
|
551
569
|
throw new Error(`ySortZ returned no z for y-sort participant ${index}`);
|
|
552
570
|
participant.setSortZ(sortZ);
|
|
553
571
|
}
|
|
572
|
+
for (const { participant, start, count } of batches) {
|
|
573
|
+
participant.setSortZs(z.slice(start, start + count));
|
|
574
|
+
}
|
|
554
575
|
}
|
|
555
576
|
renderSurface() {
|
|
556
577
|
if (this.sceneProjection === 'isometric') {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions
|
|
|
9
9
|
export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
|
|
10
10
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
11
11
|
export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
|
|
12
|
-
export { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
13
|
-
export type { YSortEntry, YSortParticipant } from './render-sort.js';
|
|
12
|
+
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
13
|
+
export type { YSortBatchParticipant, YSortEntry, YSortParticipant } from './render-sort.js';
|
|
14
14
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
15
15
|
export type { ProjectedPoint } from './projection.js';
|
|
16
16
|
export { spritePlacement } from './sprite-placement.js';
|
|
@@ -44,6 +44,8 @@ export type { StatValue } from './stats.js';
|
|
|
44
44
|
export { GameUi } from './ui.js';
|
|
45
45
|
export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
|
|
46
46
|
export { Sprite } from './components/sprite.js';
|
|
47
|
+
export { ParticleEmitter } from './components/particle-emitter.js';
|
|
48
|
+
export type { ParticleBlend, ParticleDestroyMode, ParticleOverflow, ParticleSpace, ParticleVector, } from './components/particle-emitter.js';
|
|
47
49
|
export { Solid } from './components/solid.js';
|
|
48
50
|
export { Hitbox } from './components/hitbox.js';
|
|
49
51
|
export { DynamicBody } from './components/dynamic-body.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { Game } from './game.js';
|
|
|
2
2
|
export { AssetLoader } from './assets/asset-loader.js';
|
|
3
3
|
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
4
4
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
5
|
-
export { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
5
|
+
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
6
6
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
7
7
|
export { spritePlacement } from './sprite-placement.js';
|
|
8
8
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
@@ -21,6 +21,7 @@ export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
|
|
|
21
21
|
export { Stats } from './stats.js';
|
|
22
22
|
export { GameUi } from './ui.js';
|
|
23
23
|
export { Sprite } from './components/sprite.js';
|
|
24
|
+
export { ParticleEmitter } from './components/particle-emitter.js';
|
|
24
25
|
export { Solid } from './components/solid.js';
|
|
25
26
|
export { Hitbox } from './components/hitbox.js';
|
|
26
27
|
export { DynamicBody } from './components/dynamic-body.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { AssetLoader } from './assets/asset-loader.js';
|
|
3
|
+
import type { ParticleBlend, ParticleOverflow } from './components/particle-emitter.js';
|
|
4
|
+
import { type ParticleSpawn } from './particle-pool.js';
|
|
5
|
+
import type { YSortEntry } from './render-sort.js';
|
|
6
|
+
import type { SceneDrain } from './scene-drains.js';
|
|
7
|
+
export type { ParticleSpawn } from './particle-pool.js';
|
|
8
|
+
export interface ParticleRenderContext {
|
|
9
|
+
owner: readonly [number, number];
|
|
10
|
+
projection: 'isometric' | null;
|
|
11
|
+
}
|
|
12
|
+
interface ParticleBatchOptions {
|
|
13
|
+
capacity: number;
|
|
14
|
+
seed: number;
|
|
15
|
+
layer: number;
|
|
16
|
+
blend: ParticleBlend;
|
|
17
|
+
assets: AssetLoader;
|
|
18
|
+
}
|
|
19
|
+
/** Fixed Three resources around one dense CPU particle pool. */
|
|
20
|
+
export declare class ParticleBatch implements SceneDrain {
|
|
21
|
+
readonly material: THREE.MeshBasicMaterial;
|
|
22
|
+
readonly mesh: THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicMaterial>;
|
|
23
|
+
private readonly pool;
|
|
24
|
+
private readonly assets;
|
|
25
|
+
private _layer;
|
|
26
|
+
private textureRevision;
|
|
27
|
+
private drainProjection;
|
|
28
|
+
private disposed;
|
|
29
|
+
constructor({ capacity, seed, layer, blend, assets }: ParticleBatchOptions);
|
|
30
|
+
get active(): number;
|
|
31
|
+
get layer(): number;
|
|
32
|
+
setSeed(seed: number): void;
|
|
33
|
+
setLayer(layer: number): void;
|
|
34
|
+
setBlend(blend: ParticleBlend): void;
|
|
35
|
+
setTexture(uri: string, pixelArt: boolean): void;
|
|
36
|
+
resize(capacity: number): void;
|
|
37
|
+
emit(value: number, overflow: ParticleOverflow, spawn: ParticleSpawn): number;
|
|
38
|
+
detach(context: ParticleRenderContext): void;
|
|
39
|
+
advanceDrain(dt: number): boolean;
|
|
40
|
+
advance(dt: number, context: ParticleRenderContext): void;
|
|
41
|
+
sync(context: ParticleRenderContext): void;
|
|
42
|
+
ySortEntries(): readonly YSortEntry[];
|
|
43
|
+
setSortZ(z: number): void;
|
|
44
|
+
setSortZs(values: readonly number[]): void;
|
|
45
|
+
dispose(): void;
|
|
46
|
+
private settleTexture;
|
|
47
|
+
private syncSlot;
|
|
48
|
+
private writeColor;
|
|
49
|
+
private writeSortZ;
|
|
50
|
+
private sortEntrySlots;
|
|
51
|
+
private sortIndices;
|
|
52
|
+
private insertIndex;
|
|
53
|
+
private before;
|
|
54
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { reportRejection } from './report-rejection.js';
|
|
3
|
+
import { ParticlePool, particleValueAt, } from './particle-pool.js';
|
|
4
|
+
import { projectIsometric } from './projection.js';
|
|
5
|
+
import { refreshParticleSortBounds } from './particle-sort-bounds.js';
|
|
6
|
+
function createGeometry(capacity) {
|
|
7
|
+
const geometry = new THREE.BufferGeometry();
|
|
8
|
+
const uvs = new Float32Array(capacity * 8);
|
|
9
|
+
const indices = new Uint32Array(capacity * 6);
|
|
10
|
+
for (let slot = 0; slot < capacity; slot += 1) {
|
|
11
|
+
uvs.set([0, 0, 1, 0, 1, 1, 0, 1], slot * 8);
|
|
12
|
+
const vertex = slot * 4;
|
|
13
|
+
indices.set([vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3], slot * 6);
|
|
14
|
+
}
|
|
15
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(capacity * 12), 3));
|
|
16
|
+
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
17
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(capacity * 16), 4));
|
|
18
|
+
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
19
|
+
geometry.setDrawRange(0, 0);
|
|
20
|
+
return geometry;
|
|
21
|
+
}
|
|
22
|
+
function lerp(start, end, progress) {
|
|
23
|
+
return start + (end - start) * progress;
|
|
24
|
+
}
|
|
25
|
+
/** Fixed Three resources around one dense CPU particle pool. */
|
|
26
|
+
export class ParticleBatch {
|
|
27
|
+
material;
|
|
28
|
+
mesh;
|
|
29
|
+
pool;
|
|
30
|
+
assets;
|
|
31
|
+
_layer;
|
|
32
|
+
textureRevision = 0;
|
|
33
|
+
drainProjection = null;
|
|
34
|
+
disposed = false;
|
|
35
|
+
constructor({ capacity, seed, layer, blend, assets }) {
|
|
36
|
+
this.pool = new ParticlePool(capacity, seed);
|
|
37
|
+
this.assets = assets;
|
|
38
|
+
this._layer = Number.isFinite(layer) ? layer : 0;
|
|
39
|
+
this.material = new THREE.MeshBasicMaterial({
|
|
40
|
+
color: 0xffffff,
|
|
41
|
+
transparent: true,
|
|
42
|
+
vertexColors: true,
|
|
43
|
+
depthTest: true,
|
|
44
|
+
depthWrite: true,
|
|
45
|
+
blending: blend === 'additive' ? THREE.AdditiveBlending : THREE.NormalBlending,
|
|
46
|
+
});
|
|
47
|
+
this.mesh = new THREE.Mesh(createGeometry(capacity), this.material);
|
|
48
|
+
this.mesh.frustumCulled = false;
|
|
49
|
+
}
|
|
50
|
+
get active() {
|
|
51
|
+
return this.pool.active;
|
|
52
|
+
}
|
|
53
|
+
get layer() {
|
|
54
|
+
return this._layer;
|
|
55
|
+
}
|
|
56
|
+
setSeed(seed) {
|
|
57
|
+
this.pool.setSeed(seed);
|
|
58
|
+
}
|
|
59
|
+
setLayer(layer) {
|
|
60
|
+
this._layer = Number.isFinite(layer) ? layer : 0;
|
|
61
|
+
}
|
|
62
|
+
setBlend(blend) {
|
|
63
|
+
this.material.blending = blend === 'additive' ? THREE.AdditiveBlending : THREE.NormalBlending;
|
|
64
|
+
this.material.needsUpdate = true;
|
|
65
|
+
}
|
|
66
|
+
setTexture(uri, pixelArt) {
|
|
67
|
+
this.textureRevision += 1;
|
|
68
|
+
const revision = this.textureRevision;
|
|
69
|
+
this.material.map?.dispose();
|
|
70
|
+
this.material.map = null;
|
|
71
|
+
if (!uri || this.disposed) {
|
|
72
|
+
this.material.needsUpdate = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { texture, settled } = this.assets.texture(uri);
|
|
76
|
+
const filter = pixelArt ? THREE.NearestFilter : THREE.LinearFilter;
|
|
77
|
+
texture.minFilter = filter;
|
|
78
|
+
texture.magFilter = filter;
|
|
79
|
+
this.material.map = texture;
|
|
80
|
+
this.material.needsUpdate = true;
|
|
81
|
+
reportRejection(settled.then((outcome) => this.settleTexture(outcome, revision, texture)), 'particle texture settle');
|
|
82
|
+
}
|
|
83
|
+
resize(capacity) {
|
|
84
|
+
if (this.disposed || !this.pool.resize(capacity))
|
|
85
|
+
return;
|
|
86
|
+
const previous = this.mesh.geometry;
|
|
87
|
+
this.mesh.geometry = createGeometry(capacity);
|
|
88
|
+
previous.dispose();
|
|
89
|
+
}
|
|
90
|
+
emit(value, overflow, spawn) {
|
|
91
|
+
if (this.disposed)
|
|
92
|
+
return 0;
|
|
93
|
+
return this.pool.emit(value, overflow, spawn);
|
|
94
|
+
}
|
|
95
|
+
detach(context) {
|
|
96
|
+
this.pool.detach(context.owner);
|
|
97
|
+
this.drainProjection = context.projection;
|
|
98
|
+
this.sync({ owner: [0, 0], projection: this.drainProjection });
|
|
99
|
+
}
|
|
100
|
+
advanceDrain(dt) {
|
|
101
|
+
this.advance(dt, { owner: [0, 0], projection: this.drainProjection });
|
|
102
|
+
return this.active > 0;
|
|
103
|
+
}
|
|
104
|
+
advance(dt, context) {
|
|
105
|
+
this.pool.advance(dt);
|
|
106
|
+
this.sync(context);
|
|
107
|
+
}
|
|
108
|
+
sync(context) {
|
|
109
|
+
const positions = this.mesh.geometry.getAttribute('position');
|
|
110
|
+
const colors = this.mesh.geometry.getAttribute('color');
|
|
111
|
+
for (let slot = 0; slot < this.active; slot += 1) {
|
|
112
|
+
this.syncSlot({ positions, colors, slot, context });
|
|
113
|
+
}
|
|
114
|
+
refreshParticleSortBounds(this.mesh.geometry, this.active, this._layer * 0.01);
|
|
115
|
+
positions.needsUpdate = true;
|
|
116
|
+
colors.needsUpdate = true;
|
|
117
|
+
this.sortIndices();
|
|
118
|
+
this.mesh.geometry.setDrawRange(0, this.active * 6);
|
|
119
|
+
}
|
|
120
|
+
ySortEntries() {
|
|
121
|
+
this.sortEntrySlots();
|
|
122
|
+
const { entrySlots, renderY } = this.pool.arrays;
|
|
123
|
+
return Array.from({ length: this.active }, (_, rank) => {
|
|
124
|
+
const slot = particleValueAt(entrySlots, rank);
|
|
125
|
+
return { layer: this._layer, y: particleValueAt(renderY, slot) };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
setSortZ(z) {
|
|
129
|
+
const positions = this.mesh.geometry.getAttribute('position');
|
|
130
|
+
for (let slot = 0; slot < this.active; slot += 1)
|
|
131
|
+
this.writeSortZ(positions, slot, z);
|
|
132
|
+
refreshParticleSortBounds(this.mesh.geometry, this.active, this._layer * 0.01);
|
|
133
|
+
positions.needsUpdate = true;
|
|
134
|
+
this.sortIndices();
|
|
135
|
+
}
|
|
136
|
+
setSortZs(values) {
|
|
137
|
+
const positions = this.mesh.geometry.getAttribute('position');
|
|
138
|
+
for (let rank = 0; rank < this.active; rank += 1) {
|
|
139
|
+
const slot = particleValueAt(this.pool.arrays.entrySlots, rank);
|
|
140
|
+
const z = values[rank];
|
|
141
|
+
if (z === undefined)
|
|
142
|
+
throw new Error(`ParticleBatch has no y-sort value for rank ${rank}`);
|
|
143
|
+
this.writeSortZ(positions, slot, z);
|
|
144
|
+
}
|
|
145
|
+
refreshParticleSortBounds(this.mesh.geometry, this.active, this._layer * 0.01);
|
|
146
|
+
positions.needsUpdate = true;
|
|
147
|
+
this.sortIndices();
|
|
148
|
+
}
|
|
149
|
+
dispose() {
|
|
150
|
+
if (this.disposed)
|
|
151
|
+
return;
|
|
152
|
+
this.disposed = true;
|
|
153
|
+
this.textureRevision += 1;
|
|
154
|
+
this.pool.clear();
|
|
155
|
+
this.mesh.removeFromParent();
|
|
156
|
+
this.mesh.geometry.dispose();
|
|
157
|
+
this.material.map?.dispose();
|
|
158
|
+
this.material.map = null;
|
|
159
|
+
this.material.dispose();
|
|
160
|
+
}
|
|
161
|
+
settleTexture(outcome, revision, texture) {
|
|
162
|
+
if (outcome !== 'failed' || this.disposed || revision !== this.textureRevision
|
|
163
|
+
|| this.material.map !== texture)
|
|
164
|
+
return;
|
|
165
|
+
this.material.map = null;
|
|
166
|
+
texture.dispose();
|
|
167
|
+
this.material.needsUpdate = true;
|
|
168
|
+
}
|
|
169
|
+
syncSlot({ positions, colors, slot, context }) {
|
|
170
|
+
const arrays = this.pool.arrays;
|
|
171
|
+
const local = arrays.local[slot] === 1;
|
|
172
|
+
const logicalX = particleValueAt(arrays.x, slot) + (local ? context.owner[0] : 0);
|
|
173
|
+
const logicalY = particleValueAt(arrays.y, slot) + (local ? context.owner[1] : 0);
|
|
174
|
+
const center = context.projection === 'isometric'
|
|
175
|
+
? projectIsometric(logicalX, logicalY)
|
|
176
|
+
: { x: logicalX, y: logicalY };
|
|
177
|
+
const progress = Math.min(1, Math.max(0, particleValueAt(arrays.age, slot) / particleValueAt(arrays.lifetime, slot)));
|
|
178
|
+
const scale = lerp(particleValueAt(arrays.startScale, slot), particleValueAt(arrays.endScale, slot), progress);
|
|
179
|
+
const halfWidth = particleValueAt(arrays.width, slot) * scale / 2;
|
|
180
|
+
const halfHeight = particleValueAt(arrays.height, slot) * scale / 2;
|
|
181
|
+
const vertex = slot * 4;
|
|
182
|
+
const z = this._layer * 0.01;
|
|
183
|
+
arrays.renderY[slot] = center.y;
|
|
184
|
+
arrays.sortZ[slot] = z;
|
|
185
|
+
positions.setXYZ(vertex, center.x - halfWidth, center.y - halfHeight, z);
|
|
186
|
+
positions.setXYZ(vertex + 1, center.x + halfWidth, center.y - halfHeight, z);
|
|
187
|
+
positions.setXYZ(vertex + 2, center.x + halfWidth, center.y + halfHeight, z);
|
|
188
|
+
positions.setXYZ(vertex + 3, center.x - halfWidth, center.y + halfHeight, z);
|
|
189
|
+
this.writeColor({ colors, vertex, slot, progress, arrays });
|
|
190
|
+
}
|
|
191
|
+
writeColor({ colors, vertex, slot, progress, arrays }) {
|
|
192
|
+
const start = particleValueAt(arrays.startColor, slot);
|
|
193
|
+
const end = particleValueAt(arrays.endColor, slot);
|
|
194
|
+
const red = lerp((start >>> 16) & 0xff, (end >>> 16) & 0xff, progress) / 255;
|
|
195
|
+
const green = lerp((start >>> 8) & 0xff, (end >>> 8) & 0xff, progress) / 255;
|
|
196
|
+
const blue = lerp(start & 0xff, end & 0xff, progress) / 255;
|
|
197
|
+
const opacity = lerp(particleValueAt(arrays.startAlpha, slot), particleValueAt(arrays.endAlpha, slot), progress);
|
|
198
|
+
for (let corner = 0; corner < 4; corner += 1) {
|
|
199
|
+
colors.setXYZW(vertex + corner, red, green, blue, opacity);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
writeSortZ(positions, slot, z) {
|
|
203
|
+
this.pool.arrays.sortZ[slot] = z;
|
|
204
|
+
const vertex = slot * 4;
|
|
205
|
+
for (let corner = 0; corner < 4; corner += 1)
|
|
206
|
+
positions.setZ(vertex + corner, z);
|
|
207
|
+
}
|
|
208
|
+
sortEntrySlots() {
|
|
209
|
+
const { entrySlots, ordinal } = this.pool.arrays;
|
|
210
|
+
for (let slot = 0; slot < this.active; slot += 1)
|
|
211
|
+
entrySlots[slot] = slot;
|
|
212
|
+
for (let rank = 1; rank < this.active; rank += 1) {
|
|
213
|
+
const candidate = particleValueAt(entrySlots, rank);
|
|
214
|
+
let cursor = rank - 1;
|
|
215
|
+
while (cursor >= 0) {
|
|
216
|
+
const previous = particleValueAt(entrySlots, cursor);
|
|
217
|
+
if (particleValueAt(ordinal, candidate) >= particleValueAt(ordinal, previous))
|
|
218
|
+
break;
|
|
219
|
+
entrySlots[cursor + 1] = previous;
|
|
220
|
+
cursor -= 1;
|
|
221
|
+
}
|
|
222
|
+
entrySlots[cursor + 1] = candidate;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
sortIndices() {
|
|
226
|
+
const { order } = this.pool.arrays;
|
|
227
|
+
for (let slot = 0; slot < this.active; slot += 1)
|
|
228
|
+
order[slot] = slot;
|
|
229
|
+
for (let rank = 1; rank < this.active; rank += 1)
|
|
230
|
+
this.insertIndex(rank, order);
|
|
231
|
+
const index = this.mesh.geometry.index;
|
|
232
|
+
if (!index)
|
|
233
|
+
throw new Error('ParticleBatch geometry has no index');
|
|
234
|
+
for (let rank = 0; rank < this.active; rank += 1) {
|
|
235
|
+
const vertex = particleValueAt(order, rank) * 4;
|
|
236
|
+
index.setX(rank * 6, vertex);
|
|
237
|
+
index.setX(rank * 6 + 1, vertex + 1);
|
|
238
|
+
index.setX(rank * 6 + 2, vertex + 2);
|
|
239
|
+
index.setX(rank * 6 + 3, vertex);
|
|
240
|
+
index.setX(rank * 6 + 4, vertex + 2);
|
|
241
|
+
index.setX(rank * 6 + 5, vertex + 3);
|
|
242
|
+
}
|
|
243
|
+
index.needsUpdate = true;
|
|
244
|
+
}
|
|
245
|
+
insertIndex(rank, order) {
|
|
246
|
+
const candidate = particleValueAt(order, rank);
|
|
247
|
+
let cursor = rank - 1;
|
|
248
|
+
while (cursor >= 0) {
|
|
249
|
+
const previous = particleValueAt(order, cursor);
|
|
250
|
+
if (!this.before(candidate, previous))
|
|
251
|
+
break;
|
|
252
|
+
order[cursor + 1] = previous;
|
|
253
|
+
cursor -= 1;
|
|
254
|
+
}
|
|
255
|
+
order[cursor + 1] = candidate;
|
|
256
|
+
}
|
|
257
|
+
before(left, right) {
|
|
258
|
+
const { sortZ, renderY, ordinal } = this.pool.arrays;
|
|
259
|
+
const leftZ = particleValueAt(sortZ, left);
|
|
260
|
+
const rightZ = particleValueAt(sortZ, right);
|
|
261
|
+
if (leftZ !== rightZ)
|
|
262
|
+
return leftZ < rightZ;
|
|
263
|
+
const leftY = particleValueAt(renderY, left);
|
|
264
|
+
const rightY = particleValueAt(renderY, right);
|
|
265
|
+
if (leftY !== rightY)
|
|
266
|
+
return leftY > rightY;
|
|
267
|
+
return particleValueAt(ordinal, left) < particleValueAt(ordinal, right);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ParticleOverflow, ParticleSpace } from './components/particle-emitter.js';
|
|
2
|
+
export interface ParticleSpawn {
|
|
3
|
+
origin: readonly number[];
|
|
4
|
+
positionSpread: unknown;
|
|
5
|
+
velocity: unknown;
|
|
6
|
+
velocitySpread: unknown;
|
|
7
|
+
gravity: unknown;
|
|
8
|
+
space: ParticleSpace;
|
|
9
|
+
lifetime: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
startScale: number;
|
|
13
|
+
endScale: number;
|
|
14
|
+
startColor: number;
|
|
15
|
+
endColor: number;
|
|
16
|
+
startAlpha: number;
|
|
17
|
+
endAlpha: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ParticleArrays {
|
|
20
|
+
x: Float64Array;
|
|
21
|
+
y: Float64Array;
|
|
22
|
+
vx: Float64Array;
|
|
23
|
+
vy: Float64Array;
|
|
24
|
+
gx: Float64Array;
|
|
25
|
+
gy: Float64Array;
|
|
26
|
+
age: Float64Array;
|
|
27
|
+
lifetime: Float64Array;
|
|
28
|
+
width: Float64Array;
|
|
29
|
+
height: Float64Array;
|
|
30
|
+
startScale: Float64Array;
|
|
31
|
+
endScale: Float64Array;
|
|
32
|
+
startColor: Uint32Array;
|
|
33
|
+
endColor: Uint32Array;
|
|
34
|
+
startAlpha: Float64Array;
|
|
35
|
+
endAlpha: Float64Array;
|
|
36
|
+
local: Uint8Array;
|
|
37
|
+
ordinal: Float64Array;
|
|
38
|
+
renderY: Float64Array;
|
|
39
|
+
sortZ: Float64Array;
|
|
40
|
+
order: Uint32Array;
|
|
41
|
+
entrySlots: Uint32Array;
|
|
42
|
+
}
|
|
43
|
+
export declare function particleValueAt(values: ArrayLike<number>, index: number): number;
|
|
44
|
+
/** Dense, fixed-capacity CPU state and deterministic sampling for one particle batch. */
|
|
45
|
+
export declare class ParticlePool {
|
|
46
|
+
private capacity;
|
|
47
|
+
active: number;
|
|
48
|
+
arrays: ParticleArrays;
|
|
49
|
+
private rngState;
|
|
50
|
+
private nextOrdinal;
|
|
51
|
+
constructor(capacity: number, seed: number);
|
|
52
|
+
setSeed(seed: number): void;
|
|
53
|
+
resize(capacity: number): boolean;
|
|
54
|
+
clear(): void;
|
|
55
|
+
emit(value: number, overflow: ParticleOverflow, spawn: ParticleSpawn): number;
|
|
56
|
+
detach(owner: readonly [number, number]): void;
|
|
57
|
+
advance(dt: number): void;
|
|
58
|
+
private advanceSlot;
|
|
59
|
+
private random;
|
|
60
|
+
private spawn;
|
|
61
|
+
private writeSpawnSnapshot;
|
|
62
|
+
private randomized;
|
|
63
|
+
private recycleFor;
|
|
64
|
+
private removeOldest;
|
|
65
|
+
private remove;
|
|
66
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
function particleArrays(capacity) {
|
|
2
|
+
return {
|
|
3
|
+
x: new Float64Array(capacity),
|
|
4
|
+
y: new Float64Array(capacity),
|
|
5
|
+
vx: new Float64Array(capacity),
|
|
6
|
+
vy: new Float64Array(capacity),
|
|
7
|
+
gx: new Float64Array(capacity),
|
|
8
|
+
gy: new Float64Array(capacity),
|
|
9
|
+
age: new Float64Array(capacity),
|
|
10
|
+
lifetime: new Float64Array(capacity),
|
|
11
|
+
width: new Float64Array(capacity),
|
|
12
|
+
height: new Float64Array(capacity),
|
|
13
|
+
startScale: new Float64Array(capacity),
|
|
14
|
+
endScale: new Float64Array(capacity),
|
|
15
|
+
startColor: new Uint32Array(capacity),
|
|
16
|
+
endColor: new Uint32Array(capacity),
|
|
17
|
+
startAlpha: new Float64Array(capacity),
|
|
18
|
+
endAlpha: new Float64Array(capacity),
|
|
19
|
+
local: new Uint8Array(capacity),
|
|
20
|
+
ordinal: new Float64Array(capacity),
|
|
21
|
+
renderY: new Float64Array(capacity),
|
|
22
|
+
sortZ: new Float64Array(capacity),
|
|
23
|
+
order: new Uint32Array(capacity),
|
|
24
|
+
entrySlots: new Uint32Array(capacity),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function particleValueAt(values, index) {
|
|
28
|
+
const value = values[index];
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
throw new Error(`Particle slot ${index} is outside its capacity`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function finiteAxis(value, index) {
|
|
34
|
+
if (!Array.isArray(value))
|
|
35
|
+
return 0;
|
|
36
|
+
const axis = value[index];
|
|
37
|
+
return typeof axis === 'number' && Number.isFinite(axis) ? axis : 0;
|
|
38
|
+
}
|
|
39
|
+
function finiteNonNegative(value, fallback) {
|
|
40
|
+
if (!Number.isFinite(value))
|
|
41
|
+
return fallback;
|
|
42
|
+
return Math.max(0, value);
|
|
43
|
+
}
|
|
44
|
+
function alpha(value, fallback) {
|
|
45
|
+
return Math.min(1, finiteNonNegative(value, fallback));
|
|
46
|
+
}
|
|
47
|
+
function color(value) {
|
|
48
|
+
return Number.isFinite(value) ? Math.min(0xffffff, Math.max(0, Math.trunc(value))) : 0xffffff;
|
|
49
|
+
}
|
|
50
|
+
function normalizedSeed(value) {
|
|
51
|
+
return Number.isFinite(value) ? Math.trunc(value) >>> 0 : 1;
|
|
52
|
+
}
|
|
53
|
+
function stateArrays(arrays) {
|
|
54
|
+
return [
|
|
55
|
+
arrays.x, arrays.y, arrays.vx, arrays.vy, arrays.gx, arrays.gy,
|
|
56
|
+
arrays.age, arrays.lifetime, arrays.width, arrays.height,
|
|
57
|
+
arrays.startScale, arrays.endScale, arrays.startColor, arrays.endColor,
|
|
58
|
+
arrays.startAlpha, arrays.endAlpha, arrays.local, arrays.ordinal,
|
|
59
|
+
arrays.renderY, arrays.sortZ,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
/** Dense, fixed-capacity CPU state and deterministic sampling for one particle batch. */
|
|
63
|
+
export class ParticlePool {
|
|
64
|
+
capacity;
|
|
65
|
+
active = 0;
|
|
66
|
+
arrays;
|
|
67
|
+
rngState;
|
|
68
|
+
nextOrdinal = 0;
|
|
69
|
+
constructor(capacity, seed) {
|
|
70
|
+
this.capacity = capacity;
|
|
71
|
+
this.arrays = particleArrays(capacity);
|
|
72
|
+
this.rngState = normalizedSeed(seed);
|
|
73
|
+
}
|
|
74
|
+
setSeed(seed) {
|
|
75
|
+
this.rngState = normalizedSeed(seed);
|
|
76
|
+
}
|
|
77
|
+
resize(capacity) {
|
|
78
|
+
if (capacity === this.capacity)
|
|
79
|
+
return false;
|
|
80
|
+
this.capacity = capacity;
|
|
81
|
+
this.arrays = particleArrays(capacity);
|
|
82
|
+
this.active = 0;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
clear() {
|
|
86
|
+
this.active = 0;
|
|
87
|
+
}
|
|
88
|
+
emit(value, overflow, spawn) {
|
|
89
|
+
if (!Number.isFinite(spawn.lifetime) || spawn.lifetime <= 0)
|
|
90
|
+
return 0;
|
|
91
|
+
const count = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
92
|
+
const accepted = overflow === 'drop-new'
|
|
93
|
+
? Math.min(count, this.capacity - this.active)
|
|
94
|
+
: Math.min(count, this.capacity);
|
|
95
|
+
if (overflow !== 'drop-new')
|
|
96
|
+
this.recycleFor(accepted);
|
|
97
|
+
for (let index = 0; index < accepted; index += 1)
|
|
98
|
+
this.spawn(spawn);
|
|
99
|
+
return accepted;
|
|
100
|
+
}
|
|
101
|
+
detach(owner) {
|
|
102
|
+
const { local, x, y } = this.arrays;
|
|
103
|
+
for (let slot = 0; slot < this.active; slot += 1) {
|
|
104
|
+
if (local[slot] !== 1)
|
|
105
|
+
continue;
|
|
106
|
+
x[slot] = particleValueAt(x, slot) + owner[0];
|
|
107
|
+
y[slot] = particleValueAt(y, slot) + owner[1];
|
|
108
|
+
local[slot] = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
advance(dt) {
|
|
112
|
+
let slot = 0;
|
|
113
|
+
while (slot < this.active) {
|
|
114
|
+
const age = this.advanceSlot(slot, dt);
|
|
115
|
+
if (age >= particleValueAt(this.arrays.lifetime, slot))
|
|
116
|
+
this.remove(slot);
|
|
117
|
+
else
|
|
118
|
+
slot += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
advanceSlot(slot, dt) {
|
|
122
|
+
const { vx, vy, gx, gy, x, y, age } = this.arrays;
|
|
123
|
+
const nextVx = particleValueAt(vx, slot) + particleValueAt(gx, slot) * dt;
|
|
124
|
+
const nextVy = particleValueAt(vy, slot) + particleValueAt(gy, slot) * dt;
|
|
125
|
+
const nextAge = particleValueAt(age, slot) + dt;
|
|
126
|
+
vx[slot] = nextVx;
|
|
127
|
+
vy[slot] = nextVy;
|
|
128
|
+
x[slot] = particleValueAt(x, slot) + nextVx * dt;
|
|
129
|
+
y[slot] = particleValueAt(y, slot) + nextVy * dt;
|
|
130
|
+
age[slot] = nextAge;
|
|
131
|
+
return nextAge;
|
|
132
|
+
}
|
|
133
|
+
random() {
|
|
134
|
+
this.rngState = (this.rngState + 0x6d2b79f5) >>> 0;
|
|
135
|
+
let value = this.rngState;
|
|
136
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
137
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
138
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
139
|
+
}
|
|
140
|
+
spawn(config) {
|
|
141
|
+
const positionX = this.random();
|
|
142
|
+
const positionY = this.random();
|
|
143
|
+
const velocityX = this.random();
|
|
144
|
+
const velocityY = this.random();
|
|
145
|
+
const slot = this.active;
|
|
146
|
+
const local = config.space === 'local';
|
|
147
|
+
const { x, y, vx, vy, gx, gy } = this.arrays;
|
|
148
|
+
x[slot] = (local ? 0 : finiteAxis(config.origin, 0))
|
|
149
|
+
+ this.randomized(finiteAxis(config.positionSpread, 0), positionX);
|
|
150
|
+
y[slot] = (local ? 0 : finiteAxis(config.origin, 1))
|
|
151
|
+
+ this.randomized(finiteAxis(config.positionSpread, 1), positionY);
|
|
152
|
+
vx[slot] = finiteAxis(config.velocity, 0)
|
|
153
|
+
+ this.randomized(finiteAxis(config.velocitySpread, 0), velocityX);
|
|
154
|
+
vy[slot] = finiteAxis(config.velocity, 1)
|
|
155
|
+
+ this.randomized(finiteAxis(config.velocitySpread, 1), velocityY);
|
|
156
|
+
gx[slot] = finiteAxis(config.gravity, 0);
|
|
157
|
+
gy[slot] = finiteAxis(config.gravity, 1);
|
|
158
|
+
this.writeSpawnSnapshot(slot, config, local);
|
|
159
|
+
this.active += 1;
|
|
160
|
+
}
|
|
161
|
+
writeSpawnSnapshot(slot, config, local) {
|
|
162
|
+
const arrays = this.arrays;
|
|
163
|
+
arrays.age[slot] = 0;
|
|
164
|
+
arrays.lifetime[slot] = config.lifetime;
|
|
165
|
+
arrays.width[slot] = finiteNonNegative(config.width, 1);
|
|
166
|
+
arrays.height[slot] = finiteNonNegative(config.height, 1);
|
|
167
|
+
arrays.startScale[slot] = finiteNonNegative(config.startScale, 1);
|
|
168
|
+
arrays.endScale[slot] = finiteNonNegative(config.endScale, 1);
|
|
169
|
+
arrays.startColor[slot] = color(config.startColor);
|
|
170
|
+
arrays.endColor[slot] = color(config.endColor);
|
|
171
|
+
arrays.startAlpha[slot] = alpha(config.startAlpha, 1);
|
|
172
|
+
arrays.endAlpha[slot] = alpha(config.endAlpha, 0);
|
|
173
|
+
arrays.local[slot] = local ? 1 : 0;
|
|
174
|
+
arrays.ordinal[slot] = this.nextOrdinal;
|
|
175
|
+
this.nextOrdinal += 1;
|
|
176
|
+
}
|
|
177
|
+
randomized(spread, sample) {
|
|
178
|
+
return (sample * 2 - 1) * Math.max(0, spread);
|
|
179
|
+
}
|
|
180
|
+
recycleFor(accepted) {
|
|
181
|
+
const replaced = Math.max(0, this.active + accepted - this.capacity);
|
|
182
|
+
for (let index = 0; index < replaced; index += 1)
|
|
183
|
+
this.removeOldest();
|
|
184
|
+
}
|
|
185
|
+
removeOldest() {
|
|
186
|
+
if (this.active === 0)
|
|
187
|
+
return;
|
|
188
|
+
let oldest = 0;
|
|
189
|
+
for (let slot = 1; slot < this.active; slot += 1) {
|
|
190
|
+
if (particleValueAt(this.arrays.ordinal, slot) < particleValueAt(this.arrays.ordinal, oldest)) {
|
|
191
|
+
oldest = slot;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.remove(oldest);
|
|
195
|
+
}
|
|
196
|
+
remove(slot) {
|
|
197
|
+
const last = this.active - 1;
|
|
198
|
+
if (slot !== last) {
|
|
199
|
+
for (const array of stateArrays(this.arrays))
|
|
200
|
+
array[slot] = particleValueAt(array, last);
|
|
201
|
+
}
|
|
202
|
+
this.active = last;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/** Keeps Three's transparent-object sort key on live vertices, never unused capacity. */
|
|
3
|
+
export function refreshParticleSortBounds(geometry, active, emptyZ) {
|
|
4
|
+
const positions = geometry.getAttribute('position');
|
|
5
|
+
const sphere = geometry.boundingSphere ?? new THREE.Sphere();
|
|
6
|
+
geometry.boundingSphere = sphere;
|
|
7
|
+
if (active === 0) {
|
|
8
|
+
sphere.center.set(0, 0, emptyZ);
|
|
9
|
+
sphere.radius = 0;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
13
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
14
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
15
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
16
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
17
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
18
|
+
for (let vertex = 0; vertex < active * 4; vertex += 1) {
|
|
19
|
+
const x = positions.getX(vertex);
|
|
20
|
+
const y = positions.getY(vertex);
|
|
21
|
+
const z = positions.getZ(vertex);
|
|
22
|
+
minX = Math.min(minX, x);
|
|
23
|
+
minY = Math.min(minY, y);
|
|
24
|
+
minZ = Math.min(minZ, z);
|
|
25
|
+
maxX = Math.max(maxX, x);
|
|
26
|
+
maxY = Math.max(maxY, y);
|
|
27
|
+
maxZ = Math.max(maxZ, z);
|
|
28
|
+
}
|
|
29
|
+
const width = maxX - minX;
|
|
30
|
+
const height = maxY - minY;
|
|
31
|
+
const depth = maxZ - minZ;
|
|
32
|
+
sphere.center.set(minX + width / 2, minY + height / 2, minZ + depth / 2);
|
|
33
|
+
sphere.radius = Math.sqrt(width * width + height * height + depth * depth) / 2;
|
|
34
|
+
}
|
package/dist/render-sort.d.ts
CHANGED
|
@@ -18,7 +18,13 @@ export interface YSortParticipant {
|
|
|
18
18
|
/** Y-sort pass hook: overrides the layer-derived z for this frame. */
|
|
19
19
|
setSortZ(z: number): void;
|
|
20
20
|
}
|
|
21
|
+
/** A single batched renderable that contributes one global y-sort entry per item. */
|
|
22
|
+
export interface YSortBatchParticipant extends YSortParticipant {
|
|
23
|
+
ySortEntries(): readonly YSortEntry[];
|
|
24
|
+
setSortZs(values: readonly number[]): void;
|
|
25
|
+
}
|
|
21
26
|
export declare function isYSortParticipant(value: unknown): value is YSortParticipant;
|
|
27
|
+
export declare function isYSortBatchParticipant(value: unknown): value is YSortBatchParticipant;
|
|
22
28
|
/**
|
|
23
29
|
* Z per entry under y-sort. Each layer keeps its 0.01 band; within a band,
|
|
24
30
|
* lower Y gets a higher z (renders in front), and exact Y ties keep input
|
package/dist/render-sort.js
CHANGED
|
@@ -4,6 +4,11 @@ export function isYSortParticipant(value) {
|
|
|
4
4
|
typeof value.layer === 'number' &&
|
|
5
5
|
typeof value.setSortZ === 'function');
|
|
6
6
|
}
|
|
7
|
+
export function isYSortBatchParticipant(value) {
|
|
8
|
+
return (isYSortParticipant(value) &&
|
|
9
|
+
typeof value.ySortEntries === 'function' &&
|
|
10
|
+
typeof value.setSortZs === 'function');
|
|
11
|
+
}
|
|
7
12
|
/** The Y of the entry at `index`, which `ySortZ` only reads for indices it grouped from `entries`. */
|
|
8
13
|
function entryY(entries, index) {
|
|
9
14
|
const entry = entries[index];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Game } from './game.js';
|
|
2
|
+
import type { YSortBatchParticipant } from './render-sort.js';
|
|
3
|
+
/** A detached render batch that advances only on Simulation Steps. */
|
|
4
|
+
export interface SceneDrain extends YSortBatchParticipant {
|
|
5
|
+
readonly active: number;
|
|
6
|
+
advanceDrain(dt: number): boolean;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
}
|
|
9
|
+
declare class SceneDrains {
|
|
10
|
+
private readonly drains;
|
|
11
|
+
add(drain: SceneDrain): void;
|
|
12
|
+
advance(dt: number): void;
|
|
13
|
+
participants(): readonly YSortBatchParticipant[];
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Internal scene-owned drain registry shared by Game and engine render components. */
|
|
17
|
+
export declare function sceneDrainsOf(game: Game): SceneDrains;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class SceneDrains {
|
|
2
|
+
drains = [];
|
|
3
|
+
add(drain) {
|
|
4
|
+
this.drains.push(drain);
|
|
5
|
+
}
|
|
6
|
+
advance(dt) {
|
|
7
|
+
let index = 0;
|
|
8
|
+
while (index < this.drains.length) {
|
|
9
|
+
const drain = this.drains[index];
|
|
10
|
+
if (drain?.advanceDrain(dt))
|
|
11
|
+
index += 1;
|
|
12
|
+
else {
|
|
13
|
+
drain?.dispose();
|
|
14
|
+
this.drains.splice(index, 1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
participants() {
|
|
19
|
+
return this.drains;
|
|
20
|
+
}
|
|
21
|
+
clear() {
|
|
22
|
+
for (const drain of this.drains)
|
|
23
|
+
drain.dispose();
|
|
24
|
+
this.drains.length = 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const managers = new WeakMap();
|
|
28
|
+
/** Internal scene-owned drain registry shared by Game and engine render components. */
|
|
29
|
+
export function sceneDrainsOf(game) {
|
|
30
|
+
const existing = managers.get(game);
|
|
31
|
+
if (existing)
|
|
32
|
+
return existing;
|
|
33
|
+
const created = new SceneDrains();
|
|
34
|
+
managers.set(game, created);
|
|
35
|
+
return created;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waica/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@types/three": "^0.
|
|
24
|
-
"three": "^0.
|
|
23
|
+
"@types/three": "^0.186.0",
|
|
24
|
+
"three": "^0.186.1"
|
|
25
25
|
}
|
|
26
26
|
}
|