@waica/engine 0.16.0 → 0.18.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.
@@ -0,0 +1,138 @@
1
+ import * as THREE from 'three';
2
+ import { ThreeTextureBackend } from './texture-backend.js';
3
+ /**
4
+ * Not `base.clone()`: `Texture.copy` sets `needsUpdate`, which bumps the
5
+ * shared Source's version — a GPU re-upload of the same image for every
6
+ * clone — and, while the image is still missing, makes the renderer warn
7
+ * on every frame. Sharing the Source and mirroring `version` instead marks
8
+ * the clone uploadable exactly when the base is, and three then keeps one
9
+ * GPU texture per (Source, sampler parameters), reference-counted across
10
+ * clones (WebGLTextures). Colour space is the base's; filters, repeat and
11
+ * offset are the clone's own.
12
+ */
13
+ function cloneOf(base) {
14
+ const clone = new THREE.Texture();
15
+ clone.source = base.source;
16
+ clone.colorSpace = base.colorSpace;
17
+ clone.version = base.version;
18
+ return clone;
19
+ }
20
+ /**
21
+ * The engine's texture cache (`game.assets`, ADR 0019): keep-all for the
22
+ * Game's whole life, keyed by the URL as received, one backend load and one
23
+ * base texture per URL. `ready()` is the **Assets Ready** signal — a
24
+ * promise beside the synchronous scene load — and a failed image is
25
+ * recorded, not thrown, so it never breaks a host or a Run Session that
26
+ * awaits it. `unloadScene()` never touches this cache; `dispose()` empties it.
27
+ */
28
+ export class AssetLoader {
29
+ backend;
30
+ resolveAsset;
31
+ entries = new Map();
32
+ pendingCount = 0;
33
+ loadedCount = 0;
34
+ failedCount = 0;
35
+ constructor(options = {}) {
36
+ this.backend = options.backend ?? new ThreeTextureBackend();
37
+ this.resolveAsset = options.resolveAsset ?? ((uri) => uri);
38
+ }
39
+ /** A fresh object on every read: `pending` is current, `loaded` and `failed` only grow until `dispose()`. */
40
+ get status() {
41
+ return { pending: this.pendingCount, loaded: this.loadedCount, failed: this.failedCount };
42
+ }
43
+ /**
44
+ * The consumer entry (Sprite, AnimatedSprite, Tilemap): requests `url`
45
+ * — already resolved by `resolveProps` — and returns a clone of its base
46
+ * right away, so a mesh exists before the image arrives, plus the
47
+ * settlement of that URL, which also fires on a cache hit.
48
+ */
49
+ texture(url) {
50
+ const entry = this.request(url);
51
+ const texture = cloneOf(entry.base);
52
+ if (entry.outcome === null) {
53
+ // Chained here, before the consumer chains its own continuation on
54
+ // `settled`, so the clone is uploadable by the time that one runs.
55
+ void entry.settled.then((outcome) => {
56
+ if (outcome === 'loaded')
57
+ texture.version = entry.base.version;
58
+ });
59
+ }
60
+ return { texture, settled: entry.settled };
61
+ }
62
+ /**
63
+ * Requests every uri ahead of time, each resolved through the registered
64
+ * scene catalog. Resolves once all of them settled, failures included —
65
+ * a failure still only warns once, the same as a component's request.
66
+ */
67
+ async preload(uris) {
68
+ await Promise.all(uris.map((uri) => this.request(this.resolveAsset(uri)).settled));
69
+ }
70
+ /**
71
+ * Assets Ready: resolves the first time `pending` is 0 after the call —
72
+ * at once when nothing is pending, otherwise once every URL requested
73
+ * before or while waiting has settled. Never rejects. Resolves after the
74
+ * `settled` continuations consumers chained before the call, so a host
75
+ * that does `await game.assets.ready()` then `game.start()` renders its
76
+ * first frame with the textures in place.
77
+ */
78
+ async ready() {
79
+ while (this.pendingCount > 0) {
80
+ const inFlight = [...this.entries.values()].filter((entry) => entry.outcome === null);
81
+ await Promise.all(inFlight.map((entry) => entry.settled));
82
+ }
83
+ }
84
+ /** Disposes every cached base exactly once and forgets everything; called by `Game.dispose()`. */
85
+ dispose() {
86
+ for (const entry of this.entries.values())
87
+ entry.base.dispose();
88
+ this.entries.clear();
89
+ this.pendingCount = 0;
90
+ this.loadedCount = 0;
91
+ this.failedCount = 0;
92
+ }
93
+ request(url) {
94
+ const existing = this.entries.get(url);
95
+ if (existing)
96
+ return existing;
97
+ const base = new THREE.Texture();
98
+ base.colorSpace = THREE.SRGBColorSpace;
99
+ const entry = { base, outcome: null, settled: Promise.resolve('failed') };
100
+ // Registered and counted before the backend runs, so a backend that
101
+ // throws synchronously still settles this very entry.
102
+ this.entries.set(url, entry);
103
+ this.pendingCount += 1;
104
+ entry.settled = this.load(url, entry);
105
+ return entry;
106
+ }
107
+ async load(url, entry) {
108
+ const attempt = await this.attempt(url);
109
+ // A dispose() in flight already forgot this entry and disposed its base:
110
+ // nothing to write, warn about or count. Only the settlement is still
111
+ // owed, so a consumer awaiting it never hangs; it reports what the
112
+ // backend did.
113
+ if (this.entries.get(url) !== entry)
114
+ return attempt.outcome;
115
+ if (attempt.outcome === 'loaded') {
116
+ // The image lands on the base's Source, which every clone shares.
117
+ entry.base.image = attempt.texture.image;
118
+ entry.base.needsUpdate = true;
119
+ this.loadedCount += 1;
120
+ }
121
+ else {
122
+ console.warn(`[waica] assets: failed to load "${url}"`, attempt.error);
123
+ this.failedCount += 1;
124
+ }
125
+ entry.outcome = attempt.outcome;
126
+ this.pendingCount -= 1;
127
+ return attempt.outcome;
128
+ }
129
+ /** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
130
+ async attempt(url) {
131
+ try {
132
+ return { outcome: 'loaded', texture: await this.backend.load(url) };
133
+ }
134
+ catch (error) {
135
+ return { outcome: 'failed', error };
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,17 @@
1
+ import * as THREE from 'three';
2
+ /**
3
+ * The seam between `game.assets` and the browser (ADR 0013's move, applied
4
+ * to textures — ADR 0019). `load` resolves once the image behind `url` is
5
+ * decoded, with a texture whose `image` the loader adopts into its own
6
+ * cached base; it rejects on a load error and never throws synchronously.
7
+ * `happy-dom` decodes no images, so a test injects `FakeTextureBackend`
8
+ * (assets/test-helpers.ts) through `GameOptions.textures`.
9
+ */
10
+ export interface TextureBackend {
11
+ load(url: string): Promise<THREE.Texture>;
12
+ }
13
+ /** The real implementation: one `THREE.TextureLoader` per Game, wrapped in a promise. */
14
+ export declare class ThreeTextureBackend implements TextureBackend {
15
+ private readonly loader;
16
+ load(url: string): Promise<THREE.Texture>;
17
+ }
@@ -0,0 +1,15 @@
1
+ import * as THREE from 'three';
2
+ /** The real implementation: one `THREE.TextureLoader` per Game, wrapped in a promise. */
3
+ export class ThreeTextureBackend {
4
+ loader = new THREE.TextureLoader();
5
+ load(url) {
6
+ return new Promise((resolve, reject) => {
7
+ try {
8
+ this.loader.load(url, resolve, undefined, reject);
9
+ }
10
+ catch (error) {
11
+ reject(error);
12
+ }
13
+ });
14
+ }
15
+ }
@@ -12,7 +12,7 @@ export interface ParamSpec {
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. */
15
- ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound';
15
+ ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound' | 'ui';
16
16
  }
17
17
  export interface ComponentClass<T extends Component = Component> {
18
18
  new (): T;
@@ -6,10 +6,11 @@ import type { YSortParticipant } from '../render-sort.js';
6
6
  * Sprite animated from one or more spritesheets. The main sheet is the
7
7
  * top-level texture/cols/rows (or explicit cells); extraSheets append after
8
8
  * it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
9
- * sheet 1 the next n1, …). Each instance clones its textures to animate UVs
10
- * independently. On sheets with explicit cells the frames vary in pixel size,
11
- * so the quad rescales per frame, anchored bottom-center — width/height size
12
- * the sheet's largest frame and smaller ones keep their feet planted.
9
+ * sheet 1 the next n1, …). Each instance owns its own clones of the cached
10
+ * sheets (game.assets, ADR 0019) to animate UVs independently. On sheets
11
+ * with explicit cells the frames vary in pixel size, so the quad rescales
12
+ * per frame, anchored bottom-center — width/height size the sheet's largest
13
+ * frame and smaller ones keep their feet planted.
13
14
  */
14
15
  export declare class AnimatedSprite extends Component implements YSortParticipant {
15
16
  static componentName: string;
@@ -91,6 +92,7 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
91
92
  private readonly player;
92
93
  private sheets;
93
94
  private texs;
95
+ private readonly failedSheets;
94
96
  private mesh?;
95
97
  private frame;
96
98
  private frameScaleX;
@@ -100,6 +102,17 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
100
102
  play(name: string): void;
101
103
  onUpdate(dt: number): void;
102
104
  onDestroy(): void;
105
+ /**
106
+ * The sheet's own clone of the cached base. Non-uniform slicing needs
107
+ * the image's pixel size, so the current frame is re-applied once the
108
+ * sheet settles — on a cache hit too, whose settlement is already
109
+ * resolved — unless this sprite was destroyed meanwhile. A sheet that
110
+ * fails is remembered so the quad shows its flat material on that
111
+ * sheet's frames instead of sampling an image that never arrived (CA-4).
112
+ * An empty url (a sheet not authored yet) owns a bare texture and never
113
+ * touches game.assets, so it counts nowhere and warns about nothing.
114
+ */
115
+ private sheetTexture;
103
116
  private showFrame;
104
117
  /** Repositions/rescales the displayed frame inside its anchored full-size box. */
105
118
  private syncQuad;
@@ -3,16 +3,16 @@ import { Component } from '../component.js';
3
3
  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
- const loader = new THREE.TextureLoader();
7
6
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
8
7
  /**
9
8
  * Sprite animated from one or more spritesheets. The main sheet is the
10
9
  * top-level texture/cols/rows (or explicit cells); extraSheets append after
11
10
  * it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
12
- * sheet 1 the next n1, …). Each instance clones its textures to animate UVs
13
- * independently. On sheets with explicit cells the frames vary in pixel size,
14
- * so the quad rescales per frame, anchored bottom-center — width/height size
15
- * the sheet's largest frame and smaller ones keep their feet planted.
11
+ * sheet 1 the next n1, …). Each instance owns its own clones of the cached
12
+ * sheets (game.assets, ADR 0019) to animate UVs independently. On sheets
13
+ * with explicit cells the frames vary in pixel size, so the quad rescales
14
+ * per frame, anchored bottom-center — width/height size the sheet's largest
15
+ * frame and smaller ones keep their feet planted.
16
16
  */
17
17
  export class AnimatedSprite extends Component {
18
18
  static componentName = 'AnimatedSprite';
@@ -30,6 +30,7 @@ export class AnimatedSprite extends Component {
30
30
  'player',
31
31
  'sheets',
32
32
  'texs',
33
+ 'failedSheets',
33
34
  'mesh',
34
35
  'frame',
35
36
  'frameScaleX',
@@ -135,6 +136,8 @@ export class AnimatedSprite extends Component {
135
136
  player = new ClipPlayer();
136
137
  sheets = [];
137
138
  texs = [];
139
+ // Clones whose sheet failed to load: applyFrame never installs them (CA-4).
140
+ failedSheets = new Set();
138
141
  mesh;
139
142
  frame = 0;
140
143
  // Current frame's quad scale relative to the sheet's largest cell: always
@@ -157,10 +160,7 @@ export class AnimatedSprite extends Component {
157
160
  main.cells = this.cells;
158
161
  this.sheets = [main, ...this.extraSheets];
159
162
  this.texs = this.sheets.map((sheet) => {
160
- // Non-uniform slicing needs the image's pixel size, so re-apply the
161
- // current frame once each texture is in.
162
- const tex = loader.load(sheet.texture, () => this.applyFrame());
163
- tex.colorSpace = THREE.SRGBColorSpace;
163
+ const tex = this.sheetTexture(sheet.texture);
164
164
  if (this.pixelArt) {
165
165
  tex.magFilter = THREE.NearestFilter;
166
166
  tex.minFilter = THREE.NearestFilter;
@@ -195,8 +195,34 @@ export class AnimatedSprite extends Component {
195
195
  this.mesh?.removeFromParent();
196
196
  this.mesh?.geometry.dispose();
197
197
  this.mesh?.material.dispose();
198
+ // Only this sprite's clones: the cached bases live with the Game.
198
199
  for (const tex of this.texs)
199
200
  tex.dispose();
201
+ this.texs = [];
202
+ this.failedSheets.clear();
203
+ }
204
+ /**
205
+ * The sheet's own clone of the cached base. Non-uniform slicing needs
206
+ * the image's pixel size, so the current frame is re-applied once the
207
+ * sheet settles — on a cache hit too, whose settlement is already
208
+ * resolved — unless this sprite was destroyed meanwhile. A sheet that
209
+ * fails is remembered so the quad shows its flat material on that
210
+ * sheet's frames instead of sampling an image that never arrived (CA-4).
211
+ * An empty url (a sheet not authored yet) owns a bare texture and never
212
+ * touches game.assets, so it counts nowhere and warns about nothing.
213
+ */
214
+ sheetTexture(url) {
215
+ if (!url)
216
+ return new THREE.Texture();
217
+ const { texture, settled } = this.game.assets.texture(url);
218
+ void settled.then((outcome) => {
219
+ if (!this.texs.includes(texture))
220
+ return;
221
+ if (outcome === 'failed')
222
+ this.failedSheets.add(texture);
223
+ this.applyFrame();
224
+ });
225
+ return texture;
200
226
  }
201
227
  showFrame(index) {
202
228
  this.frame = index;
@@ -227,9 +253,12 @@ export class AnimatedSprite extends Component {
227
253
  const tex = this.texs[located.sheet];
228
254
  if (!sheet || !tex)
229
255
  return;
230
- if (this.mesh && this.mesh.material.map !== tex) {
231
- this.mesh.material.map = tex;
232
- this.mesh.material.needsUpdate = true;
256
+ if (this.mesh) {
257
+ const map = this.failedSheets.has(tex) ? null : tex;
258
+ if (this.mesh.material.map !== map) {
259
+ this.mesh.material.map = map;
260
+ this.mesh.material.needsUpdate = true;
261
+ }
233
262
  }
234
263
  const cells = sheet.cells;
235
264
  if (cells?.length) {
@@ -69,6 +69,12 @@ export declare class Sprite extends Component implements YSortParticipant {
69
69
  private mesh?;
70
70
  onReady(): void;
71
71
  onDestroy(): void;
72
+ /**
73
+ * CA-4's failure rule: an image that never arrives leaves the flat
74
+ * `color`, not a white quad over an empty map. Skipped once the sprite
75
+ * was destroyed or the clone is no longer this material's map.
76
+ */
77
+ private dropFailedTexture;
72
78
  private syncQuad;
73
79
  private createGeometry;
74
80
  }
@@ -1,7 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { Component } from '../component.js';
3
3
  import { spritePlacement } from '../sprite-placement.js';
4
- const loader = new THREE.TextureLoader();
5
4
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
6
5
  /**
7
6
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
@@ -109,14 +108,20 @@ export class Sprite extends Component {
109
108
  onReady() {
110
109
  const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
111
110
  if (this.texture) {
112
- const tex = loader.load(this.texture);
111
+ // Its own clone of the cached base (game.assets, ADR 0019): filters
112
+ // are per clone, colour space comes with the base, and the image lands
113
+ // on the Source every clone of this URL shares.
114
+ const { texture, settled } = this.game.assets.texture(this.texture);
113
115
  if (this.pixelArt) {
114
- tex.magFilter = THREE.NearestFilter;
115
- tex.minFilter = THREE.NearestFilter;
116
+ texture.magFilter = THREE.NearestFilter;
117
+ texture.minFilter = THREE.NearestFilter;
116
118
  }
117
- tex.colorSpace = THREE.SRGBColorSpace;
118
- material.map = tex;
119
+ material.map = texture;
119
120
  material.color.set(0xffffff);
121
+ void settled.then((outcome) => {
122
+ if (outcome === 'failed')
123
+ this.dropFailedTexture(texture);
124
+ });
120
125
  }
121
126
  this.mesh = new THREE.Mesh(this.createGeometry(), material);
122
127
  this.mesh.position.z = this.layer * 0.01;
@@ -126,7 +131,24 @@ export class Sprite extends Component {
126
131
  onDestroy() {
127
132
  this.mesh?.removeFromParent();
128
133
  this.mesh?.geometry.dispose();
134
+ // Only this sprite's clone: the cached base lives with the Game.
135
+ this.mesh?.material.map?.dispose();
129
136
  this.mesh?.material.dispose();
137
+ this.mesh = undefined;
138
+ }
139
+ /**
140
+ * CA-4's failure rule: an image that never arrives leaves the flat
141
+ * `color`, not a white quad over an empty map. Skipped once the sprite
142
+ * was destroyed or the clone is no longer this material's map.
143
+ */
144
+ dropFailedTexture(texture) {
145
+ const material = this.mesh?.material;
146
+ if (!material || material.map !== texture)
147
+ return;
148
+ material.map = null;
149
+ texture.dispose();
150
+ material.color.setHex(this.color);
151
+ material.needsUpdate = true;
130
152
  }
131
153
  syncQuad() {
132
154
  if (!this.mesh)
@@ -107,6 +107,12 @@ export declare class Tilemap extends Component implements SolidSource {
107
107
  private gridSpec;
108
108
  private rebuildMap;
109
109
  private rebuildMaterial;
110
+ /**
111
+ * CA-4's failure rule: an image that never arrives leaves the flat
112
+ * `color`, not a white map over an empty texture. Only reached while the
113
+ * failed clone is still the current one (see rebuildMaterial).
114
+ */
115
+ private dropFailedTexture;
110
116
  private makeMaterial;
111
117
  private rebuildGeometry;
112
118
  private rebuildSolids;
@@ -5,7 +5,6 @@ import { projectIsometric } from '../projection.js';
5
5
  import { SOLID_SOURCE_SYMBOL } from '../scene-solids.js';
6
6
  import { cellAt as gridCellAt, cellBounds as gridCellBounds, cellIndex as gridCellIndex, } from '../tilemap-grid.js';
7
7
  import { Solid } from './solid.js';
8
- const loader = new THREE.TextureLoader();
9
8
  /** One authorable cell map rendered as a single merged geometry. */
10
9
  export class Tilemap extends Component {
11
10
  static componentName = 'Tilemap';
@@ -212,20 +211,45 @@ export class Tilemap extends Component {
212
211
  if (!this.texture)
213
212
  return;
214
213
  const requested = this.texture;
215
- const texture = loader.load(requested, () => {
216
- if (this.loadedTexture === texture && this.texture === requested)
214
+ // Its own clone of the cached base (game.assets, ADR 0019). The image's
215
+ // pixel size drives the UVs, so the geometry is rebuilt once the texture
216
+ // settles — on a cache hit too, whose settlement is already resolved —
217
+ // and a texture that fails is dropped for the flat colour; neither
218
+ // happens if the texture was replaced or the component destroyed
219
+ // meanwhile.
220
+ const { texture, settled } = this.game.assets.texture(requested);
221
+ void settled.then((outcome) => {
222
+ if (this.loadedTexture !== texture || this.texture !== requested)
223
+ return;
224
+ if (outcome === 'loaded')
217
225
  this.rebuildGeometry();
226
+ else
227
+ this.dropFailedTexture();
218
228
  });
219
229
  if (this.pixelArt) {
220
230
  texture.magFilter = THREE.NearestFilter;
221
231
  texture.minFilter = THREE.NearestFilter;
222
232
  }
223
- texture.colorSpace = THREE.SRGBColorSpace;
224
233
  this.loadedTexture = texture;
225
234
  mesh.material.map = texture;
226
235
  mesh.material.color.set(0xffffff);
227
236
  mesh.material.needsUpdate = true;
228
237
  }
238
+ /**
239
+ * CA-4's failure rule: an image that never arrives leaves the flat
240
+ * `color`, not a white map over an empty texture. Only reached while the
241
+ * failed clone is still the current one (see rebuildMaterial).
242
+ */
243
+ dropFailedTexture() {
244
+ const mesh = this.mesh;
245
+ if (!mesh)
246
+ return;
247
+ this.loadedTexture?.dispose();
248
+ this.loadedTexture = undefined;
249
+ mesh.material.map = null;
250
+ mesh.material.color.setHex(this.color);
251
+ mesh.material.needsUpdate = true;
252
+ }
229
253
  makeMaterial() {
230
254
  return new THREE.MeshBasicMaterial({
231
255
  color: this.color,
package/dist/game.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as THREE from 'three';
2
+ import { AssetLoader } from './assets/asset-loader.js';
3
+ import type { TextureBackend } from './assets/texture-backend.js';
2
4
  import type { AudioBackend } from './audio/backend.js';
3
5
  import { AudioSubsystem } from './audio/audio-subsystem.js';
4
6
  import { type SceneCameraJson } from './camera.js';
@@ -38,6 +40,13 @@ export interface GameOptions {
38
40
  * first unlock (CA-6), never eagerly here.
39
41
  */
40
42
  audio?: AudioBackend;
43
+ /**
44
+ * Replaces the real `THREE.TextureLoader` implementation behind
45
+ * `game.assets` (ADR 0013's seam, applied to textures — ADR 0019), mainly
46
+ * for a project's own tests: `happy-dom` decodes no images. Defaults to
47
+ * the real backend either way; `game.assets` always exists.
48
+ */
49
+ textures?: TextureBackend;
41
50
  }
42
51
  export type UpdateFn = (dt: number) => void;
43
52
  export interface SpawnPrefabOptions {
@@ -68,6 +77,12 @@ export declare class Game {
68
77
  readonly ui: GameUi;
69
78
  /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
70
79
  readonly audio: AudioSubsystem;
80
+ /**
81
+ * The texture cache: keep-all for the Game's life, `preload`, `status`
82
+ * and the Assets Ready promise `ready()`. Session-scoped by construction
83
+ * (ADR 0011): `unloadScene()` never touches it. See ADR 0019.
84
+ */
85
+ readonly assets: AssetLoader;
71
86
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
72
87
  readonly time: GameTime;
73
88
  /** Registry retained by loadScene for runtime prefab spawning. */
@@ -171,7 +186,7 @@ export declare class Game {
171
186
  setSceneCamera(json?: SceneCameraJson): void;
172
187
  start(): void;
173
188
  stop(): void;
174
- /** Internal: called by Entity.destroy(). */
189
+ /** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
175
190
  removeEntity(entity: Entity): void;
176
191
  /** The scene's render projection; null keeps logical and render space identical. */
177
192
  get projection(): 'isometric' | null;
package/dist/game.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as THREE from 'three';
2
+ import { gameViewport } from './anchored-pieces.js';
3
+ import { AssetLoader } from './assets/asset-loader.js';
2
4
  import { AudioSubsystem } from './audio/audio-subsystem.js';
3
5
  import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
4
6
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
@@ -16,7 +18,7 @@ import { isYSortParticipant, ySortZ } from './render-sort.js';
16
18
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
17
19
  import { createSpatialQuery } from './spatial-query.js';
18
20
  import { Stats } from './stats.js';
19
- import { GameUi } from './ui.js';
21
+ import { anchoredPiecesOf, GameUi } from './ui.js';
20
22
  /**
21
23
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
22
24
  * entities with components, and input. See DESIGN.md.
@@ -34,6 +36,12 @@ export class Game {
34
36
  ui;
35
37
  /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
36
38
  audio;
39
+ /**
40
+ * The texture cache: keep-all for the Game's life, `preload`, `status`
41
+ * and the Assets Ready promise `ready()`. Session-scoped by construction
42
+ * (ADR 0011): `unloadScene()` never touches it. See ADR 0019.
43
+ */
44
+ assets;
37
45
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
38
46
  time = new GameTime();
39
47
  /** Registry retained by loadScene for runtime prefab spawning. */
@@ -88,6 +96,11 @@ export class Game {
88
96
  this.query = createSpatialQuery(this);
89
97
  this.stats = new Stats(options.stats);
90
98
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
99
+ anchoredPiecesOf(this.ui).connect(() => ({
100
+ camera: this.camera,
101
+ viewport: gameViewport(canvas.clientWidth, canvas.clientHeight, this.resolution),
102
+ projection: this.sceneProjection,
103
+ }));
91
104
  this.audio = new AudioSubsystem({
92
105
  canvas,
93
106
  backend: options.audio,
@@ -97,6 +110,12 @@ export class Game {
97
110
  // music bed needs across a scene swap.
98
111
  resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
99
112
  });
113
+ this.assets = new AssetLoader({
114
+ backend: options.textures,
115
+ // Same late lookup as audio's: preload() resolves through whatever
116
+ // catalog is registered at call time, which outlives every scene.
117
+ resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
118
+ });
100
119
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
101
120
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
102
121
  this.scene.background = new THREE.Color(background);
@@ -304,6 +323,7 @@ export class Game {
304
323
  },
305
324
  loadScene: (name) => this.loadSceneByName(name),
306
325
  availableScenes: () => this.availableScenes,
326
+ assets: () => this.assets.status,
307
327
  });
308
328
  activation.register(this.runtimeBridge);
309
329
  this.audio.setSilenced(true);
@@ -318,8 +338,9 @@ export class Game {
318
338
  stop() {
319
339
  this.renderer.setAnimationLoop(null);
320
340
  }
321
- /** Internal: called by Entity.destroy(). */
341
+ /** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
322
342
  removeEntity(entity) {
343
+ anchoredPiecesOf(this.ui).release(entity);
323
344
  const i = this.entities.indexOf(entity);
324
345
  if (i !== -1)
325
346
  this.entities.splice(i, 1);
@@ -350,6 +371,9 @@ export class Game {
350
371
  this.time.cancelAll();
351
372
  for (const entity of [...this.entities])
352
373
  entity.destroy();
374
+ // After the entities: their clones go with the cascade above, the
375
+ // cached bases go here, exactly once (ADR 0019).
376
+ this.assets.dispose();
353
377
  this.renderer.dispose();
354
378
  }
355
379
  /**
@@ -511,6 +535,7 @@ export class Game {
511
535
  if (this.renderSort === 'y')
512
536
  this.applyYSort();
513
537
  this.ui.setActive(this.simulate);
538
+ anchoredPiecesOf(this.ui).place();
514
539
  if (this.resolution) {
515
540
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
516
541
  this.renderer.setScissorTest(false);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { Game } from './game.js';
2
2
  export type { GameOptions, GameResolution, SceneCatalog, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
3
+ export { AssetLoader } from './assets/asset-loader.js';
4
+ export type { AssetStatus } from './assets/asset-loader.js';
5
+ export type { TextureBackend } from './assets/texture-backend.js';
3
6
  export { AudioSubsystem } from './audio/audio-subsystem.js';
4
7
  export type { AudioSubsystemOptions } from './audio/audio-subsystem.js';
5
8
  export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './audio/backend.js';
@@ -32,11 +35,12 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
32
35
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
33
36
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
34
37
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
35
- export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeTransformSnapshot, } from './runtime-inspection.js';
38
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
36
39
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
37
40
  export { Stats } from './stats.js';
38
41
  export type { StatValue } from './stats.js';
39
42
  export { GameUi } from './ui.js';
43
+ export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
40
44
  export { Sprite } from './components/sprite.js';
41
45
  export { Solid } from './components/solid.js';
42
46
  export { Hitbox } from './components/hitbox.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Game } from './game.js';
2
+ export { AssetLoader } from './assets/asset-loader.js';
2
3
  export { AudioSubsystem } from './audio/audio-subsystem.js';
3
4
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
4
5
  export { isYSortParticipant, ySortZ } from './render-sort.js';
package/dist/input.d.ts CHANGED
@@ -24,6 +24,12 @@ export declare class Input {
24
24
  justPressed(action: ActionName): boolean;
25
25
  /** Installed semantic action names in deterministic order. */
26
26
  availableActions(): ActionName[];
27
+ /**
28
+ * The key codes bound to the action, in their declared order (e.g.
29
+ * `['KeyE', 'Space']`); `[]` for an unknown or unbound action. A new
30
+ * array every call: mutating it never changes the bindings.
31
+ */
32
+ bindingsFor(action: ActionName): string[];
27
33
  /** Currently held semantic action names in deterministic order. */
28
34
  heldActions(): ActionName[];
29
35
  /** Injects an action by semantic name; false means the action is not installed. */