@waica/engine 0.21.0 → 0.23.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 +14 -0
- package/dist/collision-category.js +5 -2
- package/dist/collision-dispatch.d.ts +7 -1
- package/dist/collision-dispatch.js +18 -11
- package/dist/components/animated-sprite.d.ts +2 -0
- package/dist/components/animated-sprite.js +41 -6
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +52 -4
- package/dist/game.d.ts +7 -3
- package/dist/game.js +15 -5
- package/dist/gamepad.d.ts +72 -0
- package/dist/gamepad.js +137 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/input.d.ts +61 -11
- package/dist/input.js +130 -24
- package/dist/runtime-bridge.d.ts +15 -3
- package/dist/runtime-bridge.js +14 -2
- package/dist/scene.d.ts +10 -0
- package/dist/spatial-broadphase.d.ts +7 -1
- package/dist/spatial-broadphase.js +16 -45
- package/dist/spatial-pairs.d.ts +17 -0
- package/dist/spatial-pairs.js +79 -0
- package/dist/sprite-batch.d.ts +93 -0
- package/dist/sprite-batch.js +211 -0
- package/dist/sprite-batches.d.ts +107 -0
- package/dist/sprite-batches.js +260 -0
- package/dist/sprite-draw-collector.d.ts +42 -0
- package/dist/sprite-draw-collector.js +107 -0
- package/dist/sprite-runs.d.ts +40 -0
- package/dist/sprite-runs.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,20 @@ The cues that hook a `StateMachine` must come after it and after the `ParticleEm
|
|
|
150
150
|
|
|
151
151
|
`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.
|
|
152
152
|
|
|
153
|
+
## Sprite Batches
|
|
154
|
+
|
|
155
|
+
`Sprite` and `AnimatedSprite` are drawn through **Sprite Batches** by default (ADR 0024). Every frame, after y-sort, the engine orders everything it draws exactly as three would without batching — layer bands, then y-sort, then spawn order, with particle batches, tilemaps and any other mesh in between — and each run of consecutive sprites that share a batch key becomes one instanced draw call. The key is the art and how it is sampled: the texture (an `AnimatedSprite`'s current sheet), `pixelArt` and `shape`. Each sprite is one instance carrying its own placement, color, frame and flip, so tinting, resizing, re-anchoring or animating a sprite never creates a material, and what is drawn in front of what never changes.
|
|
156
|
+
|
|
157
|
+
- **On by default.** A scene without a `render` block, or with `render.batch: true`, batches. Nothing to configure: sprites of one texture in a row draw in one call, a sprite of other art (or a particle batch, a tilemap) in between starts a new run.
|
|
158
|
+
- **Culling and Group order kept.** A sprite outside the camera is skipped for the frame, as three culls a sprite's own mesh, and an off-camera renderable never splits a run. A run hangs under a Group with its sprites' Group `renderOrder`, so `entity.node.renderOrder` orders batched sprites exactly as it orders their meshes without batching.
|
|
159
|
+
- **Shared art.** All live sprites of one key share one material and one texture clone, scene-scoped: released with the scene at `unloadScene()` and `game.dispose()`. A sprite whose texture fails moves alone to the untextured entry of its shape and keeps showing its `color`.
|
|
160
|
+
- **Reusable slots and buffers.** A destroyed sprite's slot goes to the next spawn of its key. Each run draws through a pooled instance buffer sized by that run: 16 instances at first, doubling when a longer run needs it, never shrinking during the scene. A key drawn as several runs keeps one buffer per run, so instance memory stays proportional to the sprites drawn. Once the buffers have grown to a scene's longest runs (and as many runs as it ever splits into), spawning, destroying and reordering sprites allocates no GPU objects.
|
|
161
|
+
- **Turning it off.** Scene JSON `"render": { "batch": false }` (the editor's **Sprite batching** toggle in the scene inspector) restores the per-sprite path: one mesh, one material and one draw call per sprite. Use it to rule batching out when a scene looks different than you expect, or while debugging draw order; the cost is one draw call per sprite, which is what dominates frame time with thousands of sprites.
|
|
162
|
+
|
|
163
|
+
Under batching, the object a sprite adds to its entity is a hidden placement anchor that is never drawn. Hide or show a batched sprite through its entity (`entity.node.visible`, as `Health` blinking does); the anchor's own `visible` is not read for the batched draw, and setting it to true makes three also draw the anchor as a plain quad (without the sprite's tint or frame) — leave it alone, or use `render.batch: false` when code needs one real mesh per sprite.
|
|
164
|
+
|
|
165
|
+
How much batching saves depends on how interleaved a scene is: one texture over the whole scene draws in one call, while a top-down scene that alternates many textures by y gains little. Runtime Snapshots are unchanged — batches are not entities, and sprites report the same state either way.
|
|
166
|
+
|
|
153
167
|
## Component lifecycle
|
|
154
168
|
|
|
155
169
|
Waica keeps the lifecycle boundaries distinct:
|
|
@@ -5,7 +5,10 @@ export function validCollisionLayer(value) {
|
|
|
5
5
|
}
|
|
6
6
|
/** Package-internal exact directional interest check. */
|
|
7
7
|
export function collisionMaskTargets(mask, targetLayer) {
|
|
8
|
-
if (!
|
|
8
|
+
if (!Array.isArray(mask))
|
|
9
9
|
return false;
|
|
10
|
-
|
|
10
|
+
// Membership first: with explicit masks it rejects most pairs of a dense
|
|
11
|
+
// scene before the layer's syntax check runs. A '*' mask still checks it.
|
|
12
|
+
const named = mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
|
|
13
|
+
return named && validCollisionLayer(targetLayer);
|
|
11
14
|
}
|
|
@@ -4,5 +4,11 @@ export interface CollisionDispatchStats {
|
|
|
4
4
|
readonly candidatePairs: number;
|
|
5
5
|
readonly narrowphaseCalls: number;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Game's package-internal trigger dispatch implementation. Pairs come from a
|
|
9
|
+
* frozen grid; each pair is then checked live, most selective checks first
|
|
10
|
+
* (liveness, masks, then the snapshotted Hitbox still mounted). The checks
|
|
11
|
+
* have no side effects, so their order only changes how fast a pair is
|
|
12
|
+
* rejected.
|
|
13
|
+
*/
|
|
8
14
|
export declare function dispatchCollisions(game: Game): CollisionDispatchStats;
|
|
@@ -3,34 +3,41 @@ import { collisionMaskTargets } from './collision-category.js';
|
|
|
3
3
|
import { collisionOverlap } from './collision-shape.js';
|
|
4
4
|
import { Hitbox } from './components/hitbox.js';
|
|
5
5
|
import { createHitboxBroadphase } from './spatial-broadphase.js';
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Game's package-internal trigger dispatch implementation. Pairs come from a
|
|
8
|
+
* frozen grid; each pair is then checked live, most selective checks first
|
|
9
|
+
* (liveness, masks, then the snapshotted Hitbox still mounted). The checks
|
|
10
|
+
* have no side effects, so their order only changes how fast a pair is
|
|
11
|
+
* rejected.
|
|
12
|
+
*/
|
|
7
13
|
export function dispatchCollisions(game) {
|
|
8
|
-
|
|
14
|
+
let candidatePairs = 0;
|
|
9
15
|
let narrowphaseCalls = 0;
|
|
10
|
-
|
|
16
|
+
createHitboxBroadphase(game).forEachPair((first, second) => {
|
|
17
|
+
candidatePairs += 1;
|
|
11
18
|
const a = first.entity;
|
|
12
19
|
const b = second.entity;
|
|
13
20
|
if (!a.alive || !b.alive)
|
|
14
|
-
|
|
15
|
-
if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
|
|
16
|
-
continue;
|
|
21
|
+
return;
|
|
17
22
|
const notifyA = collisionMaskTargets(first.hitbox.collidesWith, second.hitbox.layer);
|
|
18
23
|
const notifyB = collisionMaskTargets(second.hitbox.collidesWith, first.hitbox.layer);
|
|
19
24
|
if (!notifyA && !notifyB)
|
|
20
|
-
|
|
25
|
+
return;
|
|
26
|
+
if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
|
|
27
|
+
return;
|
|
21
28
|
narrowphaseCalls += 1;
|
|
22
29
|
if (!collisionOverlap(collisionBody(first.hitbox), collisionBody(second.hitbox)))
|
|
23
|
-
|
|
30
|
+
return;
|
|
24
31
|
if (notifyA) {
|
|
25
32
|
for (const component of [...a.components])
|
|
26
33
|
component.onCollide?.(b);
|
|
27
34
|
}
|
|
28
35
|
if (!a.alive || !b.alive)
|
|
29
|
-
|
|
36
|
+
return;
|
|
30
37
|
if (notifyB) {
|
|
31
38
|
for (const component of [...b.components])
|
|
32
39
|
component.onCollide?.(a);
|
|
33
40
|
}
|
|
34
|
-
}
|
|
35
|
-
return { candidatePairs
|
|
41
|
+
});
|
|
42
|
+
return { candidatePairs, narrowphaseCalls };
|
|
36
43
|
}
|
|
@@ -113,6 +113,8 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
|
|
|
113
113
|
* touches game.assets, so it counts nowhere and warns about nothing.
|
|
114
114
|
*/
|
|
115
115
|
private sheetTexture;
|
|
116
|
+
/** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
|
|
117
|
+
private sheetKey;
|
|
116
118
|
private showFrame;
|
|
117
119
|
/** Repositions/rescales the displayed frame inside its anchored full-size box. */
|
|
118
120
|
private syncQuad;
|
|
@@ -4,6 +4,9 @@ import { ClipPlayer } from '../animation/clip-player.js';
|
|
|
4
4
|
import { locateFrame, sheetCell } from '../animation/sheet.js';
|
|
5
5
|
import { spritePlacement } from '../sprite-placement.js';
|
|
6
6
|
import { reportRejection } from '../report-rejection.js';
|
|
7
|
+
import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
|
|
8
|
+
/** Where a frame on a failed sheet draws: the flat (white) untextured quad. */
|
|
9
|
+
const UNTEXTURED = { texture: null, pixelArt: false, shape: 'rectangle' };
|
|
7
10
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
8
11
|
/**
|
|
9
12
|
* Sprite animated from one or more spritesheets. The main sheet is the
|
|
@@ -139,6 +142,8 @@ export class AnimatedSprite extends Component {
|
|
|
139
142
|
texs = [];
|
|
140
143
|
// Clones whose sheet failed to load: applyFrame never installs them (CA-4).
|
|
141
144
|
failedSheets = new Set();
|
|
145
|
+
// The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
|
|
146
|
+
// this sprite's instance, whose frame UV is read from the sheet clone.
|
|
142
147
|
mesh;
|
|
143
148
|
frame = 0;
|
|
144
149
|
// Current frame's quad scale relative to the sheet's largest cell: always
|
|
@@ -169,8 +174,17 @@ export class AnimatedSprite extends Component {
|
|
|
169
174
|
tex.repeat.set(1 / Math.max(1, sheet.cols), 1 / Math.max(1, sheet.rows));
|
|
170
175
|
return tex;
|
|
171
176
|
});
|
|
172
|
-
const
|
|
173
|
-
|
|
177
|
+
const batches = spriteBatchesOf(this.game);
|
|
178
|
+
if (batches?.enabled) {
|
|
179
|
+
const instance = batches.attach(this.sheetKey(0));
|
|
180
|
+
instance.shownSheet = 0;
|
|
181
|
+
instance.uvSource = this.texs[0] ?? instance.uvSource;
|
|
182
|
+
this.mesh = instance.anchor;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
|
|
186
|
+
this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
|
|
187
|
+
}
|
|
174
188
|
this.mesh.position.z = this.layer * 0.01;
|
|
175
189
|
this.syncQuad();
|
|
176
190
|
this.entity.node.add(this.mesh);
|
|
@@ -193,9 +207,16 @@ export class AnimatedSprite extends Component {
|
|
|
193
207
|
this.showFrame(this.player.advance(dt));
|
|
194
208
|
}
|
|
195
209
|
onDestroy() {
|
|
196
|
-
this.mesh
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
211
|
+
if (instance) {
|
|
212
|
+
instance.release();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
this.mesh?.removeFromParent();
|
|
216
|
+
this.mesh?.geometry.dispose();
|
|
217
|
+
this.mesh?.material.dispose();
|
|
218
|
+
}
|
|
219
|
+
this.mesh = undefined;
|
|
199
220
|
// Only this sprite's clones: the cached bases live with the Game.
|
|
200
221
|
for (const tex of this.texs)
|
|
201
222
|
tex.dispose();
|
|
@@ -225,6 +246,10 @@ export class AnimatedSprite extends Component {
|
|
|
225
246
|
}), 'sheet texture settle');
|
|
226
247
|
return texture;
|
|
227
248
|
}
|
|
249
|
+
/** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
|
|
250
|
+
sheetKey(index) {
|
|
251
|
+
return { texture: this.sheets[index]?.texture ?? '', pixelArt: this.pixelArt, shape: 'rectangle' };
|
|
252
|
+
}
|
|
228
253
|
showFrame(index) {
|
|
229
254
|
this.frame = index;
|
|
230
255
|
this.applyFrame();
|
|
@@ -254,7 +279,17 @@ export class AnimatedSprite extends Component {
|
|
|
254
279
|
const tex = this.texs[located.sheet];
|
|
255
280
|
if (!sheet || !tex)
|
|
256
281
|
return;
|
|
257
|
-
|
|
282
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
283
|
+
if (instance) {
|
|
284
|
+
const failed = this.failedSheets.has(tex);
|
|
285
|
+
if (instance.shownSheet !== located.sheet || instance.shownSheetFailed !== failed) {
|
|
286
|
+
instance.moveTo(failed ? UNTEXTURED : this.sheetKey(located.sheet));
|
|
287
|
+
instance.shownSheet = located.sheet;
|
|
288
|
+
instance.shownSheetFailed = failed;
|
|
289
|
+
}
|
|
290
|
+
instance.uvSource = tex;
|
|
291
|
+
}
|
|
292
|
+
else if (this.mesh) {
|
|
258
293
|
const map = this.failedSheets.has(tex) ? null : tex;
|
|
259
294
|
if (this.mesh.material.map !== map) {
|
|
260
295
|
this.mesh.material.map = map;
|
|
@@ -68,6 +68,12 @@ export declare class Sprite extends Component implements YSortParticipant {
|
|
|
68
68
|
set shape(value: SpriteShape);
|
|
69
69
|
private mesh?;
|
|
70
70
|
onReady(): void;
|
|
71
|
+
private buildMesh;
|
|
72
|
+
/**
|
|
73
|
+
* One instance in the batch of this sprite's key, tinted like the material
|
|
74
|
+
* the per-sprite path would build: white over a texture, `color` without.
|
|
75
|
+
*/
|
|
76
|
+
private joinBatch;
|
|
71
77
|
onDestroy(): void;
|
|
72
78
|
/**
|
|
73
79
|
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|
|
2
2
|
import { Component } from '../component.js';
|
|
3
3
|
import { spritePlacement } from '../sprite-placement.js';
|
|
4
4
|
import { reportRejection } from '../report-rejection.js';
|
|
5
|
+
import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
|
|
5
6
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
6
7
|
/**
|
|
7
8
|
* Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
|
|
@@ -41,7 +42,11 @@ export class Sprite extends Component {
|
|
|
41
42
|
}
|
|
42
43
|
set color(value) {
|
|
43
44
|
this._color = value;
|
|
44
|
-
this.mesh
|
|
45
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
46
|
+
if (instance)
|
|
47
|
+
instance.color.setHex(value);
|
|
48
|
+
else
|
|
49
|
+
this.mesh?.material.color.setHex(value);
|
|
45
50
|
}
|
|
46
51
|
_offsetX = 0;
|
|
47
52
|
_offsetY = 0;
|
|
@@ -102,11 +107,30 @@ export class Sprite extends Component {
|
|
|
102
107
|
this._shape = value === 'circle' ? 'circle' : 'rectangle';
|
|
103
108
|
if (!this.mesh)
|
|
104
109
|
return;
|
|
110
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
111
|
+
if (instance) {
|
|
112
|
+
instance.moveTo({ ...instance.key, shape: this._shape });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
105
115
|
this.mesh.geometry.dispose();
|
|
106
116
|
this.mesh.geometry = this.createGeometry();
|
|
107
117
|
}
|
|
118
|
+
// The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
|
|
119
|
+
// this sprite's instance: the same placement either way.
|
|
108
120
|
mesh;
|
|
109
121
|
onReady() {
|
|
122
|
+
const batches = spriteBatchesOf(this.game);
|
|
123
|
+
if (batches?.enabled)
|
|
124
|
+
this.joinBatch(batches);
|
|
125
|
+
else
|
|
126
|
+
this.buildMesh();
|
|
127
|
+
if (!this.mesh)
|
|
128
|
+
return;
|
|
129
|
+
this.mesh.position.z = this.layer * 0.01;
|
|
130
|
+
this.syncQuad();
|
|
131
|
+
this.entity.node.add(this.mesh);
|
|
132
|
+
}
|
|
133
|
+
buildMesh() {
|
|
110
134
|
const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
|
|
111
135
|
if (this.texture) {
|
|
112
136
|
// Its own clone of the cached base (game.assets, ADR 0019): filters
|
|
@@ -125,11 +149,35 @@ export class Sprite extends Component {
|
|
|
125
149
|
}), 'sprite texture settle');
|
|
126
150
|
}
|
|
127
151
|
this.mesh = new THREE.Mesh(this.createGeometry(), material);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One instance in the batch of this sprite's key, tinted like the material
|
|
155
|
+
* the per-sprite path would build: white over a texture, `color` without.
|
|
156
|
+
*/
|
|
157
|
+
joinBatch(batches) {
|
|
158
|
+
const key = { texture: this.texture || null, pixelArt: this.pixelArt, shape: this._shape };
|
|
159
|
+
const instance = batches.attach(key);
|
|
160
|
+
instance.color.setHex(this.texture ? 0xffffff : this.color);
|
|
161
|
+
this.mesh = instance.anchor;
|
|
162
|
+
const settled = batches.settled(key);
|
|
163
|
+
if (!settled)
|
|
164
|
+
return;
|
|
165
|
+
reportRejection(settled.then((outcome) => {
|
|
166
|
+
// CA-4's failure rule, copy-on-write: only this sprite leaves the
|
|
167
|
+
// shared entry, for the untextured one of its shape, with its color.
|
|
168
|
+
if (outcome !== 'failed' || spriteInstanceOf(this.mesh) !== instance)
|
|
169
|
+
return;
|
|
170
|
+
instance.moveTo({ ...instance.key, texture: null });
|
|
171
|
+
instance.color.setHex(this.color);
|
|
172
|
+
}), 'sprite texture settle');
|
|
131
173
|
}
|
|
132
174
|
onDestroy() {
|
|
175
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
176
|
+
if (instance) {
|
|
177
|
+
instance.release();
|
|
178
|
+
this.mesh = undefined;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
133
181
|
this.mesh?.removeFromParent();
|
|
134
182
|
this.mesh?.geometry.dispose();
|
|
135
183
|
// Only this sprite's clone: the cached base lives with the Game.
|
package/dist/game.d.ts
CHANGED
|
@@ -29,8 +29,10 @@ export interface GameOptions {
|
|
|
29
29
|
viewHeight?: number;
|
|
30
30
|
/** Fixed resolution (from the project's game.json); absent = fill the canvas. */
|
|
31
31
|
resolution?: GameResolution;
|
|
32
|
-
/** Control overrides (action → key codes) on top of the defaults. */
|
|
32
|
+
/** Control overrides (action → key and `Gamepad:` codes) on top of the defaults. */
|
|
33
33
|
bindings?: InputBindings;
|
|
34
|
+
/** Radial stick dead zone of `game.input`, in [0, 1); absent or invalid means 0.2 (ADR 0023). */
|
|
35
|
+
gamepadDeadZone?: number;
|
|
34
36
|
/** Initial stat values (points, lives…) from the project's stats.json. */
|
|
35
37
|
stats?: Record<string, StatValue>;
|
|
36
38
|
/**
|
|
@@ -101,6 +103,8 @@ export declare class Game {
|
|
|
101
103
|
simulate: boolean;
|
|
102
104
|
private readonly renderer;
|
|
103
105
|
private readonly resizeObserver;
|
|
106
|
+
/** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
|
|
107
|
+
private readonly spriteBatches;
|
|
104
108
|
private readonly updateFns;
|
|
105
109
|
private readonly invalidUpdateCompositions;
|
|
106
110
|
/**
|
|
@@ -233,8 +237,8 @@ export declare class Game {
|
|
|
233
237
|
*/
|
|
234
238
|
private runFrame;
|
|
235
239
|
/**
|
|
236
|
-
* One Simulation Step:
|
|
237
|
-
*
|
|
240
|
+
* One Simulation Step: the pad is read first (issue #75 CA-6), then
|
|
241
|
+
* game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
|
|
238
242
|
* collisions, the scene camera and the host's callbacks, every one of
|
|
239
243
|
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
240
244
|
*/
|
package/dist/game.js
CHANGED
|
@@ -18,6 +18,7 @@ import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
|
18
18
|
import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
|
|
19
19
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
20
20
|
import { sceneDrainsOf } from './scene-drains.js';
|
|
21
|
+
import { createSpriteBatches } from './sprite-batches.js';
|
|
21
22
|
import { createSpatialQuery } from './spatial-query.js';
|
|
22
23
|
import { Stats } from './stats.js';
|
|
23
24
|
import { anchoredPiecesOf, GameUi } from './ui.js';
|
|
@@ -72,6 +73,8 @@ export class Game {
|
|
|
72
73
|
// TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
|
|
73
74
|
renderer;
|
|
74
75
|
resizeObserver;
|
|
76
|
+
/** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
|
|
77
|
+
spriteBatches;
|
|
75
78
|
updateFns = new Set();
|
|
76
79
|
invalidUpdateCompositions = new WeakMap();
|
|
77
80
|
/**
|
|
@@ -109,7 +112,7 @@ export class Game {
|
|
|
109
112
|
this.baseViewHeight = viewHeight;
|
|
110
113
|
this.viewHeight = viewHeight;
|
|
111
114
|
this.resolution = options.resolution ?? null;
|
|
112
|
-
this.input = new Input(options.bindings);
|
|
115
|
+
this.input = new Input(options.bindings, { gamepadDeadZone: options.gamepadDeadZone });
|
|
113
116
|
this.query = createSpatialQuery(this);
|
|
114
117
|
this.stats = new Stats(options.stats);
|
|
115
118
|
this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
|
|
@@ -133,6 +136,7 @@ export class Game {
|
|
|
133
136
|
// catalog is registered at call time, which outlives every scene.
|
|
134
137
|
resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
|
|
135
138
|
});
|
|
139
|
+
this.spriteBatches = createSpriteBatches(this, this.assets);
|
|
136
140
|
this.cameraEffects = new CameraEffects({
|
|
137
141
|
host: () => canvas.parentElement ?? document.body,
|
|
138
142
|
// One screen pixel in world units: the shake snaps to it (never the base).
|
|
@@ -205,6 +209,8 @@ export class Game {
|
|
|
205
209
|
entity.destroy();
|
|
206
210
|
// Drains transferred by the destruction cascade above are scene-scoped too.
|
|
207
211
|
sceneDrainsOf(this).clear();
|
|
212
|
+
// After the cascade freed every slot: the batches go with their scene.
|
|
213
|
+
this.spriteBatches.unload();
|
|
208
214
|
this.registry = null;
|
|
209
215
|
this.renderSort = null;
|
|
210
216
|
this.sceneProjection = null;
|
|
@@ -299,6 +305,7 @@ export class Game {
|
|
|
299
305
|
*/
|
|
300
306
|
setSceneRender(json) {
|
|
301
307
|
this.renderSort = json?.sort === 'y' ? 'y' : null;
|
|
308
|
+
this.spriteBatches.enabled = json?.batch !== false;
|
|
302
309
|
const projection = json?.projection === 'isometric' ? 'isometric' : null;
|
|
303
310
|
if (projection === this.sceneProjection)
|
|
304
311
|
return;
|
|
@@ -339,9 +346,10 @@ export class Game {
|
|
|
339
346
|
step: (onStep) => this.runFrame(1, onStep),
|
|
340
347
|
resume: (onStep) => this.resumeRuntime(onStep),
|
|
341
348
|
pause: () => this.stop(),
|
|
342
|
-
injectAction: (action, operation) => this.input.injectAction(action, operation),
|
|
349
|
+
injectAction: (action, operation, value) => this.input.injectAction(action, operation, value),
|
|
343
350
|
availableActions: () => this.input.availableActions(),
|
|
344
351
|
heldActions: () => this.input.heldActions(),
|
|
352
|
+
actionValues: () => this.input.actionValues(),
|
|
345
353
|
inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
|
|
346
354
|
click: (x, y) => {
|
|
347
355
|
this.pointer.injectClick(x, y);
|
|
@@ -398,6 +406,7 @@ export class Game {
|
|
|
398
406
|
for (const entity of [...this.entities])
|
|
399
407
|
entity.destroy();
|
|
400
408
|
sceneDrainsOf(this).clear();
|
|
409
|
+
this.spriteBatches.unload();
|
|
401
410
|
// After the entities: their clones go with the cascade above, the
|
|
402
411
|
// cached bases go here, exactly once (ADR 0019).
|
|
403
412
|
this.assets.dispose();
|
|
@@ -489,12 +498,13 @@ export class Game {
|
|
|
489
498
|
}
|
|
490
499
|
}
|
|
491
500
|
/**
|
|
492
|
-
* One Simulation Step:
|
|
493
|
-
*
|
|
501
|
+
* One Simulation Step: the pad is read first (issue #75 CA-6), then
|
|
502
|
+
* game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
|
|
494
503
|
* collisions, the scene camera and the host's callbacks, every one of
|
|
495
504
|
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
496
505
|
*/
|
|
497
506
|
simulateStep() {
|
|
507
|
+
this.input.pollGamepad();
|
|
498
508
|
advanceGameTime(this.time);
|
|
499
509
|
// Existing drains advance before entity updates, so one detached during
|
|
500
510
|
// this step begins on the next Simulation Step (ADR 0022).
|
|
@@ -600,7 +610,7 @@ export class Game {
|
|
|
600
610
|
this.renderer.clear(true, false, false);
|
|
601
611
|
this.renderer.setScissorTest(true);
|
|
602
612
|
}
|
|
603
|
-
this.renderer.render(this.scene, this.camera);
|
|
613
|
+
this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
|
|
604
614
|
}
|
|
605
615
|
finally {
|
|
606
616
|
this.camera.position.x = x;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad sources for Actions (ADR 0023): the `Gamepad:<control>` binding
|
|
3
|
+
* vocabulary over the W3C `standard` mapping, the radial dead zone, and the
|
|
4
|
+
* player-1 pad slot. `Input` consumes this module; behaviors never see a pad.
|
|
5
|
+
*/
|
|
6
|
+
/** The slice of the W3C `Gamepad` object Waica reads, so tests can hand in plain objects. */
|
|
7
|
+
export interface GamepadLike {
|
|
8
|
+
readonly index: number;
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly mapping: string;
|
|
11
|
+
readonly connected: boolean;
|
|
12
|
+
readonly axes: readonly number[];
|
|
13
|
+
readonly buttons: readonly {
|
|
14
|
+
readonly value: number;
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
/** One pad control a binding code names: a button by index, or one half of a stick axis. */
|
|
18
|
+
export type GamepadControl = {
|
|
19
|
+
kind: 'button';
|
|
20
|
+
index: number;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'stick';
|
|
23
|
+
axis: number;
|
|
24
|
+
sign: 1 | -1;
|
|
25
|
+
};
|
|
26
|
+
export declare const GAMEPAD_CODE_PREFIX = "Gamepad:";
|
|
27
|
+
/** An Action's source counts as held at this value or above (keys are always 1 while down). */
|
|
28
|
+
export declare const ACTION_HELD_THRESHOLD = 0.5;
|
|
29
|
+
/** Radial dead zone used when `GameOptions.gamepadDeadZone` is absent or invalid. */
|
|
30
|
+
export declare const DEFAULT_GAMEPAD_DEAD_ZONE = 0.2;
|
|
31
|
+
/** Every known `Gamepad:` code: buttons in index order, then the stick halves. */
|
|
32
|
+
export declare const GAMEPAD_CODES: readonly string[];
|
|
33
|
+
/** The control a binding code names; undefined for a keyboard code or an unknown pad name. */
|
|
34
|
+
export declare function gamepadControl(code: string): GamepadControl | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Radial dead zone: a stick shorter than `deadZone` reads as centered on
|
|
37
|
+
* both axes; past it the direction is kept and the magnitude is rescaled
|
|
38
|
+
* to `(m − d) / (1 − d)`, clamped to 1.
|
|
39
|
+
*/
|
|
40
|
+
export declare function radialDeadZone(x: number, y: number, deadZone: number): {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
};
|
|
44
|
+
/** The configured dead zone if it is a finite number in [0, 1); otherwise warns once and uses the default. */
|
|
45
|
+
export declare function resolveGamepadDeadZone(value: number | undefined, warn: (message: string) => void): number;
|
|
46
|
+
/** Each known code's 0..1 value on this pad: buttons by `value`, stick halves dead-zoned per stick. */
|
|
47
|
+
export declare function gamepadValues(pad: GamepadLike, deadZone: number): Map<string, number>;
|
|
48
|
+
/** The browser's pads, or none where the Gamepad API is missing or refused (Node, happy-dom, a policy-blocked frame). */
|
|
49
|
+
export declare function connectedGamepads(): readonly (GamepadLike | null)[];
|
|
50
|
+
/** One poll of the player-1 slot: its pad, and whether the poll lost the pad that was active before it. */
|
|
51
|
+
export interface GamepadSlotPoll {
|
|
52
|
+
pad: GamepadLike | null;
|
|
53
|
+
lostActive: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* @internal Editor support (the controls panel's pad capture), not a stable
|
|
57
|
+
* API: each known code's value on the first connected standard pad at the
|
|
58
|
+
* default dead zone, or null when no such pad is present.
|
|
59
|
+
*/
|
|
60
|
+
export declare function firstStandardPadValues(): Map<string, number> | null;
|
|
61
|
+
/**
|
|
62
|
+
* Player 1's pad: the first `standard` pad to connect, sticky until it
|
|
63
|
+
* disconnects; then the next one in connection order takes over. Order is
|
|
64
|
+
* the order polls first saw each pad (by index on the same poll).
|
|
65
|
+
*/
|
|
66
|
+
export declare class GamepadSlot {
|
|
67
|
+
private order;
|
|
68
|
+
private activeKey;
|
|
69
|
+
poll(pads: readonly (GamepadLike | null)[]): GamepadSlotPoll;
|
|
70
|
+
}
|
|
71
|
+
/** A connected pad with the W3C `standard` mapping; any other pad is ignored entirely. */
|
|
72
|
+
export declare function isStandardPad(pad: GamepadLike | null | undefined): pad is GamepadLike;
|
package/dist/gamepad.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad sources for Actions (ADR 0023): the `Gamepad:<control>` binding
|
|
3
|
+
* vocabulary over the W3C `standard` mapping, the radial dead zone, and the
|
|
4
|
+
* player-1 pad slot. `Input` consumes this module; behaviors never see a pad.
|
|
5
|
+
*/
|
|
6
|
+
export const GAMEPAD_CODE_PREFIX = 'Gamepad:';
|
|
7
|
+
/** An Action's source counts as held at this value or above (keys are always 1 while down). */
|
|
8
|
+
export const ACTION_HELD_THRESHOLD = 0.5;
|
|
9
|
+
/** Radial dead zone used when `GameOptions.gamepadDeadZone` is absent or invalid. */
|
|
10
|
+
export const DEFAULT_GAMEPAD_DEAD_ZONE = 0.2;
|
|
11
|
+
/** Standard-mapping buttons in W3C index order (0–16). */
|
|
12
|
+
const BUTTON_NAMES = [
|
|
13
|
+
'A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LeftStick', 'RightStick',
|
|
14
|
+
'DPadUp', 'DPadDown', 'DPadLeft', 'DPadRight', 'Home',
|
|
15
|
+
];
|
|
16
|
+
/** Stick halves: axes 0/1 are the left stick, 2/3 the right; "Up" is the negative Y axis. */
|
|
17
|
+
const STICK_HALVES = {
|
|
18
|
+
LeftStickLeft: { axis: 0, sign: -1 },
|
|
19
|
+
LeftStickRight: { axis: 0, sign: 1 },
|
|
20
|
+
LeftStickUp: { axis: 1, sign: -1 },
|
|
21
|
+
LeftStickDown: { axis: 1, sign: 1 },
|
|
22
|
+
RightStickLeft: { axis: 2, sign: -1 },
|
|
23
|
+
RightStickRight: { axis: 2, sign: 1 },
|
|
24
|
+
RightStickUp: { axis: 3, sign: -1 },
|
|
25
|
+
RightStickDown: { axis: 3, sign: 1 },
|
|
26
|
+
};
|
|
27
|
+
const CONTROLS = new Map([
|
|
28
|
+
...BUTTON_NAMES.map((name, index) => [
|
|
29
|
+
`${GAMEPAD_CODE_PREFIX}${name}`,
|
|
30
|
+
{ kind: 'button', index },
|
|
31
|
+
]),
|
|
32
|
+
...Object.entries(STICK_HALVES).map(([name, half]) => [
|
|
33
|
+
`${GAMEPAD_CODE_PREFIX}${name}`,
|
|
34
|
+
{ kind: 'stick', ...half },
|
|
35
|
+
]),
|
|
36
|
+
]);
|
|
37
|
+
/** Every known `Gamepad:` code: buttons in index order, then the stick halves. */
|
|
38
|
+
export const GAMEPAD_CODES = [...CONTROLS.keys()];
|
|
39
|
+
/** The control a binding code names; undefined for a keyboard code or an unknown pad name. */
|
|
40
|
+
export function gamepadControl(code) {
|
|
41
|
+
const control = CONTROLS.get(code);
|
|
42
|
+
return control ? { ...control } : undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Radial dead zone: a stick shorter than `deadZone` reads as centered on
|
|
46
|
+
* both axes; past it the direction is kept and the magnitude is rescaled
|
|
47
|
+
* to `(m − d) / (1 − d)`, clamped to 1.
|
|
48
|
+
*/
|
|
49
|
+
export function radialDeadZone(x, y, deadZone) {
|
|
50
|
+
const magnitude = Math.hypot(x, y);
|
|
51
|
+
if (magnitude === 0 || magnitude < deadZone)
|
|
52
|
+
return { x: 0, y: 0 };
|
|
53
|
+
const scaled = Math.min(1, (magnitude - deadZone) / (1 - deadZone));
|
|
54
|
+
return { x: (x / magnitude) * scaled, y: (y / magnitude) * scaled };
|
|
55
|
+
}
|
|
56
|
+
/** The configured dead zone if it is a finite number in [0, 1); otherwise warns once and uses the default. */
|
|
57
|
+
export function resolveGamepadDeadZone(value, warn) {
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
return DEFAULT_GAMEPAD_DEAD_ZONE;
|
|
60
|
+
if (Number.isFinite(value) && value >= 0 && value < 1)
|
|
61
|
+
return value;
|
|
62
|
+
warn(`[waica] gamepadDeadZone must be a finite number from 0 up to (not including) 1; ` +
|
|
63
|
+
`got ${String(value)}, using ${DEFAULT_GAMEPAD_DEAD_ZONE}.`);
|
|
64
|
+
return DEFAULT_GAMEPAD_DEAD_ZONE;
|
|
65
|
+
}
|
|
66
|
+
/** Each known code's 0..1 value on this pad: buttons by `value`, stick halves dead-zoned per stick. */
|
|
67
|
+
export function gamepadValues(pad, deadZone) {
|
|
68
|
+
const axis = (index) => pad.axes[index] ?? 0;
|
|
69
|
+
const sticks = [
|
|
70
|
+
radialDeadZone(axis(0), axis(1), deadZone),
|
|
71
|
+
radialDeadZone(axis(2), axis(3), deadZone),
|
|
72
|
+
];
|
|
73
|
+
const values = new Map();
|
|
74
|
+
for (const [code, control] of CONTROLS) {
|
|
75
|
+
if (control.kind === 'button') {
|
|
76
|
+
values.set(code, pad.buttons[control.index]?.value ?? 0);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const stick = sticks[control.axis >> 1] ?? { x: 0, y: 0 };
|
|
80
|
+
const component = control.axis % 2 === 0 ? stick.x : stick.y;
|
|
81
|
+
values.set(code, Math.max(0, component * control.sign));
|
|
82
|
+
}
|
|
83
|
+
return values;
|
|
84
|
+
}
|
|
85
|
+
/** The browser's pads, or none where the Gamepad API is missing or refused (Node, happy-dom, a policy-blocked frame). */
|
|
86
|
+
export function connectedGamepads() {
|
|
87
|
+
if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function')
|
|
88
|
+
return [];
|
|
89
|
+
try {
|
|
90
|
+
return navigator.getGamepads();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A permissions policy can make getGamepads throw; input stays keyboard-only.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* @internal Editor support (the controls panel's pad capture), not a stable
|
|
99
|
+
* API: each known code's value on the first connected standard pad at the
|
|
100
|
+
* default dead zone, or null when no such pad is present.
|
|
101
|
+
*/
|
|
102
|
+
export function firstStandardPadValues() {
|
|
103
|
+
const pad = connectedGamepads().find(isStandardPad);
|
|
104
|
+
return pad ? gamepadValues(pad, DEFAULT_GAMEPAD_DEAD_ZONE) : null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Player 1's pad: the first `standard` pad to connect, sticky until it
|
|
108
|
+
* disconnects; then the next one in connection order takes over. Order is
|
|
109
|
+
* the order polls first saw each pad (by index on the same poll).
|
|
110
|
+
*/
|
|
111
|
+
export class GamepadSlot {
|
|
112
|
+
order = [];
|
|
113
|
+
activeKey = null;
|
|
114
|
+
poll(pads) {
|
|
115
|
+
const present = new Map();
|
|
116
|
+
for (const pad of pads)
|
|
117
|
+
if (isStandardPad(pad))
|
|
118
|
+
present.set(slotKey(pad), pad);
|
|
119
|
+
this.order = this.order.filter((key) => present.has(key));
|
|
120
|
+
for (const key of present.keys())
|
|
121
|
+
if (!this.order.includes(key))
|
|
122
|
+
this.order.push(key);
|
|
123
|
+
const previous = this.activeKey;
|
|
124
|
+
this.activeKey = this.order[0] ?? null;
|
|
125
|
+
return {
|
|
126
|
+
pad: this.activeKey === null ? null : (present.get(this.activeKey) ?? null),
|
|
127
|
+
lostActive: previous !== null && previous !== this.activeKey,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** A connected pad with the W3C `standard` mapping; any other pad is ignored entirely. */
|
|
132
|
+
export function isStandardPad(pad) {
|
|
133
|
+
return pad?.connected === true && pad.mapping === 'standard';
|
|
134
|
+
}
|
|
135
|
+
function slotKey(pad) {
|
|
136
|
+
return `${pad.index}|${pad.id}`;
|
|
137
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,7 +31,9 @@ export type { ComponentModule } from './component-registry.js';
|
|
|
31
31
|
export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
32
32
|
export type { ComponentUpdateCycleIssue, ComponentUpdateRegistry, ComponentUpdateScheduleIssue, ComponentUpdateScheduleResult, DuplicateComponentUpdateIssue, InvalidComponentUpdateConstraintIssue, InvalidComponentUpdateSchedule, InvalidUpdateConstraintReason, ValidComponentUpdateSchedule, } from './component-update-schedule.js';
|
|
33
33
|
export { Input, DEFAULT_BINDINGS } from './input.js';
|
|
34
|
-
export type { ActionName, InjectedActionOperation, InputBindings } from './input.js';
|
|
34
|
+
export type { ActionName, InjectedActionOperation, InputBindings, InputOptions } from './input.js';
|
|
35
|
+
export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
|
|
36
|
+
export type { GamepadControl } from './gamepad.js';
|
|
35
37
|
export { Pointer } from './pointer.js';
|
|
36
38
|
export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
|
|
37
39
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|