From e24784caab06651741b4227862c9515a7c89f536 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:19:44 +0200 Subject: [PATCH 01/85] refactor(examples)!: migrate batch A off Scene.load()/unload() to seamless init() --- examples/application-scenes/camera-basic.ts | 5 +-- .../multi-view-split-screen.ts | 6 ++-- .../application-scenes/pause-and-resume.ts | 5 +-- .../application-scenes/picture-in-picture.ts | 6 ++-- .../application-scenes/scene-lifecycle.ts | 34 +++++++++++++++---- examples/getting-started/game-loop.ts | 5 +-- examples/getting-started/hello-world.ts | 5 +-- examples/getting-started/resize-and-dpr.ts | 5 +-- examples/sprites-textures/blendmodes.ts | 24 ++++++++----- examples/sprites-textures/sprite-basics.ts | 8 ++--- .../sprites-textures/spritesheet-frames.ts | 11 ++---- examples/sprites-textures/svg-drawable.ts | 24 ++++++++----- examples/sprites-textures/texture-loader.ts | 30 +++++++--------- examples/sprites-textures/video-drawable.ts | 26 +++++++------- 14 files changed, 97 insertions(+), 97 deletions(-) diff --git a/examples/application-scenes/camera-basic.ts b/examples/application-scenes/camera-basic.ts index c94a466de..c46155416 100644 --- a/examples/application-scenes/camera-basic.ts +++ b/examples/application-scenes/camera-basic.ts @@ -19,13 +19,10 @@ class CameraBasicScene extends Scene { private uiBar!: Graphics; private zoom = 1; - override async load(loader): Promise { - this.bunny = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { const { width, height } = this.app.canvas; + this.bunny = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.bunny.setAnchor(0.5).setPosition(width / 2, height / 2); this.grid = new Graphics(); diff --git a/examples/application-scenes/multi-view-split-screen.ts b/examples/application-scenes/multi-view-split-screen.ts index 28aa96e87..a6456b653 100644 --- a/examples/application-scenes/multi-view-split-screen.ts +++ b/examples/application-scenes/multi-view-split-screen.ts @@ -31,13 +31,11 @@ class SplitScreenScene extends Scene { down: 0, }; - override async load(loader): Promise { - this.texture = await loader.load(Texture, 'image/ship-a.png'); - } - override init(): void { const { width, height } = this.app.canvas; + this.texture = this.loader.get(Texture, 'image/ship-a.png'); + this.leftView = new View(0, 0, width / 2, height).setViewport(0, 0, 0.5, 1); this.rightView = new View(0, 0, width / 2, height).setViewport(0.5, 0, 0.5, 1); diff --git a/examples/application-scenes/pause-and-resume.ts b/examples/application-scenes/pause-and-resume.ts index a963f89e2..6fd12dcb8 100644 --- a/examples/application-scenes/pause-and-resume.ts +++ b/examples/application-scenes/pause-and-resume.ts @@ -17,13 +17,10 @@ class PauseResumeScene extends Scene { private sprite!: Sprite; private label!: Text; - override async load(loader): Promise { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); diff --git a/examples/application-scenes/picture-in-picture.ts b/examples/application-scenes/picture-in-picture.ts index 9ba5ff915..cc5b93350 100644 --- a/examples/application-scenes/picture-in-picture.ts +++ b/examples/application-scenes/picture-in-picture.ts @@ -20,13 +20,11 @@ class PictureInPictureScene extends Scene { private velocity = 220; private frame!: Graphics; - override async load(loader): Promise { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); + this.mainView = new View(0, 0, width, height); this.pipView = new View(0, 0, width * 0.3, height * 0.3).setViewport(0.68, 0.04, 0.28, 0.28); // Zoom < 1 zooms OUT (a larger visible world area maps into the same diff --git a/examples/application-scenes/scene-lifecycle.ts b/examples/application-scenes/scene-lifecycle.ts index 4763d4b78..e8dc1b100 100644 --- a/examples/application-scenes/scene-lifecycle.ts +++ b/examples/application-scenes/scene-lifecycle.ts @@ -13,6 +13,19 @@ const app = new Application({ }, }); +// The scene lifecycle has two hooks: +// - `init(loader)` — one-shot async setup, called once before the first frame. +// Fetch/await assets here (`this.loader.get(...)` for +// seamless resources, `await this.loader.load(...)` for +// value assets), then build the scene graph. +// - `destroy()` — one-shot teardown, called once when the scene is +// finally popped off the stack. +// `update`/`draw` run every frame in between. Two signals bracket the same +// span from the outside: `onLoad` fires right after `init()` resolves (the +// scene is about to become active) and `onUnload` fires right before +// `destroy()` runs (the scene is about to deactivate) — a hook point for +// cross-cutting concerns (audio cues, analytics, HUD toggles) that shouldn't +// live inside `init`/`destroy` themselves. class LifecycleScene extends Scene { private events!: string[]; private counter = 0; @@ -20,14 +33,22 @@ class LifecycleScene extends Scene { private timer!: Timer; private text!: Text; - override async load(): Promise { - this.events = ['load']; - } - - override init(): void { + override async init(): Promise { const { width, height } = this.app.canvas; - this.events.push('init'); + // This scene is procedural — nothing to fetch — but a real scene would + // resolve its assets here before touching the scene graph, e.g.: + // const texture = this.loader.get(Texture, 'ship.png'); + // const data = (await this.loader.load(Json, 'level.json')) as LevelData; + this.events = ['init']; + + this.onLoad.add(() => { + this.events.push('onLoad'); + }); + + this.onUnload.add(() => { + this.events.push('onUnload'); + }); this.timer = new Timer(Time.fromSeconds(1), true); @@ -52,6 +73,7 @@ class LifecycleScene extends Scene { } override destroy(): void { + // destroy() is the single teardown hook — no separate unload() step. this.events.push('destroy'); super.destroy(); } diff --git a/examples/getting-started/game-loop.ts b/examples/getting-started/game-loop.ts index 111a32ccb..f82bc4bf2 100644 --- a/examples/getting-started/game-loop.ts +++ b/examples/getting-started/game-loop.ts @@ -16,13 +16,10 @@ const app = new Application({ class GameLoopScene extends Scene { private sprite!: Sprite; - override async load(loader): Promise { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } diff --git a/examples/getting-started/hello-world.ts b/examples/getting-started/hello-world.ts index 408ff2df2..1cc6ef19e 100644 --- a/examples/getting-started/hello-world.ts +++ b/examples/getting-started/hello-world.ts @@ -17,13 +17,10 @@ const app = new Application({ class HelloWorldScene extends Scene { private sprite!: Sprite; - override async load(loader): Promise { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } diff --git a/examples/getting-started/resize-and-dpr.ts b/examples/getting-started/resize-and-dpr.ts index 727d35533..8080f99f9 100644 --- a/examples/getting-started/resize-and-dpr.ts +++ b/examples/getting-started/resize-and-dpr.ts @@ -33,11 +33,8 @@ class ResizeScene extends Scene { private sprite!: Sprite; private info!: Text; - override async load(loader): Promise { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } - override init(): void { + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.sprite.setAnchor(0.5); this.info = new Text('', { fillColor: Color.white, fontSize: 16 }); diff --git a/examples/sprites-textures/blendmodes.ts b/examples/sprites-textures/blendmodes.ts index 8bd6c739e..38e8188dd 100644 --- a/examples/sprites-textures/blendmodes.ts +++ b/examples/sprites-textures/blendmodes.ts @@ -47,8 +47,17 @@ class BlendmodesScene extends Scene { private hud!: ReturnType; private cycle!: { set(value: number): void }; - override async load(loader): Promise { - await loader.load( + // Note: passing `options` as a 3rd argument to `loader.get(Texture, …)` or + // `loader.load(Texture, …)` alongside a non-Json type currently mis-resolves + // the overload (falls through to the `Json` generic and types the result as + // `unknown`) — see the flagged deviation in the migration report. `load()` + // is awaited here purely to seed the fetch with `scaleMode: Nearest`; its + // return value is intentionally unused. The subsequent 2-argument `get()` + // calls for the same sources are unaffected and stay seamless. + override async init(): Promise { + const { width, height } = this.app.canvas; + + await this.loader.load( Texture, { background: ALPHA_RINGS, @@ -58,12 +67,11 @@ class BlendmodesScene extends Scene { scaleMode: ScaleModes.Nearest, }, ); - } - override init(loader): void { - const { width, height } = this.app.canvas; + const backgroundTexture = this.loader.get(Texture, ALPHA_RINGS); + const shipTexture = this.loader.get(Texture, assets.demo.textures.shipA); - this.background = new Sprite(loader.get(Texture, 'background')); + this.background = new Sprite(backgroundTexture); this.background.setPosition(width / 2, height / 2); this.background.setAnchor(0.5, 0.5); this.background.setScale(Math.max(width, height) / 256); @@ -71,12 +79,12 @@ class BlendmodesScene extends Scene { // Two overlapping sprites in complementary hues so the composite in the // overlap region differs clearly between modes. - this.left = new Sprite(loader.get(Texture, 'ship')); + this.left = new Sprite(shipTexture); this.left.setAnchor(0.5, 0.5); this.left.setScale(5); this.left.setTint(new Color(80, 210, 255)); - this.right = new Sprite(loader.get(Texture, 'ship')); + this.right = new Sprite(shipTexture); this.right.setAnchor(0.5, 0.5); this.right.setScale(5); this.right.setTint(new Color(255, 96, 200)); diff --git a/examples/sprites-textures/sprite-basics.ts b/examples/sprites-textures/sprite-basics.ts index 41196cbc2..72425acab 100644 --- a/examples/sprites-textures/sprite-basics.ts +++ b/examples/sprites-textures/sprite-basics.ts @@ -21,14 +21,10 @@ class SpriteBasicsScene extends Scene { private elapsed = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.ship = new Sprite(loader.get(Texture, 'ship')); + this.ship = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); this.ship.setPosition((width / 2) | 0, (height / 2) | 0); this.ship.setAnchor(0.5); this.ship.setScale(3); diff --git a/examples/sprites-textures/spritesheet-frames.ts b/examples/sprites-textures/spritesheet-frames.ts index 5f6a7b685..6987b3acf 100644 --- a/examples/sprites-textures/spritesheet-frames.ts +++ b/examples/sprites-textures/spritesheet-frames.ts @@ -25,15 +25,10 @@ class SpritesheetFramesScene extends Scene { private playing = true; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { characters: 'image/platformer-characters.png' }); - await loader.load(Json, { characters: 'json/platformer-characters.json' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'characters'); - const data = loader.get(Json, 'characters').value as SpritesheetData; + const texture = this.loader.get(Texture, 'image/platformer-characters.png'); + const data = (await this.loader.load(Json, 'json/platformer-characters.json')) as SpritesheetData; this.spritesheet = new Spritesheet(texture, data); diff --git a/examples/sprites-textures/svg-drawable.ts b/examples/sprites-textures/svg-drawable.ts index c9805a69e..1a81611d2 100644 --- a/examples/sprites-textures/svg-drawable.ts +++ b/examples/sprites-textures/svg-drawable.ts @@ -17,18 +17,24 @@ class SvgDrawableScene extends Scene { private texture!: Texture; private sprite!: Sprite; - override async load(loader): Promise { + override async init(): Promise { + const { width, height } = this.app.canvas; + + // SvgAsset has no seamless adapter (unlike Texture/Sound), so it is + // awaited via `load()` rather than fetched synchronously via `get()`. // The exo.js wordmark SVG carries only a viewBox (no width/height), so // it would rasterise to a 0x0 image. Request an explicit pixel size — // the SVG is vector, so it stays crisp at any rasterised resolution. - await loader.load(SvgAsset, { mark: 'svg/exo-wordmark.svg' }, { width: 850, height: 324 }); - } - - override init(loader): void { - const { width, height } = this.app.canvas; - - this.texture = new Texture(loader.get(SvgAsset, 'mark')); - + // + // The cast below works around a pre-existing overload-resolution gap: + // every value-asset dispatch token (Json/TextAsset/SvgAsset/…) is an + // empty marker class, so they're structurally identical to `load()`'s + // `typeof Json` overload — which is declared first and wins, typing + // the result as `unknown` instead of `HTMLImageElement`. See the + // flagged deviation in the migration report. + const mark = (await this.loader.load(SvgAsset, 'svg/exo-wordmark.svg', { width: 850, height: 324 })) as HTMLImageElement; + + this.texture = new Texture(mark); this.sprite = new Sprite(this.texture); this.sprite.setAnchor(0.5); this.sprite.setPosition((width / 2) | 0, (height / 2) | 0); diff --git a/examples/sprites-textures/texture-loader.ts b/examples/sprites-textures/texture-loader.ts index 5f4aaa7ad..51bd103d8 100644 --- a/examples/sprites-textures/texture-loader.ts +++ b/examples/sprites-textures/texture-loader.ts @@ -15,6 +15,7 @@ const app = new Application({ class TextureLoaderScene extends Scene { private sprites!: Sprite[]; + private textures!: Texture[]; private bar!: Graphics; private label!: Text; private barX = 0; @@ -22,29 +23,18 @@ class TextureLoaderScene extends Scene { private barWidth = 0; private progress = { loaded: 0, total: 3 }; - override async load(loader): Promise { - const loading = loader.load(Texture, { - bunny: 'image/ship-a.png', - gradient: 'image/hue-ramp.png', - uvGrid: 'image/uv-grid-256.png', - }); - - loading.onProgress.add((progress) => { - this.progress = progress; - }); - - await loading; - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - const textures = [loader.get(Texture, 'bunny'), loader.get(Texture, 'gradient'), loader.get(Texture, 'uvGrid')]; + + // Seamless get() returns placeholder handles immediately; each pops in + // (loadState → 'ready') as its fetch completes, polled in update(). + this.textures = [this.loader.get(Texture, 'image/ship-a.png'), this.loader.get(Texture, 'image/hue-ramp.png'), this.loader.get(Texture, 'image/uv-grid-256.png')]; // Spread the three textures evenly across the width, one per third. - this.sprites = textures.map((texture, index) => { + this.sprites = this.textures.map((texture, index) => { const sprite = new Sprite(texture); sprite.setAnchor(0.5); - sprite.setPosition((width / textures.length) * (index + 0.5), height * 0.6); + sprite.setPosition((width / this.textures.length) * (index + 0.5), height * 0.6); return sprite; }); @@ -59,6 +49,10 @@ class TextureLoaderScene extends Scene { this.label.setPosition(width / 2, this.barY + 40); } + override update(): void { + this.progress.loaded = this.textures.filter(texture => texture.loadState === 'ready').length; + } + override draw(context): void { context.backend.clear(); const { loaded, total } = this.progress; diff --git a/examples/sprites-textures/video-drawable.ts b/examples/sprites-textures/video-drawable.ts index 5ca69e08c..6c104abb0 100644 --- a/examples/sprites-textures/video-drawable.ts +++ b/examples/sprites-textures/video-drawable.ts @@ -26,27 +26,25 @@ class VideoDrawableScene extends Scene { private overlay!: Sprite; private elapsed = 0; private hud!: ReturnType; - private assetLoader: any = null; private videoIdx = 0; private readonly loadedVideos = new Set(); private switching = false; - override async load(loader): Promise { - this.assetLoader = loader; - await loader.load(Video, { [VIDEOS[0].name]: VIDEOS[0].url }); - this.loadedVideos.add(VIDEOS[0].name); - await loader.load(Texture, { ship: assets.demo.textures.shipA }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - this.video = loader.get(Video, VIDEOS[0].name); + // Video has no seamless adapter (unlike Texture/Sound), so it is + // awaited via `load()` rather than fetched synchronously via `get()`. + await this.loader.load(Video, { [VIDEOS[0].name]: VIDEOS[0].url }); + this.loadedVideos.add(VIDEOS[0].name); + + this.video = this.loader.get(Video, VIDEOS[0].name); this.configureVideo(); // A sprite composited on top of the live video texture — the same scene - // graph draws video frames and regular sprites side by side. - this.overlay = new Sprite(loader.get(Texture, 'ship')); + // graph draws video frames and regular sprites side by side. Texture IS + // seamless, so it is fetched directly by source with no preload step. + this.overlay = new Sprite(this.loader.get(Texture, assets.demo.textures.shipA)); this.overlay.setAnchor(0.5); this.overlay.setScale(3); this.overlay.setPosition(width / 2, height / 2); @@ -91,12 +89,12 @@ class VideoDrawableScene extends Scene { this.hud.setStatus(`Loading — ${entry.label}…`); try { if (!this.loadedVideos.has(entry.name)) { - await this.assetLoader.load(Video, { [entry.name]: entry.url }); + await this.loader.load(Video, { [entry.name]: entry.url }); this.loadedVideos.add(entry.name); } this.video.pause(); this.videoIdx = idx; - this.video = this.assetLoader.get(Video, entry.name); + this.video = this.loader.get(Video, entry.name); this.configureVideo(); this.video.play(); this.hud.setStatus(`Playing — ${entry.label}`); From 9a42eb93e107891b7068027f6be705e47bd81174 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:17:03 +0200 Subject: [PATCH 02/85] refactor(examples)!: migrate batch B (audio) off Scene lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate audio-basics, audio-fx, beat-detection, and spatial-audio examples from Scene.load()/unload() to async init(loader) + seamless this.loader.get()/load(). - Sound is seamless: resolved via the extension-inferred path-only get(source) form, which sidesteps a compile-time overload ambiguity between the Sound and Json tokens (both have zero-arg-constructible instance types) — the Sound token form still works but needs a cast. - AudioStream has no seamless adapter: kept on awaited this.loader.load(AudioStream, { alias: source }) in async init. The single-path form of load() is rejected at compile time for .ogg because AudioStream isn't in ExtensionTypeMap for that extension, so the record/map form is used instead. - spatial-audio scenes construct a derived Sound from .audioBuffer synchronously, so they await load(Sound, source) (cast to Sound) rather than the deferred get(), whose placeholder audioBuffer is null until the fetch fills it. --- examples/audio-basics/audio-buses.ts | 17 +++++++++-------- examples/audio-basics/crossfade-tracks.ts | 14 ++++++-------- examples/audio-basics/music-loop.ts | 10 ++++------ examples/audio-basics/play-sound.ts | 11 +++++------ examples/audio-basics/random-pitch-pool.ts | 11 +++++------ examples/audio-basics/sound-pool.ts | 15 +++++++-------- examples/audio-fx/compressor.ts | 10 ++++------ examples/audio-fx/ducking.ts | 16 ++++++++-------- examples/audio-fx/reverb-and-delay.ts | 11 +++++------ examples/audio-fx/vocoder.ts | 17 ++++++++--------- examples/beat-detection/beat-sync-pulse.ts | 15 ++++++--------- examples/beat-detection/frequency-bands.ts | 10 ++++------ examples/beat-detection/tempo-tracking.ts | 10 ++++------ examples/spatial-audio/falloff-curves.ts | 16 ++++++++++------ examples/spatial-audio/listener-and-source.ts | 19 +++++++++++-------- examples/spatial-audio/moving-source.ts | 19 +++++++++++-------- 16 files changed, 107 insertions(+), 114 deletions(-) diff --git a/examples/audio-basics/audio-buses.ts b/examples/audio-basics/audio-buses.ts index 9d83d5808..b617edc85 100644 --- a/examples/audio-basics/audio-buses.ts +++ b/examples/audio-basics/audio-buses.ts @@ -35,12 +35,7 @@ class AudioBusesScene extends Scene { private sfxButton = { x: 0, y: 0, w: 0, h: 0 }; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); - await loader.load(Sound, { sfx: assets.demo.audio.uiClick }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Centre the bus mixer horizontally and spread the bars across the wide @@ -50,8 +45,14 @@ class AudioBusesScene extends Scene { this.rowY = rows.map((_, i) => height * 0.34 + i * 90); this.sfxButton = { x: width / 2 - 150, y: height * 0.74, w: 300, h: 36 }; - this.music = loader.get(AudioStream, 'music'); - this.sfx = loader.get(Sound, 'sfx'); + // AudioStream has no seamless adapter — await it explicitly. + const { music } = await this.loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); + this.music = music; + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // (both have zero-arg-constructible instance types) when passing the + // Sound token explicitly. + this.sfx = this.loader.get(assets.demo.audio.uiClick); this.graphics = new Graphics(); this.labels = rows.map((_, i) => new Text('', { fillColor: Color.white, fontSize: 18 }).setPosition(this.trackX - 50, this.rowY[i] - 34)); diff --git a/examples/audio-basics/crossfade-tracks.ts b/examples/audio-basics/crossfade-tracks.ts index 440478cdb..42a14e72a 100644 --- a/examples/audio-basics/crossfade-tracks.ts +++ b/examples/audio-basics/crossfade-tracks.ts @@ -38,11 +38,7 @@ class CrossfadeTracksScene extends Scene { private meterBaseY = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(AudioStream, { a: assets.demo.audio.musicA, b: assets.demo.audio.musicB }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Spread the two meters across the wide canvas: each sits a third of the @@ -51,9 +47,11 @@ class CrossfadeTracksScene extends Scene { this.meterBX = width * 0.67 - METER_W / 2; this.meterBaseY = height * 0.82; - // Both tracks loop; the crossfade only swaps which one is audible. - this.trackA = loader.get(AudioStream, 'a'); - this.trackB = loader.get(AudioStream, 'b'); + // AudioStream has no seamless adapter — await it explicitly. Both tracks + // loop; the crossfade only swaps which one is audible. + const tracks = await this.loader.load(AudioStream, { a: assets.demo.audio.musicA, b: assets.demo.audio.musicB }); + this.trackA = tracks.a; + this.trackB = tracks.b; this.graphics = new Graphics(); this.labelA = new Text('Track A', { fillColor: Color.white, fontSize: 22, align: 'center' }) diff --git a/examples/audio-basics/music-loop.ts b/examples/audio-basics/music-loop.ts index e38850ef9..caa2e6785 100644 --- a/examples/audio-basics/music-loop.ts +++ b/examples/audio-basics/music-loop.ts @@ -22,11 +22,7 @@ class MusicLoopScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Wide progress bar centred horizontally on the 16:9 canvas. @@ -34,7 +30,9 @@ class MusicLoopScene extends Scene { // A single streaming track — the browser's media pipeline loops it // seamlessly when `loop` is on, so no duplicate/silent track is needed. - this.music = loader.get(AudioStream, 'track'); + // AudioStream has no seamless adapter — await it explicitly. + const { track } = await this.loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); + this.music = track; // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically — play() returns the Voice now, diff --git a/examples/audio-basics/play-sound.ts b/examples/audio-basics/play-sound.ts index 0033ab6ce..5e7bdd313 100644 --- a/examples/audio-basics/play-sound.ts +++ b/examples/audio-basics/play-sound.ts @@ -18,17 +18,16 @@ class PlaySoundScene extends Scene { private text!: Text; private index = 0; - override async load(loader): Promise { - await Promise.all(SOUND_KEYS.map(key => loader.load(Sound, { [key]: assets.demo.audio[key] }))); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // Keep example SFX comfortable — full volume is jarring in the docs. this.app.audio.sound.volume = 0.5; - this.sounds = SOUND_KEYS.map(key => loader.get(Sound, key)); + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // when passing the Sound token explicitly. + this.sounds = SOUND_KEYS.map(key => this.loader.get(assets.demo.audio[key])); this.text = new Text('Click anywhere to play SFX', { fillColor: Color.white, fontSize: 24, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height / 2); diff --git a/examples/audio-basics/random-pitch-pool.ts b/examples/audio-basics/random-pitch-pool.ts index 7dcaf991f..afb929fb2 100644 --- a/examples/audio-basics/random-pitch-pool.ts +++ b/examples/audio-basics/random-pitch-pool.ts @@ -30,18 +30,17 @@ class RandomPitchPoolScene extends Scene { private trackHalf = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Sound, { blip: assets.demo.audio.impactLight }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.centerX = width / 2; this.trackY = height * 0.55; this.trackHalf = width * 0.38; - this.sound = loader.get(Sound, 'blip'); + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // when passing the Sound token explicitly. + this.sound = this.loader.get(assets.demo.audio.impactLight); this.sound.poolSize = 20; this.graphics = new Graphics(); diff --git a/examples/audio-basics/sound-pool.ts b/examples/audio-basics/sound-pool.ts index 0f66a970f..cea87d552 100644 --- a/examples/audio-basics/sound-pool.ts +++ b/examples/audio-basics/sound-pool.ts @@ -30,16 +30,15 @@ class SoundPoolScene extends Scene { private evictions = 0; private hud!: ReturnType; - override async load(loader): Promise { - // impactHeavy is long enough (~0.5 s) that rapid fire actually overlaps - - // a short click ends before the next shot and the pool never fills. - await loader.load(Sound, { shot: assets.demo.audio.impactHeavy }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sound = loader.get(Sound, 'shot'); + // impactHeavy is long enough (~0.5 s) that rapid fire actually overlaps - + // a short click ends before the next shot and the pool never fills. + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // when passing the Sound token explicitly. + this.sound = this.loader.get(assets.demo.audio.impactHeavy); this.sound.poolSize = POOL_SIZE; this.graphics = new Graphics(); diff --git a/examples/audio-fx/compressor.ts b/examples/audio-fx/compressor.ts index 90be06874..b36d97098 100644 --- a/examples/audio-fx/compressor.ts +++ b/examples/audio-fx/compressor.ts @@ -46,11 +46,7 @@ class CompressorScene extends Scene { private meterY = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(AudioStream, { music: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Wide horizontal bars centred on the 16:9 canvas; labels sit to the left. @@ -60,7 +56,9 @@ class CompressorScene extends Scene { this.rowY = sliders.map((_, i) => height * 0.26 + i * 90); this.meterY = this.rowY[this.rowY.length - 1] + 100; - this.music = loader.get(AudioStream, 'music'); + // AudioStream has no seamless adapter — await it explicitly. + const { music } = await this.loader.load(AudioStream, { music: 'audio/demo-loop-main.ogg' }); + this.music = music; this.filter = new CompressorEffect(); app.audio.music.addEffect(this.filter); diff --git a/examples/audio-fx/ducking.ts b/examples/audio-fx/ducking.ts index 57b5d87c6..65677e4da 100644 --- a/examples/audio-fx/ducking.ts +++ b/examples/audio-fx/ducking.ts @@ -33,12 +33,7 @@ class DuckingScene extends Scene { private voiceBarY = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); - await loader.load(Sound, { voice: assets.demo.voice.congratulations }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Wide meters centred on the 16:9 canvas. @@ -47,8 +42,13 @@ class DuckingScene extends Scene { this.musicBarY = height * 0.42; this.voiceBarY = height * 0.55; - this.music = loader.get(AudioStream, 'music'); - this.voice = loader.get(Sound, 'voice'); + // AudioStream has no seamless adapter — await it explicitly. + const { music } = await this.loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); + this.music = music; + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // when passing the Sound token explicitly. + this.voice = this.loader.get(assets.demo.voice.congratulations); // Route the voice-over onto its own bus so it can drive the sidechain. this.voiceBus = new AudioBus('voice-over', { parent: app.audio.master }); diff --git a/examples/audio-fx/reverb-and-delay.ts b/examples/audio-fx/reverb-and-delay.ts index 1fdaa4e31..bf40712b0 100644 --- a/examples/audio-fx/reverb-and-delay.ts +++ b/examples/audio-fx/reverb-and-delay.ts @@ -29,17 +29,16 @@ class ReverbAndDelayScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(Sound, { sfx: 'audio/impact-light.ogg' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // A large click pad centred on the canvas. this.pad = { x: width / 2 - 240, y: height * 0.36, w: 480, h: 160 }; - this.sound = loader.get(Sound, 'sfx'); + // Path-only get() infers Sound from the .ogg extension — sidesteps a + // compile-time overload ambiguity between Sound and the Json token form + // when passing the Sound token explicitly. + this.sound = this.loader.get('audio/impact-light.ogg'); // Reverb (room tail) → Delay (echoes) chained on the sound bus. this.reverb = new ReverbEffect({ wet: 0.4, decay: 2 }); diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index 07dbaf504..88749df85 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -29,14 +29,7 @@ class VocoderScene extends Scene { private tapPrompt!: Text; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load( - Sound, - Object.fromEntries(PHRASES.map(phrase => [phrase.key, phrase.asset])), - ); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // The spoken voice is the modulator: route every phrase onto its own bus @@ -45,7 +38,13 @@ class VocoderScene extends Scene { app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { - this.phrases.set(phrase.key, loader.get(Sound, phrase.key)); + // phrase.asset is a widened `string` (not a path literal), so the + // path-only get() overload can't infer Sound from the extension; + // the explicit Sound token form is ambiguous with the Json token + // overload at compile time (both resolve zero-arg-constructible + // instance types) even though the runtime seamless adapter is + // correct — cast through `unknown` to bridge it. + this.phrases.set(phrase.key, this.loader.get(Sound, phrase.asset) as unknown as Sound); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); diff --git a/examples/beat-detection/beat-sync-pulse.ts b/examples/beat-detection/beat-sync-pulse.ts index dc68f3c0a..dc910e902 100644 --- a/examples/beat-detection/beat-sync-pulse.ts +++ b/examples/beat-detection/beat-sync-pulse.ts @@ -36,23 +36,20 @@ class BeatSyncPulseScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png', particle: 'image/particle-light.png' }); - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(width / 2, height / 2); + // AudioStream has no seamless adapter — await it explicitly. + const { track } = await this.loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); + this.music = track; + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); this.hud = mountControls({ title: 'Beat Sync Pulse', hint: 'The ring and particle burst fire on each detected beat.', }); - this.particles = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 3500 }); + this.particles = new ParticleSystem(this.loader.get(Texture, 'image/particle-light.png'), { capacity: 3500 }); this.particles.setPosition(width / 2, height / 2); this.burst = new BurstSpawn({ schedule: [{ time: 0, count: 90 }], diff --git a/examples/beat-detection/frequency-bands.ts b/examples/beat-detection/frequency-bands.ts index 67f8d3de8..b0bfb0bff 100644 --- a/examples/beat-detection/frequency-bands.ts +++ b/examples/beat-detection/frequency-bands.ts @@ -45,12 +45,10 @@ class FrequencyBandsScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { - this.music = loader.get(AudioStream, 'track'); + override async init(): Promise { + // AudioStream has no seamless adapter — await it explicitly. + const { track } = await this.loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); + this.music = track; this.analyser = new AudioAnalyser({ fftSize: 2048, smoothingTimeConstant: 0.75 }); this.analyser.source = this.app.audio.music; diff --git a/examples/beat-detection/tempo-tracking.ts b/examples/beat-detection/tempo-tracking.ts index badd43022..14895ea41 100644 --- a/examples/beat-detection/tempo-tracking.ts +++ b/examples/beat-detection/tempo-tracking.ts @@ -29,15 +29,13 @@ class TempoTrackingScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; const marginX = width * 0.08; - this.music = loader.get(AudioStream, 'track'); + // AudioStream has no seamless adapter — await it explicitly. + const { track } = await this.loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); + this.music = track; this.detector = new BeatDetector(); this.detector.source = this.app.audio.music; diff --git a/examples/spatial-audio/falloff-curves.ts b/examples/spatial-audio/falloff-curves.ts index bcc76661d..4716ef3b6 100644 --- a/examples/spatial-audio/falloff-curves.ts +++ b/examples/spatial-audio/falloff-curves.ts @@ -60,11 +60,7 @@ class FalloffCurvesScene extends Scene { private plot = { x: 0, y: 0, w: 0, h: 0 }; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Sound, { source: 'audio/impact-light.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Sources spread across the lower half; the listener starts centred. @@ -75,8 +71,16 @@ class FalloffCurvesScene extends Scene { this.listener = { x: width / 2, y: height / 2 }; app.audio.listener.target = this.listener; + // Each derived Sound below reads .audioBuffer synchronously, so the + // shared source must be fully decoded first — await load() instead of + // the deferred get() (whose placeholder audioBuffer is null until fill). + // The explicit Sound token also hits a compile-time overload ambiguity + // with the Json token form (both resolve zero-arg-constructible + // instance types), so the awaited result is cast — the runtime + // seamless/factory resolution is unaffected. + const source = (await this.loader.load(Sound, 'audio/impact-light.ogg')) as Sound; this.sounds = this.sources.map(({ model, x, y }) => { - const sound = new Sound(loader.get(Sound, 'source').audioBuffer, { + const sound = new Sound(source.audioBuffer, { distanceModel: model, refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, diff --git a/examples/spatial-audio/listener-and-source.ts b/examples/spatial-audio/listener-and-source.ts index b49965b54..ed51d7109 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -40,16 +40,19 @@ class ListenerAndSourceScene extends Scene { private tapPrompt!: Text; private hud!: ReturnType; - override async load(loader): Promise { - // A continuous music loop, not a one-shot: spatialization is only - // audible while there is sustained signal to pan/attenuate. - await loader.load(Sound, { source: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - this.sound = new Sound(loader.get(Sound, 'source').audioBuffer, { + // A continuous music loop, not a one-shot: spatialization is only + // audible while there is sustained signal to pan/attenuate. The derived + // Sound below reads .audioBuffer synchronously, so await load() instead + // of the deferred get() (whose placeholder audioBuffer is null until fill). + // The explicit Sound token also hits a compile-time overload ambiguity + // with the Json token form (both resolve zero-arg-constructible + // instance types), so the awaited result is cast — the runtime + // seamless/factory resolution is unaffected. + const source = (await this.loader.load(Sound, 'audio/demo-loop-main.ogg')) as Sound; + this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, diff --git a/examples/spatial-audio/moving-source.ts b/examples/spatial-audio/moving-source.ts index 8d4b12dfd..8aff43574 100644 --- a/examples/spatial-audio/moving-source.ts +++ b/examples/spatial-audio/moving-source.ts @@ -38,16 +38,19 @@ class MovingSourceScene extends Scene { private tapPrompt!: Text; private hud!: ReturnType; - override async load(loader): Promise { - // A continuous music loop, not a one-shot: spatialization is only - // audible while there is sustained signal to pan/attenuate. - await loader.load(Sound, { tone: 'audio/demo-loop-main.ogg' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - this.sound = new Sound(loader.get(Sound, 'tone').audioBuffer, { + // A continuous music loop, not a one-shot: spatialization is only + // audible while there is sustained signal to pan/attenuate. The derived + // Sound below reads .audioBuffer synchronously, so await load() instead + // of the deferred get() (whose placeholder audioBuffer is null until fill). + // The explicit Sound token also hits a compile-time overload ambiguity + // with the Json token form (both resolve zero-arg-constructible + // instance types), so the awaited result is cast — the runtime + // seamless/factory resolution is unaffected. + const source = (await this.loader.load(Sound, 'audio/demo-loop-main.ogg')) as Sound; + this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, From cb7e44cf3a52d0b37256fb4c8d99f4a9df7ba796 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:05:36 +0200 Subject: [PATCH 03/85] refactor(examples)!: migrate batch C (filters/rt/geometry) + drop unload() --- examples/filters/blur-filter.ts | 8 ++------ examples/filters/chromatic-aberration.ts | 8 ++------ examples/filters/color-filter.ts | 8 ++------ examples/filters/crt-scanlines.ts | 8 ++------ examples/filters/custom-fragment-shader.ts | 8 ++------ examples/filters/filter-stack.ts | 8 ++------ examples/filters/noise-vignette.ts | 8 ++------ examples/filters/palette-cycling.ts | 8 ++------ examples/geometry-graphics/gradient.ts | 6 +----- examples/geometry-graphics/graphics-gradient.ts | 6 +----- examples/geometry-graphics/graphics-primitives.ts | 4 ---- examples/geometry-graphics/immediate-mode-rendering.ts | 4 ---- examples/geometry-graphics/infinite-grid.ts | 8 ++------ examples/geometry-graphics/mesh-deformed-grid.ts | 8 ++------ examples/geometry-graphics/mesh-textured-quad.ts | 8 ++------ examples/render-targets/bloom-lite.ts | 8 ++------ examples/render-targets/render-to-texture.ts | 8 ++------ examples/render-targets/trail-feedback.ts | 8 ++------ examples/render-targets/water-mirror.ts | 8 ++------ 19 files changed, 32 insertions(+), 108 deletions(-) diff --git a/examples/filters/blur-filter.ts b/examples/filters/blur-filter.ts index cf3758708..e21d9cb5c 100644 --- a/examples/filters/blur-filter.ts +++ b/examples/filters/blur-filter.ts @@ -24,15 +24,11 @@ class BlurFilterScene extends Scene { private panel!: ReturnType; private slider!: ReturnType['addSlider']>; - override async load(loader): Promise { - await loader.load(Texture, { grid: PIXEL_GRID }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.blur = new BlurFilter({ radius: 4, quality: 2 }); - this.sprite = new Sprite(loader.get(Texture, 'grid')).setAnchor(0.5).setScale(4.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, PIXEL_GRID)).setAnchor(0.5).setScale(4.5).setPosition(width / 2, height / 2); this.sprite.filters = [this.blur]; this.hud = mountControls({ diff --git a/examples/filters/chromatic-aberration.ts b/examples/filters/chromatic-aberration.ts index d29ef1a55..e25929245 100644 --- a/examples/filters/chromatic-aberration.ts +++ b/examples/filters/chromatic-aberration.ts @@ -42,18 +42,14 @@ class ChromaticAberrationScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { checker: CHECKER }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.filter = app.backend.backendType === RenderBackendType.WebGpu ? new WebGpuShaderFilter({ fragmentSource: wgsl, uniforms: { uOffset: 0 } }) : new WebGl2ShaderFilter({ fragmentSource: glsl, uniforms: { uOffset: 0 } }); - this.sprite = new Sprite(loader.get(Texture, 'checker')).setAnchor(0.5).setScale(2.6).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, CHECKER)).setAnchor(0.5).setScale(2.6).setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; // The HUD must exist before applyIntensity() runs — it calls hud.setStatus(). diff --git a/examples/filters/color-filter.ts b/examples/filters/color-filter.ts index cf2440d56..f9b2c8ae3 100644 --- a/examples/filters/color-filter.ts +++ b/examples/filters/color-filter.ts @@ -58,14 +58,10 @@ class ColorFilterScene extends Scene { private hud!: ReturnType; private cycle!: ReturnType['addCycle']>; - override async load(loader): Promise { - await loader.load(Texture, { hueRamp: HUE_RAMP }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'hueRamp')).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, HUE_RAMP)).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); // The built-in ColorFilter multiplies by a solid colour (warm tint here). this.tint = new ColorFilter(new Color(255, 160, 120)); diff --git a/examples/filters/crt-scanlines.ts b/examples/filters/crt-scanlines.ts index e921a96b5..5f4dd5bd1 100644 --- a/examples/filters/crt-scanlines.ts +++ b/examples/filters/crt-scanlines.ts @@ -27,18 +27,14 @@ class CrtScanlinesScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { grid: PIXEL_GRID }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.filter = app.backend.backendType === RenderBackendType.WebGpu ? new WebGpuShaderFilter({ fragmentSource: wgsl }) : new WebGl2ShaderFilter({ fragmentSource: glsl }); - this.sprite = new Sprite(loader.get(Texture, 'grid')).setAnchor(0.5).setScale(5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, PIXEL_GRID)).setAnchor(0.5).setScale(5).setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ diff --git a/examples/filters/custom-fragment-shader.ts b/examples/filters/custom-fragment-shader.ts index 978e923bb..e9639195b 100644 --- a/examples/filters/custom-fragment-shader.ts +++ b/examples/filters/custom-fragment-shader.ts @@ -37,18 +37,14 @@ class CustomFragmentShaderScene extends Scene { private sprite!: Sprite; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { hueRamp: HUE_RAMP }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.filter = app.backend.backendType === RenderBackendType.WebGpu ? new WebGpuShaderFilter({ fragmentSource: wgsl, uniforms: { uTime: 0 } }) : new WebGl2ShaderFilter({ fragmentSource: glsl, uniforms: { uTime: 0 } }); - this.sprite = new Sprite(loader.get(Texture, 'hueRamp')).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, HUE_RAMP)).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ diff --git a/examples/filters/filter-stack.ts b/examples/filters/filter-stack.ts index 4a377a3e0..61ba9aed4 100644 --- a/examples/filters/filter-stack.ts +++ b/examples/filters/filter-stack.ts @@ -28,14 +28,10 @@ class FilterStackScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'ramp')).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, PRIMARY_RAMP)).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); this.blur = new BlurFilter({ radius: 4, quality: 2 }); this.tint = new ColorFilter(new Color(140, 210, 255)); this.custom = diff --git a/examples/filters/noise-vignette.ts b/examples/filters/noise-vignette.ts index 70645cf46..f6b0fc618 100644 --- a/examples/filters/noise-vignette.ts +++ b/examples/filters/noise-vignette.ts @@ -40,11 +40,7 @@ class NoiseVignetteScene extends Scene { private hud!: ReturnType; private panel!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { grid: UV_GRID }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.filter = @@ -53,7 +49,7 @@ class NoiseVignetteScene extends Scene { : new WebGl2ShaderFilter({ fragmentSource: glsl, uniforms: { uTime: 0, uIntensity: this.intensity } }); // Fill the whole 16:9 frame so the post effect covers the viewport. - const texture = loader.get(Texture, 'grid'); + const texture = this.loader.get(Texture, UV_GRID); this.sprite = new Sprite(texture).setAnchor(0.5).setPosition(width / 2, height / 2); this.sprite.width = width; diff --git a/examples/filters/palette-cycling.ts b/examples/filters/palette-cycling.ts index 7e7a39f6f..6c70984b5 100644 --- a/examples/filters/palette-cycling.ts +++ b/examples/filters/palette-cycling.ts @@ -43,17 +43,13 @@ class PaletteCyclingScene extends Scene { private offset = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.palette = LutFilter.fromImage(buildPaletteCanvas(0)); this.filter = new LutFilter({ mode: '1d' }).setLut(this.palette); - this.sprite = new Sprite(loader.get(Texture, 'ramp')).setAnchor(0.5).setScale(4); + this.sprite = new Sprite(this.loader.get(Texture, PRIMARY_RAMP)).setAnchor(0.5).setScale(4); this.sprite.setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; diff --git a/examples/geometry-graphics/gradient.ts b/examples/geometry-graphics/gradient.ts index 9ed786952..176e3e5a8 100644 --- a/examples/geometry-graphics/gradient.ts +++ b/examples/geometry-graphics/gradient.ts @@ -58,7 +58,7 @@ class GradientScene extends Scene { context.render(this.orb); } - override unload(): void { + override destroy(): void { this.background?.texture?.destroy(); this.orb?.texture?.destroy(); this.background?.destroy(); @@ -66,10 +66,6 @@ class GradientScene extends Scene { this.backgroundGradient?.destroy(); this.orbGradient?.destroy(); } - - override destroy(): void { - this.unload(); - } } app.start(new GradientScene()).catch(() => { diff --git a/examples/geometry-graphics/graphics-gradient.ts b/examples/geometry-graphics/graphics-gradient.ts index 72d2fb60e..680dc953f 100644 --- a/examples/geometry-graphics/graphics-gradient.ts +++ b/examples/geometry-graphics/graphics-gradient.ts @@ -80,12 +80,8 @@ class GraphicsGradientScene extends Scene { context.render(this.sceneRoot); } - override unload(): void { - this.sceneRoot?.destroy(); - } - override destroy(): void { - this.unload(); + this.sceneRoot?.destroy(); } } diff --git a/examples/geometry-graphics/graphics-primitives.ts b/examples/geometry-graphics/graphics-primitives.ts index 78ae3f1f7..cf2dcdbfc 100644 --- a/examples/geometry-graphics/graphics-primitives.ts +++ b/examples/geometry-graphics/graphics-primitives.ts @@ -54,10 +54,6 @@ class GraphicsPrimitivesScene extends Scene { context.render(this.sceneRoot); } - override unload(): void { - this.sceneRoot?.destroy(); - } - override destroy(): void { this.sceneRoot?.destroy(); } diff --git a/examples/geometry-graphics/immediate-mode-rendering.ts b/examples/geometry-graphics/immediate-mode-rendering.ts index a9739dcc5..32bab53bb 100644 --- a/examples/geometry-graphics/immediate-mode-rendering.ts +++ b/examples/geometry-graphics/immediate-mode-rendering.ts @@ -223,10 +223,6 @@ class ImmediateModeScene extends Scene { this.hud.setStatus(`${path} · ${FIELD_COUNT} sparks · ${GEAR_COUNT} gears · drawCalls: ${drawCalls}`); } - override unload(): void { - this.dispose(); - } - override destroy(): void { this.dispose(); } diff --git a/examples/geometry-graphics/infinite-grid.ts b/examples/geometry-graphics/infinite-grid.ts index ee6116627..fc36387b3 100644 --- a/examples/geometry-graphics/infinite-grid.ts +++ b/examples/geometry-graphics/infinite-grid.ts @@ -62,15 +62,11 @@ class InfiniteGridScene extends Scene { private sprite!: Sprite; private filter!: WebGl2ShaderFilter | WebGpuShaderFilter; - override async load(loader): Promise { - await loader.load(Texture, { uvGrid: 'image/uv-grid-256.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.view = new View(0, 0, width, height); - this.sprite = new Sprite(loader.get(Texture, 'uvGrid')); + this.sprite = new Sprite(this.loader.get(Texture, 'image/uv-grid-256.png')); this.sprite.width = width; this.sprite.height = height; this.filter = diff --git a/examples/geometry-graphics/mesh-deformed-grid.ts b/examples/geometry-graphics/mesh-deformed-grid.ts index a69e849b2..8f830a153 100644 --- a/examples/geometry-graphics/mesh-deformed-grid.ts +++ b/examples/geometry-graphics/mesh-deformed-grid.ts @@ -59,11 +59,7 @@ class MeshDeformedGridScene extends Scene { private mesh!: Mesh; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { uvGrid: UV_GRID }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; const grid = buildGrid(); @@ -72,7 +68,7 @@ class MeshDeformedGridScene extends Scene { vertices: grid.vertices, uvs: grid.uvs, indices: grid.indices, - texture: loader.get(Texture, 'uvGrid'), + texture: this.loader.get(Texture, UV_GRID), }); this.mesh.setPosition((width / 2) | 0, (height / 2) | 0); } diff --git a/examples/geometry-graphics/mesh-textured-quad.ts b/examples/geometry-graphics/mesh-textured-quad.ts index b71b63cc6..d1bc4722e 100644 --- a/examples/geometry-graphics/mesh-textured-quad.ts +++ b/examples/geometry-graphics/mesh-textured-quad.ts @@ -16,11 +16,7 @@ const HALF = 300; class MeshTexturedQuadScene extends Scene { private quad!: Mesh; - override async load(loader): Promise { - await loader.load(Texture, { uvGrid: UV_GRID }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.quad = new Mesh({ @@ -36,7 +32,7 @@ class MeshTexturedQuadScene extends Scene { ]), uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), indices: new Uint16Array([0, 1, 2, 0, 2, 3]), - texture: loader.get(Texture, 'uvGrid'), + texture: this.loader.get(Texture, UV_GRID), }); this.quad.setPosition((width / 2) | 0, (height / 2) | 0); diff --git a/examples/render-targets/bloom-lite.ts b/examples/render-targets/bloom-lite.ts index 124e191fc..1c4a5c0f9 100644 --- a/examples/render-targets/bloom-lite.ts +++ b/examples/render-targets/bloom-lite.ts @@ -24,17 +24,13 @@ class BloomLiteScene extends Scene { private pipeline!: RenderPipeline; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.baseRt = new RenderTexture(width, height); this.glowRt = new RenderTexture(width, height); this.blurredRt = new RenderTexture(width, height); - this.bunny = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(1.9); + this.bunny = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setScale(1.9); this.baseSprite = new Sprite(this.baseRt); this.glowSprite = new Sprite(this.blurredRt).setTint(new Color(255, 255, 255, 0.8)).setBlendMode(BlendModes.Additive); this.blur = new BlurFilter({ radius: 10, quality: 2 }); diff --git a/examples/render-targets/render-to-texture.ts b/examples/render-targets/render-to-texture.ts index d216b3df7..c7c8ed635 100644 --- a/examples/render-targets/render-to-texture.ts +++ b/examples/render-targets/render-to-texture.ts @@ -18,14 +18,10 @@ class RenderToTextureScene extends Scene { private renderTexture!: RenderTexture; private renderSprite!: Sprite; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.container = this.createBunnyContainer(loader.get(Texture, 'bunny')); + this.container = this.createBunnyContainer(this.loader.get(Texture, 'image/ship-a.png')); this.renderTexture = this.createRenderTexture(this.container); diff --git a/examples/render-targets/trail-feedback.ts b/examples/render-targets/trail-feedback.ts index 0e95a3cbb..a3256fb24 100644 --- a/examples/render-targets/trail-feedback.ts +++ b/examples/render-targets/trail-feedback.ts @@ -26,16 +26,12 @@ class TrailFeedbackScene extends Scene { private forward = true; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.rtA = new RenderTexture(width, height); this.rtB = new RenderTexture(width, height); - this.bunny = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5); + this.bunny = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5); // A 93%-alpha copy of the source target = the decaying trail. const decayA = new Sprite(this.rtA).setTint(new Color(255, 255, 255, 0.93)); diff --git a/examples/render-targets/water-mirror.ts b/examples/render-targets/water-mirror.ts index da0547f23..bffd0f788 100644 --- a/examples/render-targets/water-mirror.ts +++ b/examples/render-targets/water-mirror.ts @@ -47,16 +47,12 @@ class WaterMirrorScene extends Scene { private pipeline!: RenderPipeline; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; const half = height / 2; this.rt = new RenderTexture(width, half); - this.source = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(width / 2, half / 2).setScale(2.6); + this.source = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, half / 2).setScale(2.6); // Flip the captured top half down into the bottom half for the mirrored reflection. this.mirror = new Sprite(this.rt).setPosition(0, height).setScale(1, -1); this.filter = From 18f0924c792c9ec03e268f637c46c8bca893b025 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:05:09 +0200 Subject: [PATCH 04/85] refactor(examples)!: migrate batch D (particles/perf/renderers) + drop unload() --- examples/custom-renderers/custom-render-pass.ts | 10 +++------- examples/custom-renderers/custom-triangle-renderer.ts | 4 ---- examples/particles/bonfire.ts | 10 +++------- examples/particles/cursor-attractor-particles.ts | 8 ++------ examples/particles/custom-wgsl-module.ts | 8 ++------ examples/particles/emitter-basics.ts | 8 ++------ examples/particles/fireworks.ts | 8 ++------ examples/particles/gpu-particles.ts | 8 ++------ examples/performance/backend-comparison.ts | 8 ++------ examples/performance/multi-texture-stress.ts | 4 ---- examples/performance/particle-stress.ts | 4 ---- examples/performance/sprite-stress.ts | 4 ---- 12 files changed, 18 insertions(+), 66 deletions(-) diff --git a/examples/custom-renderers/custom-render-pass.ts b/examples/custom-renderers/custom-render-pass.ts index 830c64534..3427472d4 100644 --- a/examples/custom-renderers/custom-render-pass.ts +++ b/examples/custom-renderers/custom-render-pass.ts @@ -20,19 +20,15 @@ class CustomRenderPassScene extends Scene { private pipeline!: RenderPipeline; private angle = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.back = new Sprite(loader.get(Texture, 'bunny')) + this.back = new Sprite(this.loader.get(Texture, 'image/ship-a.png')) .setAnchor(0.5) .setPosition(width / 2 - 200, height / 2) .setScale(2.2) .setTint(new Color(120, 170, 255)); - this.front = new Sprite(loader.get(Texture, 'bunny')) + this.front = new Sprite(this.loader.get(Texture, 'image/ship-a.png')) .setAnchor(0.5) .setPosition(width / 2 + 200, height / 2) .setScale(2.2) diff --git a/examples/custom-renderers/custom-triangle-renderer.ts b/examples/custom-renderers/custom-triangle-renderer.ts index 14f49bba0..4fffa751a 100644 --- a/examples/custom-renderers/custom-triangle-renderer.ts +++ b/examples/custom-renderers/custom-triangle-renderer.ts @@ -157,10 +157,6 @@ class CustomTriangleRendererScene extends Scene { this.triangleRenderer.draw(); } - override unload(): void { - this.triangleRenderer?.destroy(); - } - override destroy(): void { this.triangleRenderer?.destroy(); } diff --git a/examples/particles/bonfire.ts b/examples/particles/bonfire.ts index 3b014ee2a..256a2cc00 100644 --- a/examples/particles/bonfire.ts +++ b/examples/particles/bonfire.ts @@ -32,14 +32,10 @@ class BonfireScene extends Scene { private fireSystem!: ParticleSystem; private smokeSystem!: ParticleSystem; - override async load(loader): Promise { - await loader.load(Texture, { flame: assets.demo.textures.particleFlame, smoke: assets.demo.textures.particleSmoke }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.fireSystem = new ParticleSystem(loader.get(Texture, 'flame')); + this.fireSystem = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleFlame)); this.fireSystem.setPosition(width * 0.5, height * 0.75); this.fireSystem.setBlendMode(BlendModes.Additive); @@ -61,7 +57,7 @@ class BonfireScene extends Scene { ), ); - this.smokeSystem = new ParticleSystem(loader.get(Texture, 'smoke')); + this.smokeSystem = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleSmoke)); this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40); this.smokeSystem.setBlendMode(BlendModes.Normal); diff --git a/examples/particles/cursor-attractor-particles.ts b/examples/particles/cursor-attractor-particles.ts index 7dede8b42..96525d408 100644 --- a/examples/particles/cursor-attractor-particles.ts +++ b/examples/particles/cursor-attractor-particles.ts @@ -33,14 +33,10 @@ class CursorAttractorParticlesScene extends Scene { private mode: 'attract' | 'repel' = 'attract'; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 32000 }); + this.system = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleLight), { capacity: 32000 }); this.system.setPosition(width / 2, height / 2); this.system.addSpawnModule( diff --git a/examples/particles/custom-wgsl-module.ts b/examples/particles/custom-wgsl-module.ts index 38ce782a8..57eba2e43 100644 --- a/examples/particles/custom-wgsl-module.ts +++ b/examples/particles/custom-wgsl-module.ts @@ -66,14 +66,10 @@ class CustomWgslModuleScene extends Scene { private hud!: ReturnType; private reportedMode = false; - override async load(loader): Promise { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 26000 }); + this.system = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleLight), { capacity: 26000 }); this.system.setPosition(width / 2, height - 60); this.system.addSpawnModule( new RateSpawn({ diff --git a/examples/particles/emitter-basics.ts b/examples/particles/emitter-basics.ts index eff501a86..604912293 100644 --- a/examples/particles/emitter-basics.ts +++ b/examples/particles/emitter-basics.ts @@ -29,14 +29,10 @@ class EmitterBasicsScene extends Scene { private system!: ParticleSystem; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 4000 }); + this.system = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleLight), { capacity: 4000 }); this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that diff --git a/examples/particles/fireworks.ts b/examples/particles/fireworks.ts index 690403944..b845ffc24 100644 --- a/examples/particles/fireworks.ts +++ b/examples/particles/fireworks.ts @@ -76,15 +76,11 @@ class FireworksScene extends Scene { private launchCount = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { star: assets.demo.textures.particleStar }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.canvasSize = new Size(width, height); - this.rocketTexture = loader.get(Texture, 'star'); + this.rocketTexture = this.loader.get(Texture, assets.demo.textures.particleStar); // Single explosion system shared by every detonation. We reposition and // re-tint a single BurstSpawn, then reset() it to fire one burst. diff --git a/examples/particles/gpu-particles.ts b/examples/particles/gpu-particles.ts index 76c1eb60a..b81e4d537 100644 --- a/examples/particles/gpu-particles.ts +++ b/examples/particles/gpu-particles.ts @@ -37,14 +37,10 @@ class GpuParticlesScene extends Scene { private system!: ParticleSystem; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { particle: 'image/particle-light.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: CAPACITY }); + this.system = new ParticleSystem(this.loader.get(Texture, 'image/particle-light.png'), { capacity: CAPACITY }); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( new RateSpawn({ diff --git a/examples/performance/backend-comparison.ts b/examples/performance/backend-comparison.ts index 4dbb1d593..70a79678b 100644 --- a/examples/performance/backend-comparison.ts +++ b/examples/performance/backend-comparison.ts @@ -21,15 +21,11 @@ let backendType: 'webgl2' | 'webgpu' = 'webgpu'; class DemoScene extends Scene { private sprites!: { sprite: Sprite; vx: number; vy: number }[]; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.sprites = Array.from({ length: 2200 }, () => { - const sprite = new Sprite(loader.get(Texture, 'bunny')); + const sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')); sprite.setAnchor(0.5); sprite.setScale(0.35); sprite.setPosition(Math.random() * width, Math.random() * height); diff --git a/examples/performance/multi-texture-stress.ts b/examples/performance/multi-texture-stress.ts index fc37094bb..cc0e19113 100644 --- a/examples/performance/multi-texture-stress.ts +++ b/examples/performance/multi-texture-stress.ts @@ -89,10 +89,6 @@ class MultiTextureStressScene extends Scene { context.render(this.spriteLayer); } - override unload(): void { - this.spriteLayer?.destroy(); - } - override destroy(): void { this.spriteLayer?.destroy(); } diff --git a/examples/performance/particle-stress.ts b/examples/performance/particle-stress.ts index f848815fa..54a3d35a8 100644 --- a/examples/performance/particle-stress.ts +++ b/examples/performance/particle-stress.ts @@ -178,10 +178,6 @@ class ParticleStressScene extends Scene { } } - override unload(): void { - this.destroySystems(); - } - override destroy(): void { this.destroySystems(); } diff --git a/examples/performance/sprite-stress.ts b/examples/performance/sprite-stress.ts index 629e3c8d9..957291441 100644 --- a/examples/performance/sprite-stress.ts +++ b/examples/performance/sprite-stress.ts @@ -83,10 +83,6 @@ class SpriteStressScene extends Scene { context.render(this.spriteLayer); } - override unload(): void { - this.spriteLayer?.destroy(); - } - override destroy(): void { this.spriteLayer?.destroy(); } From efe8960b6ecd04a5736ac47f9cd496da287b7e18 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:09:45 +0200 Subject: [PATCH 05/85] refactor(examples)!: migrate batch E (scene-graph/tweens/physics) off Scene lifecycle hooks --- examples/physics/sprite-follows-body.ts | 17 ++++++----------- examples/physics/tiled-map-physics-actor.ts | 17 ++++++----------- examples/scene-graph/containers.ts | 18 ++++++------------ .../scene-graph/local-vs-global-transform.ts | 8 ++------ examples/scene-graph/masks.ts | 8 ++------ examples/scene-graph/pivot-and-anchor.ts | 8 ++------ examples/scene-graph/z-ordering.ts | 9 +++------ examples/tweens-animation/frame-animation.ts | 13 ++++--------- .../tweens-animation/interrupt-and-replace.ts | 8 ++------ examples/tweens-animation/tween-basics.ts | 8 ++------ examples/tweens-animation/tween-chains.ts | 8 ++------ examples/tweens-animation/tween-from-array.ts | 8 ++------ examples/tweens-animation/tween-with-yoyo.ts | 8 ++------ 13 files changed, 41 insertions(+), 97 deletions(-) diff --git a/examples/physics/sprite-follows-body.ts b/examples/physics/sprite-follows-body.ts index 31f94f609..7544a86f5 100644 --- a/examples/physics/sprite-follows-body.ts +++ b/examples/physics/sprite-follows-body.ts @@ -26,21 +26,16 @@ class SpriteFollowsBodyScene extends Scene { private settled = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { - characters: assets.demo.spritesheets.platformerCharacters.image, - pixel: assets.demo.textures.pixelWhite, - }); - await loader.load(Json, { characters: assets.demo.spritesheets.platformerCharacters.data }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; // Gravity in px/s², +Y down — matches the engine's screen space. this.world = new PhysicsWorld({ gravity: { x: 0, y: 1400 } }); - const characters = new Spritesheet(loader.get(Texture, 'characters'), loader.get(Json, 'characters').value as SpritesheetData); + const characters = new Spritesheet( + this.loader.get(Texture, assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Json, assets.demo.spritesheets.platformerCharacters.data)) as SpritesheetData, + ); this.floorY = height - 80; @@ -50,7 +45,7 @@ class SpriteFollowsBodyScene extends Scene { const floorWidth = width - 120; const floorHeight = 48; - this.floor = new Sprite(loader.get(Texture, 'pixel')).setAnchor(0.5); + this.floor = new Sprite(this.loader.get(Texture, assets.demo.textures.pixelWhite)).setAnchor(0.5); this.floor.width = floorWidth; this.floor.height = floorHeight; this.floor.tint = new Color(70, 92, 120); diff --git a/examples/physics/tiled-map-physics-actor.ts b/examples/physics/tiled-map-physics-actor.ts index 007b46f0b..51c563a7d 100644 --- a/examples/physics/tiled-map-physics-actor.ts +++ b/examples/physics/tiled-map-physics-actor.ts @@ -44,21 +44,13 @@ class TiledMapPhysicsActorScene extends Scene { private debug!: PhysicsDebugDraw; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { - tiles: assets.demo.tilesets.map.image, - characters: assets.demo.spritesheets.platformerCharacters.image, - }); - await loader.load(Json, { characters: assets.demo.spritesheets.platformerCharacters.data }); - } - - override init(loader): void { + override async init(): Promise { this.world = new PhysicsWorld({ gravity: { x: 0, y: 1500 } }); // ── Tileset + a single ground tile layer ────────────────────────── // The map-pack tilesheet is a uniform 64×64 grid (17 columns), so it // works as a classic grid tileset. We only need one solid-looking tile. - const tilesTexture = loader.get(Texture, 'tiles'); + const tilesTexture = this.loader.get(Texture, assets.demo.tilesets.map.image); const tileset = new TileSet({ name: 'map', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -129,7 +121,10 @@ class TiledMapPhysicsActorScene extends Scene { } // ── Dynamic actor ───────────────────────────────────────────────── - const characters = new Spritesheet(loader.get(Texture, 'characters'), loader.get(Json, 'characters').value as SpritesheetData); + const characters = new Spritesheet( + this.loader.get(Texture, assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Json, assets.demo.spritesheets.platformerCharacters.data)) as SpritesheetData, + ); this.actor = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.actorBody = this.world.attach(this.actor, { diff --git a/examples/scene-graph/containers.ts b/examples/scene-graph/containers.ts index f205cf1c8..2916ebe8e 100644 --- a/examples/scene-graph/containers.ts +++ b/examples/scene-graph/containers.ts @@ -17,27 +17,21 @@ class ContainersScene extends Scene { private rainbow!: Sprite; private bunnies!: Container; - override async load(loader): Promise { - await loader.load(Texture, { - bunny: 'image/ship-a.png', - rainbow: 'image/hue-ramp.png', - }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; + const { bunny, rainbow } = this.loader.get(Texture, { bunny: 'image/ship-a.png', rainbow: 'image/hue-ramp.png' }); - this.rainbow = new Sprite(loader.get(Texture, 'rainbow')); + this.rainbow = new Sprite(rainbow); this.bunnies = new Container(); this.bunnies.setPosition((width / 2) | 0, (height / 2) | 0); for (let i = 0; i < 25; i++) { - const bunny = new Sprite(loader.get(Texture, 'bunny')); + const sprite = new Sprite(bunny); - bunny.setPosition((i % 5) * (bunny.width + 15), ((i / 5) | 0) * (bunny.height + 10)); + sprite.setPosition((i % 5) * (sprite.width + 15), ((i / 5) | 0) * (sprite.height + 10)); - this.bunnies.addChild(bunny); + this.bunnies.addChild(sprite); } this.bunnies.setAnchor(0.5); diff --git a/examples/scene-graph/local-vs-global-transform.ts b/examples/scene-graph/local-vs-global-transform.ts index 48c186f1a..cfc5fe85b 100644 --- a/examples/scene-graph/local-vs-global-transform.ts +++ b/examples/scene-graph/local-vs-global-transform.ts @@ -20,13 +20,9 @@ class LocalVsGlobalTransformScene extends Scene { private localLabel!: Text; private globalLabel!: Text; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'bunny'); + const texture = this.loader.get(Texture, 'image/ship-a.png'); this.parent = new Container().setPosition(width / 4, height / 2); this.localSprite = new Sprite(texture) diff --git a/examples/scene-graph/masks.ts b/examples/scene-graph/masks.ts index 7e219346b..8dc5cb8e7 100644 --- a/examples/scene-graph/masks.ts +++ b/examples/scene-graph/masks.ts @@ -18,13 +18,9 @@ class MasksScene extends Scene { private gfxSprite!: Sprite; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { alphaRings: ALPHA_RINGS }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - const tex = loader.get(Texture, 'alphaRings'); + const tex = this.loader.get(Texture, ALPHA_RINGS); this.rectSprite = new Sprite(tex); this.rectSprite.setScale(1); diff --git a/examples/scene-graph/pivot-and-anchor.ts b/examples/scene-graph/pivot-and-anchor.ts index 41f2ab8e8..c9e2d5084 100644 --- a/examples/scene-graph/pivot-and-anchor.ts +++ b/examples/scene-graph/pivot-and-anchor.ts @@ -26,14 +26,10 @@ class PivotAndAnchorScene extends Scene { private mode = 0; private timer = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setPosition(width / 2, height / 2); this.pivotMarker = new Graphics(); this.label = new Text('', { fillColor: Color.white, fontSize: 18 }); this.label.setPosition(20, 20); diff --git a/examples/scene-graph/z-ordering.ts b/examples/scene-graph/z-ordering.ts index 5cee41f59..15156da0c 100644 --- a/examples/scene-graph/z-ordering.ts +++ b/examples/scene-graph/z-ordering.ts @@ -18,12 +18,9 @@ class ZOrderingScene extends Scene { private label!: Text; private sprites!: Sprite[]; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; + const texture = this.loader.get(Texture, 'image/ship-a.png'); this.group = new Container(); this.label = new Text('Press 1, 2, 3 — front: 3 (blue)', { fillColor: Color.white, fontSize: 18 }); @@ -32,7 +29,7 @@ class ZOrderingScene extends Scene { // Large, tightly spaced sprites so they clearly overlap — otherwise a // zIndex change has nothing visible to reorder. this.sprites = [0, 1, 2].map(index => { - const sprite = new Sprite(loader.get(Texture, 'bunny')) + const sprite = new Sprite(texture) .setAnchor(0.5) .setScale(2.4) .setPosition(width / 2 - 90 + index * 90, height / 2); diff --git a/examples/tweens-animation/frame-animation.ts b/examples/tweens-animation/frame-animation.ts index ebf049667..a5de8662f 100644 --- a/examples/tweens-animation/frame-animation.ts +++ b/examples/tweens-animation/frame-animation.ts @@ -1,4 +1,4 @@ -import { AnimatedSprite, Application, Color, Json, Scene, Spritesheet, Texture } from '@codexo/exojs'; +import { AnimatedSprite, Application, Color, Json, Scene, Spritesheet, type SpritesheetData, Texture } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -21,15 +21,10 @@ class FrameAnimationScene extends Scene { private frameCount = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { characters: 'image/platformer-characters.png' }); - await loader.load(Json, { characters: 'json/platformer-characters.json' }); - } - - override init(loader): void { + override async init(): Promise { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'characters'); - const data = loader.get(Json, 'characters').value; + const texture = this.loader.get(Texture, 'image/platformer-characters.png'); + const data = (await this.loader.load(Json, 'json/platformer-characters.json')) as SpritesheetData; const sheet = new Spritesheet(texture, data); const walkFrames = ['character_beige_walk_a', 'character_beige_walk_b'].map(name => sheet.getFrame(name)); diff --git a/examples/tweens-animation/interrupt-and-replace.ts b/examples/tweens-animation/interrupt-and-replace.ts index dabadcb39..52b0d56f0 100644 --- a/examples/tweens-animation/interrupt-and-replace.ts +++ b/examples/tweens-animation/interrupt-and-replace.ts @@ -17,14 +17,10 @@ class InterruptAndReplaceScene extends Scene { private sprite!: Sprite; private moveTween: Tween | null = null; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); this.app.input.onPointerTap.add(pointer => { if (this.moveTween !== null) { this.moveTween.stop(); diff --git a/examples/tweens-animation/tween-basics.ts b/examples/tweens-animation/tween-basics.ts index f960230ef..d0c76bbd7 100644 --- a/examples/tweens-animation/tween-basics.ts +++ b/examples/tweens-animation/tween-basics.ts @@ -19,16 +19,12 @@ class TweenBasicsScene extends Scene { private forward!: Tween; private backward!: Tween; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; const left = width * 0.1; const right = width * 0.9; - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(left, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(left, height / 2); this.text = new Text('Tween running', { fillColor: Color.white, fontSize: 18 }); this.text.setPosition(20, 20); this.forward = this.app.tweens.create(this.sprite.position).to({ x: right }, 1.2); diff --git a/examples/tweens-animation/tween-chains.ts b/examples/tweens-animation/tween-chains.ts index 02eb33a1d..36db0321c 100644 --- a/examples/tweens-animation/tween-chains.ts +++ b/examples/tweens-animation/tween-chains.ts @@ -16,11 +16,7 @@ const app = new Application({ class TweenChainsScene extends Scene { private sprite!: Sprite; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // A rectangle centred in the frame, spread across the wider 16:9 space. const left = width / 2 - width * 0.28; @@ -28,7 +24,7 @@ class TweenChainsScene extends Scene { const top = height / 2 - height * 0.28; const bottom = height / 2 + height * 0.28; - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(left, top); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(left, top); const a = this.app.tweens .create(this.sprite.position) diff --git a/examples/tweens-animation/tween-from-array.ts b/examples/tweens-animation/tween-from-array.ts index 1855dfca4..ff6349c70 100644 --- a/examples/tweens-animation/tween-from-array.ts +++ b/examples/tweens-animation/tween-from-array.ts @@ -30,15 +30,11 @@ class TweenFromArrayScene extends Scene { private sprite!: Sprite; private waypoints: Array<{ x: number; y: number }> = []; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.waypoints = waypointFractions.map(({ fx, fy }) => ({ x: fx * width, y: fy * height })); - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); this.buildPath(); } diff --git a/examples/tweens-animation/tween-with-yoyo.ts b/examples/tweens-animation/tween-with-yoyo.ts index e5c232683..344fadc12 100644 --- a/examples/tweens-animation/tween-with-yoyo.ts +++ b/examples/tweens-animation/tween-with-yoyo.ts @@ -16,14 +16,10 @@ const app = new Application({ class TweenWithYoyoScene extends Scene { private sprite!: Sprite; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); this.app.tweens.create(this.sprite.scale).to({ x: 1.5, y: 1.5 }, 0.8).yoyo(true).repeat(-1).start(); this.app.tweens.create(this.sprite).to({ rotation: 20 }, 0.8).yoyo(true).repeat(-1).start(); } From 3fa0f6d38c986f213edec73058dfbc7565c28ce1 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:09:12 +0200 Subject: [PATCH 06/85] refactor(examples)!: migrate batch F (showcase) off Scene lifecycle hooks --- examples/showcase/audio-reactive-particles.ts | 11 +++-------- examples/showcase/audio-visualisation.ts | 8 ++------ examples/showcase/boss-intro-cinematic.ts | 11 +++-------- examples/showcase/color-grading.ts | 8 ++------ examples/showcase/damage-flash.ts | 8 ++------ examples/showcase/dialog-system.ts | 11 +++-------- examples/showcase/gamepad-spaceship.ts | 10 +++------- examples/showcase/loading-progress-with-shader.ts | 8 ++------ examples/showcase/low-band-camera-shake.ts | 11 +++-------- examples/showcase/pause-blur.ts | 8 ++------ examples/showcase/rectangles-collision.ts | 8 ++------ examples/showcase/screen-shake-on-explosion.ts | 8 ++------ examples/showcase/typewriter-text.ts | 8 ++------ examples/showcase/vinyl-record.ts | 8 ++------ 14 files changed, 33 insertions(+), 93 deletions(-) diff --git a/examples/showcase/audio-reactive-particles.ts b/examples/showcase/audio-reactive-particles.ts index 0f5ef1b0b..9ea2b00b3 100644 --- a/examples/showcase/audio-reactive-particles.ts +++ b/examples/showcase/audio-reactive-particles.ts @@ -35,15 +35,10 @@ class AudioReactiveParticlesScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(AudioStream, assets.demo.audio.musicLoop); // Two parallel taps of the same track: the analyser gives per-band // energy (drives emission), the detector gives beats (recolours). @@ -51,7 +46,7 @@ class AudioReactiveParticlesScene extends Scene { this.detector = new BeatDetector(); this.detector.source = this.app.audio.music; - this.ps = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 6000 }); + this.ps = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleLight), { capacity: 6000 }); this.ps.setPosition(width / 2, height / 2); // The rate (density) and the cone speed range (spread) are mutated every diff --git a/examples/showcase/audio-visualisation.ts b/examples/showcase/audio-visualisation.ts index 1d28ee3c4..452124508 100644 --- a/examples/showcase/audio-visualisation.ts +++ b/examples/showcase/audio-visualisation.ts @@ -26,14 +26,10 @@ class AudioVisualisationScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { example: assets.demo.audio.musicLoop }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'example'); + this.music = this.loader.get(AudioStream, assets.demo.audio.musicLoop); // One analyser tap for spectrum/waveform, one beat detector for the // beat-pulse ring. Both read the music bus the stream plays through, diff --git a/examples/showcase/boss-intro-cinematic.ts b/examples/showcase/boss-intro-cinematic.ts index b4a7af92c..59555bc30 100644 --- a/examples/showcase/boss-intro-cinematic.ts +++ b/examples/showcase/boss-intro-cinematic.ts @@ -28,12 +28,7 @@ class BossIntroCinematicScene extends Scene { private width = 0; private height = 0; - override async load(loader): Promise { - await loader.load(Texture, { boss: assets.demo.textures.shipA }); - await loader.load(AudioStream, { track: assets.demo.music.loopMain }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.width = width; this.height = height; @@ -46,12 +41,12 @@ class BossIntroCinematicScene extends Scene { this.title = new Text('', { fillColor: Color.white, fontSize: 56, fontWeight: 'bold' }); this.title.setPosition(width * 0.12, height * 0.2); this.titleState = { count: 0 }; - this.boss = new Sprite(loader.get(Texture, 'boss')) + this.boss = new Sprite(this.loader.get(Texture, assets.demo.textures.shipA)) .setAnchor(0.5) .setScale(0.4) .setPosition(width * 0.62, height / 2) .setTint(new Color(255, 130, 130)); - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(AudioStream, assets.demo.music.loopMain); this.hud = mountControls({ title: 'Boss Intro Cinematic', diff --git a/examples/showcase/color-grading.ts b/examples/showcase/color-grading.ts index e09895c01..34d9a5008 100644 --- a/examples/showcase/color-grading.ts +++ b/examples/showcase/color-grading.ts @@ -84,17 +84,13 @@ class ColorGradingScene extends Scene { private hud!: ReturnType; private cycle!: { set(value: number): void }; - override async load(loader): Promise { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.luts = LOOKS.map(look => LutFilter.fromImage(buildLut3D(look.transform))); this.filter = new LutFilter({ mode: '3d', size: LUT_SIZE }).setLut(this.luts[0]); - this.sprite = new Sprite(loader.get(Texture, 'ramp')).setAnchor(0.5).setScale(3.5); + this.sprite = new Sprite(this.loader.get(Texture, PRIMARY_RAMP)).setAnchor(0.5).setScale(3.5); this.sprite.setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; diff --git a/examples/showcase/damage-flash.ts b/examples/showcase/damage-flash.ts index ed828fa5a..4cd79e184 100644 --- a/examples/showcase/damage-flash.ts +++ b/examples/showcase/damage-flash.ts @@ -22,15 +22,11 @@ class DamageFlashScene extends Scene { private hud!: ReturnType; private hits = 0; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.hit = new Signal(); - this.ship = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(2.2).setPosition(width / 2, height / 2); + this.ship = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setScale(2.2).setPosition(width / 2, height / 2); this.filterColor = new Color(255, 255, 255, 1); this.filter = new ColorFilter(this.filterColor); this.ship.filters = [this.filter]; diff --git a/examples/showcase/dialog-system.ts b/examples/showcase/dialog-system.ts index 102fef56a..4189027d6 100644 --- a/examples/showcase/dialog-system.ts +++ b/examples/showcase/dialog-system.ts @@ -38,17 +38,12 @@ class DialogSystemScene extends Scene { private done = false; private awaitingChoice = false; - override async load(loader): Promise { - await loader.load(Texture, { portrait: assets.demo.textures.shipA }); - await loader.load(Sound, { beep: assets.demo.sound.uiConfirm }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // Portrait sits on the left; the dialog column runs to its right and // fills the wider 16:9 frame as a classic VN bottom-third box. - this.portrait = new Sprite(loader.get(Texture, 'portrait')).setAnchor(0.5).setScale(2.4).setPosition(width * 0.16, height * 0.62); + this.portrait = new Sprite(this.loader.get(Texture, assets.demo.textures.shipA)).setAnchor(0.5).setScale(2.4).setPosition(width * 0.16, height * 0.62); const textX = width * 0.3; @@ -62,7 +57,7 @@ class DialogSystemScene extends Scene { this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); this.choicePrompt.setPosition(textX, height * 0.78); - this.beep = loader.get(Sound, 'beep'); + this.beep = this.loader.get(assets.demo.sound.uiConfirm); this.hud = mountControls({ title: 'Dialog System', diff --git a/examples/showcase/gamepad-spaceship.ts b/examples/showcase/gamepad-spaceship.ts index f6ade216a..9456b2017 100644 --- a/examples/showcase/gamepad-spaceship.ts +++ b/examples/showcase/gamepad-spaceship.ts @@ -53,19 +53,15 @@ class GamepadSpaceshipScene extends Scene { private connectPrompt!: Text; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: assets.demo.textures.shipA, particle: assets.demo.textures.particleSpark }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.ship = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(0.5).setPosition(width / 2, height / 2); + this.ship = new Sprite(this.loader.get(Texture, assets.demo.textures.shipA)).setAnchor(0.5).setScale(0.5).setPosition(width / 2, height / 2); this.engine = this.app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }); this.fx = new Graphics(); - this.particles = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 4000 }); + this.particles = new ParticleSystem(this.loader.get(Texture, assets.demo.textures.particleSpark), { capacity: 4000 }); this.burst = new BurstSpawn({ schedule: [{ time: 0, count: 60 }], lifetime: new Constant(0.5), diff --git a/examples/showcase/loading-progress-with-shader.ts b/examples/showcase/loading-progress-with-shader.ts index 99fa0b794..70608300b 100644 --- a/examples/showcase/loading-progress-with-shader.ts +++ b/examples/showcase/loading-progress-with-shader.ts @@ -34,17 +34,13 @@ class LoadingProgressWithShaderScene extends Scene { private ring!: Sprite; private filter!: WebGl2ShaderFilter | WebGpuShaderFilter; - override async load(loader): Promise { - await loader.load(Texture, { uvGrid: 'image/uv-grid-256.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.progress = { v: 0 }; this.label = new Text('0%', { fillColor: Color.white, fontSize: 42, align: 'center' }); this.label.setAnchor(0.5, 0.5).setPosition(width / 2, height / 2); - this.ring = new Sprite(loader.get(Texture, 'uvGrid')).setAnchor(0.5).setScale(2.4).setPosition(width / 2, height / 2); + this.ring = new Sprite(this.loader.get(Texture, 'image/uv-grid-256.png')).setAnchor(0.5).setScale(2.4).setPosition(width / 2, height / 2); this.filter = app.backend.backendType === RenderBackendType.WebGpu ? new WebGpuShaderFilter({ fragmentSource: wgsl, uniforms: { uProgress: 0 } }) diff --git a/examples/showcase/low-band-camera-shake.ts b/examples/showcase/low-band-camera-shake.ts index 20527236b..8d8f01fa0 100644 --- a/examples/showcase/low-band-camera-shake.ts +++ b/examples/showcase/low-band-camera-shake.ts @@ -21,18 +21,13 @@ class LowBandCameraShakeScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - await loader.load(Texture, { ship: assets.demo.textures.shipA }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(AudioStream, assets.demo.audio.musicLoop); this.analyser = new AudioAnalyser({ fftSize: 1024, source: this.app.audio.music }); this.view = new View(width / 2, height / 2, width, height); - this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(3).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, assets.demo.textures.shipA)).setAnchor(0.5).setScale(3).setPosition(width / 2, height / 2); this.hud = mountControls({ title: 'Low Band Camera Shake', diff --git a/examples/showcase/pause-blur.ts b/examples/showcase/pause-blur.ts index 34de52463..913ffae15 100644 --- a/examples/showcase/pause-blur.ts +++ b/examples/showcase/pause-blur.ts @@ -31,14 +31,10 @@ class GameScene extends Scene { private pauseLabel!: Label; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(2).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setScale(2).setPosition(width / 2, height / 2); this.addChild(this.sprite); // Pause overlay on the UI layer, hidden until paused. diff --git a/examples/showcase/rectangles-collision.ts b/examples/showcase/rectangles-collision.ts index d3a00419e..e37ffd8ff 100644 --- a/examples/showcase/rectangles-collision.ts +++ b/examples/showcase/rectangles-collision.ts @@ -23,13 +23,9 @@ class RectanglesCollisionScene extends Scene { private overlap!: Graphics; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { gradient: 'image/hue-ramp.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'gradient'); + const texture = this.loader.get(Texture, 'image/hue-ramp.png'); // Two axis-aligned rectangles (no rotation) so collision is a true AABB // test. Explicit width/height drive the sprite scale; anchor 0.5 keeps the diff --git a/examples/showcase/screen-shake-on-explosion.ts b/examples/showcase/screen-shake-on-explosion.ts index 2affa4292..950bb5f40 100644 --- a/examples/showcase/screen-shake-on-explosion.ts +++ b/examples/showcase/screen-shake-on-explosion.ts @@ -28,15 +28,11 @@ class ScreenShakeOnExplosionScene extends Scene { private burstPos!: Vector; private burst!: BurstSpawn; - override async load(loader): Promise { - await loader.load(Texture, { particle: 'image/particle-light.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.view = new View(width / 2, height / 2, width, height); - this.ps = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 5000 }); + this.ps = new ParticleSystem(this.loader.get(Texture, 'image/particle-light.png'), { capacity: 5000 }); this.ps.setPosition(width / 2, height / 2); this.burstPos = new Vector(0, 0); this.burst = new BurstSpawn({ diff --git a/examples/showcase/typewriter-text.ts b/examples/showcase/typewriter-text.ts index 41e618d08..9040259ff 100644 --- a/examples/showcase/typewriter-text.ts +++ b/examples/showcase/typewriter-text.ts @@ -22,14 +22,10 @@ class TypewriterTextScene extends Scene { private last = 0; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(Sound, { tick: 'audio/ui-click.ogg' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sound = loader.get(Sound, 'tick'); + this.sound = this.loader.get('audio/ui-click.ogg'); this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56, maxWidth: 900 }); this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2); this.state = { count: 0 }; diff --git a/examples/showcase/vinyl-record.ts b/examples/showcase/vinyl-record.ts index 81d93a48c..d44b1410c 100644 --- a/examples/showcase/vinyl-record.ts +++ b/examples/showcase/vinyl-record.ts @@ -23,14 +23,10 @@ class VinylRecordScene extends Scene { private hud!: ReturnType; private tapPrompt!: Text; - override async load(loader): Promise { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(AudioStream, assets.demo.audio.musicLoop); this.analyser = new AudioAnalyser({ fftSize: 1024, source: this.app.audio.music }); this.disc = new Graphics(); this.bars = new Graphics(); From d5a9acb1bc78d42a3dafa4e5157869db3827565a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 7 Jul 2026 20:06:53 +0200 Subject: [PATCH 07/85] refactor(examples)!: migrate batch G (input/debug/fonts) off Scene lifecycle hooks --- examples/debug-layer/asset-browser.ts | 8 ++------ examples/debug-layer/bounding-boxes.ts | 8 ++------ examples/debug-layer/performance-overlay.ts | 8 ++------ examples/debug-layer/pointer-and-hittest.ts | 8 ++------ examples/input/action-mapping.ts | 8 ++------ examples/input/gamepad.ts | 11 ++++------- examples/input/mouse-and-pointer.ts | 8 ++------ examples/input/multi-gamepad.ts | 8 ++------ examples/text-fonts/basic-text.ts | 6 ++---- examples/text-fonts/bitmap-text-basic.ts | 6 ++---- examples/text-fonts/web-fonts.ts | 6 ++---- 11 files changed, 24 insertions(+), 61 deletions(-) diff --git a/examples/debug-layer/asset-browser.ts b/examples/debug-layer/asset-browser.ts index 8400ae03f..fd054af60 100644 --- a/examples/debug-layer/asset-browser.ts +++ b/examples/debug-layer/asset-browser.ts @@ -171,13 +171,9 @@ class AssetBrowserScene extends Scene { loadedCats = new Set(); loadingCats = new Set(); - override async load(loader): Promise { - this.assetLoader = loader; + override async init(): Promise { + this.assetLoader = this.loader; await this.ensureCategory(this.cat); - } - - override init(loader): void { - this.assetLoader = loader; this.app.input.onPointerTap.add(p => this.onTap(p.x, p.y)); this.app.input.onPointerMove.add(p => this.onMove(p.x, p.y)); diff --git a/examples/debug-layer/bounding-boxes.ts b/examples/debug-layer/bounding-boxes.ts index c085ae8f3..8cb454f31 100644 --- a/examples/debug-layer/bounding-boxes.ts +++ b/examples/debug-layer/bounding-boxes.ts @@ -21,18 +21,14 @@ class BoundingBoxesScene extends Scene { private sprites!: { sprite: Sprite; speed: number }[]; private time = 0; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; const count = 7; const margin = width * 0.12; const step = (width - 2 * margin) / (count - 1); this.sprites = Array.from({ length: count }, (_, i) => { - const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.8); + const sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setScale(0.8); sprite.setPosition(margin + i * step, height / 2 + Math.sin(i) * 80); // The boundingBoxes layer walks the SCENE GRAPH (scene.root), so // the sprites must be attached to it — nodes that are only passed diff --git a/examples/debug-layer/performance-overlay.ts b/examples/debug-layer/performance-overlay.ts index b5b1d4157..cb169a8ad 100644 --- a/examples/debug-layer/performance-overlay.ts +++ b/examples/debug-layer/performance-overlay.ts @@ -21,11 +21,7 @@ class PerformanceOverlayScene extends Scene { private sprites!: { sprite: Sprite; vx: number; vy: number }[]; private layer!: Container; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; // All sprites share one texture, so adding them to a single container and @@ -34,7 +30,7 @@ class PerformanceOverlayScene extends Scene { // instead emit one draw call per sprite and tank the frame rate. this.layer = new Container(); this.sprites = Array.from({ length: 1600 }, () => { - const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.25); + const sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setScale(0.25); sprite.setPosition(Math.random() * width, Math.random() * height); this.layer.addChild(sprite); return { diff --git a/examples/debug-layer/pointer-and-hittest.ts b/examples/debug-layer/pointer-and-hittest.ts index ee8b41f64..2626a5cd9 100644 --- a/examples/debug-layer/pointer-and-hittest.ts +++ b/examples/debug-layer/pointer-and-hittest.ts @@ -21,16 +21,12 @@ debug.layers.pointerStack.visible = true; class PointerAndHittestScene extends Scene { private sprites!: Sprite[]; - override async load(loader): Promise { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.sprites = []; for (let i = 0; i < 5; i++) { - const sprite = new Sprite(loader.get(Texture, 'bunny')) + const sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')) .setAnchor(0.5) .setScale(1.2) .setPosition(width / 2 - 120 + i * 60, height / 2 - 20 + (i % 2) * 40); diff --git a/examples/input/action-mapping.ts b/examples/input/action-mapping.ts index b200a1cce..7410514b4 100644 --- a/examples/input/action-mapping.ts +++ b/examples/input/action-mapping.ts @@ -28,14 +28,10 @@ class ActionMappingScene extends Scene { private actions = { moveX: 0, moveY: 0, jump: false }; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; - this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); const pad0 = this.app.input.getGamepad(0); diff --git a/examples/input/gamepad.ts b/examples/input/gamepad.ts index cc494fdfd..2e5246f35 100644 --- a/examples/input/gamepad.ts +++ b/examples/input/gamepad.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, GamepadAxis, GamepadButton, Json, lerp, Scene, Sprite, Spritesheet, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Container, GamepadAxis, GamepadButton, Json, lerp, Scene, Sprite, Spritesheet, type SpritesheetData, Texture, Vector } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -24,13 +24,10 @@ class GamepadScene extends Scene { private status!: Sprite; private container!: Container; - override async load(loader): Promise { - await loader.load(Texture, { buttons: 'image/buttons.png' }); - await loader.load(Json, { buttons: 'json/buttons.json' }); - } + override async init(): Promise { + const buttonsData = (await this.loader.load(Json, 'json/buttons.json')) as SpritesheetData; - override init(loader): void { - this.buttons = new Spritesheet(loader.get(Texture, 'buttons'), loader.get(Json, 'buttons').value); + this.buttons = new Spritesheet(this.loader.get(Texture, 'image/buttons.png'), buttonsData); this.status = this.createStatus(); this.container = this.createGamepad(); diff --git a/examples/input/mouse-and-pointer.ts b/examples/input/mouse-and-pointer.ts index 1d92225e8..e67472f13 100644 --- a/examples/input/mouse-and-pointer.ts +++ b/examples/input/mouse-and-pointer.ts @@ -31,17 +31,13 @@ class MouseAndPointerScene extends Scene { private clicks = 0; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.pointer = { x: width / 2, y: height / 2 }; this.previous = { x: width / 2, y: height / 2 }; - this.ship = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.ship = new Sprite(this.loader.get(Texture, 'image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); this.ship.interactive = true; this.ship.draggable = true; this.crosshair = new Graphics(); diff --git a/examples/input/multi-gamepad.ts b/examples/input/multi-gamepad.ts index da0049db1..aed1d403e 100644 --- a/examples/input/multi-gamepad.ts +++ b/examples/input/multi-gamepad.ts @@ -32,15 +32,11 @@ class MultiGamepadScene extends Scene { private connectPrompt!: Text; private hud!: ReturnType; - override async load(loader): Promise { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - - override init(loader): void { + override init(): void { const { width, height } = this.app.canvas; this.players = this.app.input.gamepads.map((pad, index) => { - const sprite = new Sprite(loader.get(Texture, 'ship')) + const sprite = new Sprite(this.loader.get(Texture, 'image/ship-a.png')) .setAnchor(0.5) .setScale(0.6) .setPosition(width * (0.2 + index * 0.2), height / 2) diff --git a/examples/text-fonts/basic-text.ts b/examples/text-fonts/basic-text.ts index ff2fe0d17..93a056da6 100644 --- a/examples/text-fonts/basic-text.ts +++ b/examples/text-fonts/basic-text.ts @@ -17,11 +17,9 @@ class BasicTextScene extends Scene { private time!: Time; private text!: Text; - override async load(loader): Promise { - await loader.load(FontAsset, { example: 'font/Kenney Future.ttf' }, { family: 'Kenney Future' }); - } + override async init(): Promise { + await this.loader.load(FontAsset, 'font/Kenney Future.ttf', { family: 'Kenney Future' }); - override init(): void { const { width, height } = this.app.canvas; this.time = new Time(); diff --git a/examples/text-fonts/bitmap-text-basic.ts b/examples/text-fonts/bitmap-text-basic.ts index 6be814b34..db32ea0c1 100644 --- a/examples/text-fonts/bitmap-text-basic.ts +++ b/examples/text-fonts/bitmap-text-basic.ts @@ -18,11 +18,9 @@ class BitmapTextBasicScene extends Scene { private counter!: BitmapText; private frame = 0; - override async load(loader): Promise { - this.font = await loader.load(BmFont, assets.demo.fonts.kenneyBlocksFnt); - } + override async init(): Promise { + this.font = await this.loader.load(BmFont, assets.demo.fonts.kenneyBlocksFnt); - override init(): void { const font = this.font; const { width, height } = this.app.canvas; const marginX = width * 0.08; diff --git a/examples/text-fonts/web-fonts.ts b/examples/text-fonts/web-fonts.ts index 881673a96..9ab22728e 100644 --- a/examples/text-fonts/web-fonts.ts +++ b/examples/text-fonts/web-fonts.ts @@ -17,11 +17,9 @@ class WebFontsScene extends Scene { private default!: Text; private loaded!: Text; - override async load(loader): Promise { - await loader.load(FontAsset, { andy: 'font/Kenney Future.ttf' }, { family: 'Kenney Future' }); - } + override async init(): Promise { + await this.loader.load(FontAsset, 'font/Kenney Future.ttf', { family: 'Kenney Future' }); - override init(): void { const { width, height } = this.app.canvas; this.default = new Text('Default Font', { fillColor: Color.white, fontSize: 52, align: 'center' }); From 3916143dd0a666b8de14b8789cb052191fe685f5 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 00:19:58 +0200 Subject: [PATCH 08/85] =?UTF-8?q?fix(resources):=20explicit=20Sound=20toke?= =?UTF-8?q?n=20overloads=20for=20get()/load()=20=E2=80=94=20fixes=20AssetR?= =?UTF-8?q?ef=20mis-resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/audio-fx/vocoder.ts | 9 +- examples/spatial-audio/falloff-curves.ts | 6 +- examples/spatial-audio/listener-and-source.ts | 6 +- examples/spatial-audio/moving-source.ts | 6 +- site/src/content/api/loader.json | 1176 ++++++++++++++--- src/core/Scene.ts | 6 + src/resources/Loader.ts | 8 + test/resources/loader-seamless.test.ts | 24 + 8 files changed, 1021 insertions(+), 220 deletions(-) diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index 88749df85..fb5d629a2 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -39,12 +39,9 @@ class VocoderScene extends Scene { for (const phrase of PHRASES) { // phrase.asset is a widened `string` (not a path literal), so the - // path-only get() overload can't infer Sound from the extension; - // the explicit Sound token form is ambiguous with the Json token - // overload at compile time (both resolve zero-arg-constructible - // instance types) even though the runtime seamless adapter is - // correct — cast through `unknown` to bridge it. - this.phrases.set(phrase.key, this.loader.get(Sound, phrase.asset) as unknown as Sound); + // path-only get() overload can't infer Sound from the extension — + // use the explicit Sound token form. + this.phrases.set(phrase.key, this.loader.get(Sound, phrase.asset)); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); diff --git a/examples/spatial-audio/falloff-curves.ts b/examples/spatial-audio/falloff-curves.ts index 4716ef3b6..8983cda7d 100644 --- a/examples/spatial-audio/falloff-curves.ts +++ b/examples/spatial-audio/falloff-curves.ts @@ -74,11 +74,7 @@ class FalloffCurvesScene extends Scene { // Each derived Sound below reads .audioBuffer synchronously, so the // shared source must be fully decoded first — await load() instead of // the deferred get() (whose placeholder audioBuffer is null until fill). - // The explicit Sound token also hits a compile-time overload ambiguity - // with the Json token form (both resolve zero-arg-constructible - // instance types), so the awaited result is cast — the runtime - // seamless/factory resolution is unaffected. - const source = (await this.loader.load(Sound, 'audio/impact-light.ogg')) as Sound; + const source = await this.loader.load(Sound, 'audio/impact-light.ogg'); this.sounds = this.sources.map(({ model, x, y }) => { const sound = new Sound(source.audioBuffer, { distanceModel: model, diff --git a/examples/spatial-audio/listener-and-source.ts b/examples/spatial-audio/listener-and-source.ts index ed51d7109..27f64a4fa 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -47,11 +47,7 @@ class ListenerAndSourceScene extends Scene { // audible while there is sustained signal to pan/attenuate. The derived // Sound below reads .audioBuffer synchronously, so await load() instead // of the deferred get() (whose placeholder audioBuffer is null until fill). - // The explicit Sound token also hits a compile-time overload ambiguity - // with the Json token form (both resolve zero-arg-constructible - // instance types), so the awaited result is cast — the runtime - // seamless/factory resolution is unaffected. - const source = (await this.loader.load(Sound, 'audio/demo-loop-main.ogg')) as Sound; + const source = await this.loader.load(Sound, 'audio/demo-loop-main.ogg'); this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, diff --git a/examples/spatial-audio/moving-source.ts b/examples/spatial-audio/moving-source.ts index 8aff43574..a64d072e4 100644 --- a/examples/spatial-audio/moving-source.ts +++ b/examples/spatial-audio/moving-source.ts @@ -45,11 +45,7 @@ class MovingSourceScene extends Scene { // audible while there is sustained signal to pan/attenuate. The derived // Sound below reads .audioBuffer synchronously, so await load() instead // of the deferred get() (whose placeholder audioBuffer is null until fill). - // The explicit Sound token also hits a compile-time overload ambiguity - // with the Json token form (both resolve zero-arg-constructible - // instance types), so the awaited result is cast — the runtime - // seamless/factory resolution is unaffected. - const source = (await this.loader.load(Sound, 'audio/demo-loop-main.ogg')) as Sound; + const source = await this.loader.load(Sound, 'audio/demo-loop-main.ogg'); this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index 803eda927..33bda1054 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 62, + "memberCount": 68, "counts": { "constructors": 1, - "methods": 51, + "methods": 57, "properties": 2, "events": 8 }, @@ -740,7 +740,7 @@ }, { "name": "get", - "signature": "get(path: LoadByPath extends Sound | Texture ? S : never, options?: unknown): LoadByPath", + "signature": "get(type: typeof Sound, source: string, options?: unknown): Sound", "signatureTokens": [ { "text": "get", @@ -751,7 +751,7 @@ "kind": "punctuation" }, { - "text": "path", + "text": "type", "kind": "param" }, { @@ -759,27 +759,7 @@ "kind": "punctuation" }, { - "text": "LoadByPath", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "extends", + "text": "typeof", "kind": "keyword" }, { @@ -791,27 +771,19 @@ "kind": "type" }, { - "text": " | ", - "kind": "punctuation" - }, - { - "text": "Texture", - "kind": "type" - }, - { - "text": " ? ", + "text": ", ", "kind": "punctuation" }, { - "text": "S", - "kind": "type" + "text": "source", + "kind": "param" }, { - "text": " : ", + "text": ": ", "kind": "punctuation" }, { - "text": "never", + "text": "string", "kind": "keyword" }, { @@ -843,26 +815,19 @@ "kind": "punctuation" }, { - "text": "LoadByPath", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", + "text": "Sound", "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" } ], "params": [ { - "name": "path", - "type": "LoadByPath extends Sound | Texture ? S : never", + "name": "type", + "type": "typeof Sound", + "optional": false + }, + { + "name": "source", + "type": "string", "optional": false }, { @@ -871,12 +836,12 @@ "optional": true } ], - "returnType": "LoadByPath", - "description": "Seamless access by path alone — the asset type is inferred from the file extension (basename-only, longest-suffix-first; see ExtensionTypeMap). Accepts only paths whose inferred type has a seamless adapter (compile-time gate); dynamic strings resolving to an unregistered extension or a non-seamless type throw with guidance." + "returnType": "Sound", + "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: typeof Json, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof Sound, sources: readonly string[], options?: unknown): Sound[]", "signatureTokens": [ { "text": "get", @@ -903,7 +868,7 @@ "kind": "punctuation" }, { - "text": "Json", + "text": "Sound", "kind": "type" }, { @@ -911,17 +876,29 @@ "kind": "punctuation" }, { - "text": "source", + "text": "sources", "kind": "param" }, { "text": ": ", "kind": "punctuation" }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, { "text": "string", "kind": "keyword" }, + { + "text": "[]", + "kind": "punctuation" + }, { "text": ", ", "kind": "punctuation" @@ -951,31 +928,23 @@ "kind": "punctuation" }, { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "T", + "text": "Sound", "kind": "type" }, { - "text": ">", + "text": "[]", "kind": "punctuation" } ], "params": [ { "name": "type", - "type": "typeof Json", + "type": "typeof Sound", "optional": false }, { - "name": "source", - "type": "string", + "name": "sources", + "type": "readonly string[]", "optional": false }, { @@ -984,12 +953,12 @@ "optional": true } ], - "returnType": "AssetRef", - "description": "Seamless access to a value asset: returns a stable AssetRef synchronously and never throws. The ref fills when the payload arrives (loaded resolves with the parsed value); failed refs retry in place on the next get. load() keeps resolving the raw value." + "returnType": "Sound[]", + "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: typeof TextAsset, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof Sound, items: Readonly>, options?: unknown): Record", "signatureTokens": [ { "text": "get", @@ -1016,7 +985,7 @@ "kind": "punctuation" }, { - "text": "TextAsset", + "text": "Sound", "kind": "type" }, { @@ -1024,17 +993,49 @@ "kind": "punctuation" }, { - "text": "source", + "text": "items", "kind": "param" }, { "text": ": ", "kind": "punctuation" }, + { + "text": "Readonly", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "K", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, { "text": "string", "kind": "keyword" }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + }, { "text": ", ", "kind": "punctuation" @@ -1064,7 +1065,7 @@ "kind": "punctuation" }, { - "text": "AssetRef", + "text": "Record", "kind": "type" }, { @@ -1072,8 +1073,16 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "K", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" }, { "text": ">", @@ -1083,12 +1092,12 @@ "params": [ { "name": "type", - "type": "typeof TextAsset", + "type": "typeof Sound", "optional": false }, { - "name": "source", - "type": "string", + "name": "items", + "type": "Readonly>", "optional": false }, { @@ -1097,12 +1106,12 @@ "optional": true } ], - "returnType": "AssetRef", + "returnType": "Record", "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: typeof CsvAsset, source: string, options?: unknown): AssetRef", + "signature": "get(path: LoadByPath extends Sound | Texture ? S : never, options?: unknown): LoadByPath", "signatureTokens": [ { "text": "get", @@ -1113,7 +1122,7 @@ "kind": "punctuation" }, { - "text": "type", + "text": "path", "kind": "param" }, { @@ -1121,7 +1130,27 @@ "kind": "punctuation" }, { - "text": "typeof", + "text": "LoadByPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "extends", "kind": "keyword" }, { @@ -1129,23 +1158,31 @@ "kind": "punctuation" }, { - "text": "CsvAsset", + "text": "Sound", "kind": "type" }, { - "text": ", ", + "text": " | ", "kind": "punctuation" }, { - "text": "source", - "kind": "param" + "text": "Texture", + "kind": "type" }, { - "text": ": ", + "text": " ? ", "kind": "punctuation" }, { - "text": "string", + "text": "S", + "kind": "type" + }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "never", "kind": "keyword" }, { @@ -1177,7 +1214,7 @@ "kind": "punctuation" }, { - "text": "AssetRef", + "text": "LoadByPath", "kind": "type" }, { @@ -1185,16 +1222,8 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": "[]", - "kind": "punctuation" + "text": "S", + "kind": "type" }, { "text": ">", @@ -1203,13 +1232,8 @@ ], "params": [ { - "name": "type", - "type": "typeof CsvAsset", - "optional": false - }, - { - "name": "source", - "type": "string", + "name": "path", + "type": "LoadByPath extends Sound | Texture ? S : never", "optional": false }, { @@ -1218,12 +1242,12 @@ "optional": true } ], - "returnType": "AssetRef", - "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." + "returnType": "LoadByPath", + "description": "Seamless access by path alone — the asset type is inferred from the file extension (basename-only, longest-suffix-first; see ExtensionTypeMap). Accepts only paths whose inferred type has a seamless adapter (compile-time gate); dynamic strings resolving to an unregistered extension or a non-seamless type throw with guidance." }, { "name": "get", - "signature": "get(type: typeof XmlAsset, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof Json, source: string, options?: unknown): AssetRef", "signatureTokens": [ { "text": "get", @@ -1250,7 +1274,7 @@ "kind": "punctuation" }, { - "text": "XmlAsset", + "text": "Json", "kind": "type" }, { @@ -1306,7 +1330,7 @@ "kind": "punctuation" }, { - "text": "Document", + "text": "T", "kind": "type" }, { @@ -1317,7 +1341,7 @@ "params": [ { "name": "type", - "type": "typeof XmlAsset", + "type": "typeof Json", "optional": false }, { @@ -1331,12 +1355,12 @@ "optional": true } ], - "returnType": "AssetRef", - "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." + "returnType": "AssetRef", + "description": "Seamless access to a value asset: returns a stable AssetRef synchronously and never throws. The ref fills when the payload arrives (loaded resolves with the parsed value); failed refs retry in place on the next get. load() keeps resolving the raw value." }, { "name": "get", - "signature": "get(type: typeof SubtitleAsset, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof TextAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { "text": "get", @@ -1363,7 +1387,7 @@ "kind": "punctuation" }, { - "text": "SubtitleAsset", + "text": "TextAsset", "kind": "type" }, { @@ -1419,12 +1443,8 @@ "kind": "punctuation" }, { - "text": "VTTCue", - "kind": "type" - }, - { - "text": "[]", - "kind": "punctuation" + "text": "string", + "kind": "keyword" }, { "text": ">", @@ -1434,7 +1454,7 @@ "params": [ { "name": "type", - "type": "typeof SubtitleAsset", + "type": "typeof TextAsset", "optional": false }, { @@ -1448,12 +1468,12 @@ "optional": true } ], - "returnType": "AssetRef", + "returnType": "AssetRef", "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof CsvAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { "text": "get", @@ -1480,7 +1500,7 @@ "kind": "punctuation" }, { - "text": "BinaryAsset", + "text": "CsvAsset", "kind": "type" }, { @@ -1536,8 +1556,16 @@ "kind": "punctuation" }, { - "text": "ArrayBuffer", - "kind": "type" + "text": "string", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": "[]", + "kind": "punctuation" }, { "text": ">", @@ -1547,7 +1575,7 @@ "params": [ { "name": "type", - "type": "typeof BinaryAsset", + "type": "typeof CsvAsset", "optional": false }, { @@ -1561,12 +1589,12 @@ "optional": true } ], - "returnType": "AssetRef", + "returnType": "AssetRef", "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef", + "signature": "get(type: typeof XmlAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { "text": "get", @@ -1593,7 +1621,7 @@ "kind": "punctuation" }, { - "text": "WasmAsset", + "text": "XmlAsset", "kind": "type" }, { @@ -1649,7 +1677,7 @@ "kind": "punctuation" }, { - "text": "Module", + "text": "Document", "kind": "type" }, { @@ -1660,7 +1688,7 @@ "params": [ { "name": "type", - "type": "typeof WasmAsset", + "type": "typeof XmlAsset", "optional": false }, { @@ -1674,12 +1702,12 @@ "optional": true } ], - "returnType": "AssetRef", + "returnType": "AssetRef", "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { "name": "get", - "signature": "get(type: T, alias: string): LoadReturn", + "signature": "get(type: typeof SubtitleAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { "text": "get", @@ -1698,7 +1726,15 @@ "kind": "punctuation" }, { - "text": "T", + "text": "typeof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "SubtitleAsset", "kind": "type" }, { @@ -1706,7 +1742,7 @@ "kind": "punctuation" }, { - "text": "alias", + "text": "source", "kind": "param" }, { @@ -1717,6 +1753,26 @@ "text": "string", "kind": "keyword" }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, { "text": ")", "kind": "punctuation" @@ -1726,7 +1782,7 @@ "kind": "punctuation" }, { - "text": "LoadReturn", + "text": "AssetRef", "kind": "type" }, { @@ -1734,9 +1790,13 @@ "kind": "punctuation" }, { - "text": "T", + "text": "VTTCue", "kind": "type" }, + { + "text": "[]", + "kind": "punctuation" + }, { "text": ">", "kind": "punctuation" @@ -1745,24 +1805,29 @@ "params": [ { "name": "type", - "type": "T", + "type": "typeof SubtitleAsset", "optional": false }, { - "name": "alias", + "name": "source", "type": "string", "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true } ], - "returnType": "LoadReturn", - "description": "Retrieves a previously loaded asset by type and alias (legacy lookup form for types without a seamless adapter; replaced by the asset graph in a later slice). Throws if the asset has not been loaded — use peek for a non-throwing alternative, or has to guard the call." + "returnType": "AssetRef", + "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { - "name": "has", - "signature": "has(type: Loadable, alias: string): boolean", + "name": "get", + "signature": "get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { - "text": "has", + "text": "get", "kind": "name" }, { @@ -1778,7 +1843,15 @@ "kind": "punctuation" }, { - "text": "Loadable", + "text": "typeof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "BinaryAsset", "kind": "type" }, { @@ -1786,7 +1859,7 @@ "kind": "punctuation" }, { - "text": "alias", + "text": "source", "kind": "param" }, { @@ -1798,7 +1871,15 @@ "kind": "keyword" }, { - "text": ")", + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", "kind": "punctuation" }, { @@ -1806,31 +1887,60 @@ "kind": "punctuation" }, { - "text": "boolean", + "text": "unknown", "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetRef", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "ArrayBuffer", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { "name": "type", - "type": "Loadable", + "type": "typeof BinaryAsset", "optional": false }, { - "name": "alias", + "name": "source", "type": "string", "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true } ], - "returnType": "boolean", - "description": "Returns true if the asset is currently held in the in-memory store." + "returnType": "AssetRef", + "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { - "name": "hasAssetType", - "signature": "hasAssetType(typeName: string): boolean", + "name": "get", + "signature": "get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef", "signatureTokens": [ { - "text": "hasAssetType", + "text": "get", "kind": "name" }, { @@ -1838,7 +1948,7 @@ "kind": "punctuation" }, { - "text": "typeName", + "text": "type", "kind": "param" }, { @@ -1846,54 +1956,51 @@ "kind": "punctuation" }, { - "text": "string", + "text": "typeof", "kind": "keyword" }, { - "text": ")", + "text": " ", "kind": "punctuation" }, { - "text": ": ", + "text": "WasmAsset", + "kind": "type" + }, + { + "text": ", ", "kind": "punctuation" }, { - "text": "boolean", - "kind": "keyword" - } - ], - "params": [ + "text": "source", + "kind": "param" + }, { - "name": "typeName", - "type": "string", - "optional": false - } - ], - "returnType": "boolean", - "description": "Returns true if a type-name mapping is already registered." - }, - { - "name": "hasBundle", - "signature": "hasBundle(name: string): boolean", - "signatureTokens": [ + "text": ": ", + "kind": "punctuation" + }, { - "text": "hasBundle", - "kind": "name" + "text": "string", + "kind": "keyword" }, { - "text": "(", + "text": ", ", "kind": "punctuation" }, { - "text": "name", + "text": "options", "kind": "param" }, + { + "text": "?", + "kind": "punctuation" + }, { "text": ": ", "kind": "punctuation" }, { - "text": "string", + "text": "unknown", "kind": "keyword" }, { @@ -1905,26 +2012,48 @@ "kind": "punctuation" }, { - "text": "boolean", - "kind": "keyword" + "text": "AssetRef", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Module", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "name", + "name": "type", + "type": "typeof WasmAsset", + "optional": false + }, + { + "name": "source", "type": "string", "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true } ], - "returnType": "boolean", - "description": "Returns true if every asset in the named bundle has been loaded and is currently held in the in-memory resource store." + "returnType": "AssetRef", + "description": "Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored texture; an unknown source returns a placeholder handle immediately, starts the fetch, and fills the handle in place when the payload arrives (track it via Texture.loadState / Texture.loaded). Failed loads show the Texture.missing checker; calling get again for a 'failed' source retries and heals the same handle in place. The same source always yields the same instance — also across load — and options are first-wins: conflicting options on a later call are ignored with a one-time dev warning." }, { - "name": "hasExtension", - "signature": "hasExtension(ext: string): boolean", + "name": "get", + "signature": "get(type: T, alias: string): LoadReturn", "signatureTokens": [ { - "text": "hasExtension", + "text": "get", "kind": "name" }, { @@ -1932,7 +2061,7 @@ "kind": "punctuation" }, { - "text": "ext", + "text": "type", "kind": "param" }, { @@ -1940,8 +2069,250 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "T", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "alias", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadReturn", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "type", + "type": "T", + "optional": false + }, + { + "name": "alias", + "type": "string", + "optional": false + } + ], + "returnType": "LoadReturn", + "description": "Retrieves a previously loaded asset by type and alias (legacy lookup form for types without a seamless adapter; replaced by the asset graph in a later slice). Throws if the asset has not been loaded — use peek for a non-throwing alternative, or has to guard the call." + }, + { + "name": "has", + "signature": "has(type: Loadable, alias: string): boolean", + "signatureTokens": [ + { + "text": "has", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "type", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Loadable", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "alias", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "type", + "type": "Loadable", + "optional": false + }, + { + "name": "alias", + "type": "string", + "optional": false + } + ], + "returnType": "boolean", + "description": "Returns true if the asset is currently held in the in-memory store." + }, + { + "name": "hasAssetType", + "signature": "hasAssetType(typeName: string): boolean", + "signatureTokens": [ + { + "text": "hasAssetType", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "typeName", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "typeName", + "type": "string", + "optional": false + } + ], + "returnType": "boolean", + "description": "Returns true if a type-name mapping is already registered." + }, + { + "name": "hasBundle", + "signature": "hasBundle(name: string): boolean", + "signatureTokens": [ + { + "text": "hasBundle", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "name", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "name", + "type": "string", + "optional": false + } + ], + "returnType": "boolean", + "description": "Returns true if every asset in the named bundle has been loaded and is currently held in the in-memory resource store." + }, + { + "name": "hasExtension", + "signature": "hasExtension(ext: string): boolean", + "signatureTokens": [ + { + "text": "hasExtension", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "ext", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" }, { "text": ")", @@ -2100,6 +2471,413 @@ "returnType": "{ source: string; type: AssetConstructor } | null", "description": "Reverse lookup: given a loaded resource object, return the asset type and source key it was first loaded under, or null for runtime-created, unloaded, or non-object resources. When a resource is shared across several aliases, the **first** alias it was stored under is returned (the canonical key). Primitive results (parsed JSON, text, CSV rows) are not keyable. Used by scene serialization to turn a live asset reference back into a portable source key; the contract is that the same asset is pre-loaded under that key before a matching deserialize." }, + { + "name": "load", + "signature": "load(type: typeof Sound, path: string, options?: unknown): LoadingQueue", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "type", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "typeof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "path", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "type", + "type": "typeof Sound", + "optional": false + }, + { + "name": "path", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, + { + "name": "load", + "signature": "load(type: typeof Sound, paths: readonly string[], options?: unknown): LoadingQueue", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "type", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "typeof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "paths", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "type", + "type": "typeof Sound", + "optional": false + }, + { + "name": "paths", + "type": "readonly string[]", + "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, + { + "name": "load", + "signature": "load(type: typeof Sound, items: Readonly>, options?: unknown): LoadingQueue>", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "type", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "typeof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "items", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Readonly", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "K", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "K", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "type", + "type": "typeof Sound", + "optional": false + }, + { + "name": "items", + "type": "Readonly>", + "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true + } + ], + "returnType": "LoadingQueue>", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, { "name": "load", "signature": "load(type: typeof Json, path: string, options?: unknown): LoadingQueue", diff --git a/src/core/Scene.ts b/src/core/Scene.ts index d6b506b80..76a852a34 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -116,6 +116,9 @@ class SceneLoader implements Destroyable { public get(type: typeof Texture, source: string, options?: TextureFactoryOptions & PreSizeOptions): Texture; public get(type: typeof Texture, sources: readonly string[], options?: TextureFactoryOptions & PreSizeOptions): Texture[]; public get(type: typeof Texture, items: Readonly>, options?: TextureFactoryOptions & PreSizeOptions): Record; + public get(type: typeof Sound, source: string, options?: unknown): Sound; + public get(type: typeof Sound, sources: readonly string[], options?: unknown): Sound[]; + public get(type: typeof Sound, items: Readonly>, options?: unknown): Record; public get(path: LoadByPath extends Texture | Sound ? S : never, options?: unknown): LoadByPath; public get(type: typeof Json, source: string, options?: unknown): AssetRef; public get(type: typeof TextAsset, source: string, options?: unknown): AssetRef; @@ -129,6 +132,9 @@ class SceneLoader implements Destroyable { return this._loader._getClaimed(this._scope, typeOrPath, source, options); } + public load(type: typeof Sound, path: string, options?: unknown): LoadingQueue; + public load(type: typeof Sound, paths: readonly string[], options?: unknown): LoadingQueue; + public load(type: typeof Sound, items: Readonly>, options?: unknown): LoadingQueue>; public load(type: typeof Json, path: string, options?: unknown): LoadingQueue; public load(type: typeof Json, paths: readonly string[], options?: unknown): LoadingQueue; public load(type: typeof Json, items: Readonly>, options?: unknown): LoadingQueue>; diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index a9a565f3f..8e2d91efe 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -717,6 +717,10 @@ export class Loader { * Supply a custom `options` object to pass factory-specific configuration * (e.g. audio decoding hints or image format overrides). */ + public load(type: typeof Sound, path: string, options?: unknown): LoadingQueue; + public load(type: typeof Sound, paths: readonly string[], options?: unknown): LoadingQueue; + public load(type: typeof Sound, items: Readonly>, options?: unknown): LoadingQueue>; + public load(type: typeof Json, path: string, options?: unknown): LoadingQueue; public load(type: typeof Json, paths: readonly string[], options?: unknown): LoadingQueue; public load(type: typeof Json, items: Readonly>, options?: unknown): LoadingQueue>; @@ -1096,6 +1100,10 @@ export class Loader { public get(type: typeof Texture, sources: readonly string[], options?: TextureFactoryOptions & PreSizeOptions): Texture[]; public get(type: typeof Texture, items: Readonly>, options?: TextureFactoryOptions & PreSizeOptions): Record; + public get(type: typeof Sound, source: string, options?: unknown): Sound; + public get(type: typeof Sound, sources: readonly string[], options?: unknown): Sound[]; + public get(type: typeof Sound, items: Readonly>, options?: unknown): Record; + /** * Seamless access by path alone — the asset type is inferred from the file * extension (basename-only, longest-suffix-first; see {@link ExtensionTypeMap}). diff --git a/test/resources/loader-seamless.test.ts b/test/resources/loader-seamless.test.ts index af61ab8b2..0ea25b668 100644 --- a/test/resources/loader-seamless.test.ts +++ b/test/resources/loader-seamless.test.ts @@ -1,11 +1,15 @@ import { expectTypeOf } from 'vitest'; +import { Sound } from '#audio/Sound'; import { logger, LogSeverity } from '#core/logging'; import { materializeAssetBindings } from '#extensions/materialize'; import { Texture } from '#rendering/texture/Texture'; +import type { AssetRef } from '#resources/AssetRef'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; +import type { LoadingQueue } from '#resources/LoadingQueue'; import { textureSeamlessAdapter } from '#resources/seamless'; +import { Json } from '#resources/tokens'; /** Loader with all core asset bindings (mirrors createCoreLoader in loader.test.ts). */ function createCoreLoader(): Loader { @@ -370,6 +374,26 @@ describe('Loader seamless get (Texture)', () => { expectTypeOf(new Texture(null).loaded).toEqualTypeOf>(); }); + test('type-level: Sound token get/load resolve to Sound (not AssetRef)', () => { + const loader = createCoreLoader(); + + // Assertions live in a never-invoked closure: tsc still checks them (pnpm + // typecheck covers test files) but no runtime get/load fires a real fetch. + // Regression: Sound's all-optional constructor made `typeof Sound` structurally + // assignable to `typeof Json`, so get(Sound, …) fell through to the value-asset + // overload and resolved AssetRef. Explicit Sound token overloads fix it. + void (() => { + expectTypeOf(loader.get(Sound, 'x.ogg')).toEqualTypeOf(); + expectTypeOf(loader.get(Sound, ['a.ogg', 'b.ogg'])).toEqualTypeOf(); + expectTypeOf(loader.get(Sound, { a: 'a.ogg', b: 'b.ogg' })).toEqualTypeOf>(); + expectTypeOf(loader.load(Sound, 'x.ogg')).toEqualTypeOf>(); + + // No regression to the other tokens. + expectTypeOf(loader.get(Texture, 'x.png')).toEqualTypeOf(); + expectTypeOf(loader.get(Json, 'x.json')).toEqualTypeOf>(); + }); + }); + test("get('ship.png') infers Texture via extension and is seamless", async () => { mockFetchImage(); const loader = createCoreLoader(); From 33b9697496b60f17f41e4570630424baaa0ebc7d Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:10:15 +0200 Subject: [PATCH 09/85] feat(resources): assetMeta stamp for handle-hybrid catalog leaves --- src/resources/assetMeta.ts | 23 +++++++++++++++++++++++ test/resources/assetMeta.test.ts | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/resources/assetMeta.ts create mode 100644 test/resources/assetMeta.test.ts diff --git a/src/resources/assetMeta.ts b/src/resources/assetMeta.ts new file mode 100644 index 000000000..9edb9bcfc --- /dev/null +++ b/src/resources/assetMeta.ts @@ -0,0 +1,23 @@ +import type { AssetDefinitions } from './AssetDefinitions'; + +/** Descriptor metadata stamped onto a handle-hybrid catalog leaf. */ +export interface AssetMeta { + readonly kind: keyof AssetDefinitions; + readonly src: string; + readonly opts?: unknown; +} + +/** Symbol under which {@link AssetMeta} rides on a handle-hybrid leaf. @internal */ +export const assetMeta: unique symbol = Symbol('exo.assetMeta'); + +/** Stamp {@link AssetMeta} onto a handle (non-enumerable); returns the handle. @internal */ +export function stampMeta(target: T, meta: AssetMeta): T { + Object.defineProperty(target, assetMeta, { value: meta, enumerable: false, configurable: false, writable: false }); + return target; +} + +/** Read the {@link AssetMeta} off a value, or `undefined` if not stamped. @internal */ +export function readMeta(value: unknown): AssetMeta | undefined { + if (typeof value !== 'object' || value === null) return undefined; + return (value as { [assetMeta]?: AssetMeta })[assetMeta]; +} diff --git a/test/resources/assetMeta.test.ts b/test/resources/assetMeta.test.ts new file mode 100644 index 000000000..dcde68d3f --- /dev/null +++ b/test/resources/assetMeta.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { assetMeta, readMeta, stampMeta } from '#resources/assetMeta'; + +describe('assetMeta', () => { + it('stamps and reads back meta, non-enumerable', () => { + const target = new Map(); + const returned = stampMeta(target, { kind: 'texture', src: 'ship.png' }); + + expect(returned).toBe(target); // returns the same object + expect(readMeta(target)).toEqual({ kind: 'texture', src: 'ship.png' }); + expect(Object.keys(target)).not.toContain(assetMeta.toString()); // non-enumerable + expect((target as { [assetMeta]?: unknown })[assetMeta]).toBeDefined(); + }); + + it('readMeta returns undefined for unstamped and primitive values', () => { + expect(readMeta({})).toBeUndefined(); + expect(readMeta(null)).toBeUndefined(); + expect(readMeta(42)).toBeUndefined(); + }); +}); From 73d9371ddfae8306cccfb74f1462940b245cca0c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:17:05 +0200 Subject: [PATCH 10/85] refactor(resources): _-prefix assetMeta internals + real non-enumerable assertion Rename assetMeta/stampMeta/readMeta to _assetMeta/_stampMeta/_readMeta to follow the repo's internal-export convention. Replace the vacuous Object.keys() non-enumerability check with a real descriptor assertion, and note that the stamp's immutability is intentional. --- src/resources/assetMeta.ts | 19 +++++++++++++------ test/resources/assetMeta.test.ts | 16 ++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/resources/assetMeta.ts b/src/resources/assetMeta.ts index 9edb9bcfc..6a1347928 100644 --- a/src/resources/assetMeta.ts +++ b/src/resources/assetMeta.ts @@ -8,16 +8,23 @@ export interface AssetMeta { } /** Symbol under which {@link AssetMeta} rides on a handle-hybrid leaf. @internal */ -export const assetMeta: unique symbol = Symbol('exo.assetMeta'); +export const _assetMeta: unique symbol = Symbol('exo.assetMeta'); -/** Stamp {@link AssetMeta} onto a handle (non-enumerable); returns the handle. @internal */ -export function stampMeta(target: T, meta: AssetMeta): T { - Object.defineProperty(target, assetMeta, { value: meta, enumerable: false, configurable: false, writable: false }); +/** + * Stamp {@link AssetMeta} onto a handle (non-enumerable); returns the handle. + * + * The stamp is intentionally immutable (non-configurable, non-writable): a leaf's + * asset meta never changes after creation, so no task re-stamps it. + * + * @internal + */ +export function _stampMeta(target: T, meta: AssetMeta): T { + Object.defineProperty(target, _assetMeta, { value: meta, enumerable: false, configurable: false, writable: false }); return target; } /** Read the {@link AssetMeta} off a value, or `undefined` if not stamped. @internal */ -export function readMeta(value: unknown): AssetMeta | undefined { +export function _readMeta(value: unknown): AssetMeta | undefined { if (typeof value !== 'object' || value === null) return undefined; - return (value as { [assetMeta]?: AssetMeta })[assetMeta]; + return (value as { [_assetMeta]?: AssetMeta })[_assetMeta]; } diff --git a/test/resources/assetMeta.test.ts b/test/resources/assetMeta.test.ts index dcde68d3f..2c5634381 100644 --- a/test/resources/assetMeta.test.ts +++ b/test/resources/assetMeta.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { assetMeta, readMeta, stampMeta } from '#resources/assetMeta'; +import { _assetMeta, _readMeta, _stampMeta } from '#resources/assetMeta'; describe('assetMeta', () => { it('stamps and reads back meta, non-enumerable', () => { const target = new Map(); - const returned = stampMeta(target, { kind: 'texture', src: 'ship.png' }); + const returned = _stampMeta(target, { kind: 'texture', src: 'ship.png' }); expect(returned).toBe(target); // returns the same object - expect(readMeta(target)).toEqual({ kind: 'texture', src: 'ship.png' }); - expect(Object.keys(target)).not.toContain(assetMeta.toString()); // non-enumerable - expect((target as { [assetMeta]?: unknown })[assetMeta]).toBeDefined(); + expect(_readMeta(target)).toEqual({ kind: 'texture', src: 'ship.png' }); + expect(Object.getOwnPropertyDescriptor(target, _assetMeta)?.enumerable).toBe(false); // non-enumerable + expect((target as { [_assetMeta]?: unknown })[_assetMeta]).toBeDefined(); }); it('readMeta returns undefined for unstamped and primitive values', () => { - expect(readMeta({})).toBeUndefined(); - expect(readMeta(null)).toBeUndefined(); - expect(readMeta(42)).toBeUndefined(); + expect(_readMeta({})).toBeUndefined(); + expect(_readMeta(null)).toBeUndefined(); + expect(_readMeta(42)).toBeUndefined(); }); }); From df8f3d4fdd80652e5b9badb19921feb8b8d528c2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:23:28 +0200 Subject: [PATCH 11/85] feat(resources): module-level asset-kind registry + core kind registration Adds a loader-independent registry mapping type-string -> { adapter?, isValue } plus registerAssetKind/getAssetKind/createLeaf, so Assets.from() can build a meta-stamped placeholder handle (resource) or AssetRef (value) without ever touching a Loader instance. Registers the core kinds (texture, sound as resource kinds; json, text, csv, xml, srt, vtt, binary, wasm as value kinds) as a side effect of loading seamless.ts. --- src/resources/assetKindRegistry.ts | 44 ++++++++++++++++++++++++ src/resources/seamless.ts | 9 +++++ test/resources/assetKindRegistry.test.ts | 30 ++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/resources/assetKindRegistry.ts create mode 100644 test/resources/assetKindRegistry.test.ts diff --git a/src/resources/assetKindRegistry.ts b/src/resources/assetKindRegistry.ts new file mode 100644 index 000000000..2ac6cd222 --- /dev/null +++ b/src/resources/assetKindRegistry.ts @@ -0,0 +1,44 @@ +import type { AssetDefinitions } from './AssetDefinitions'; +import { _stampMeta } from './assetMeta'; +import { AssetRef } from './AssetRef'; +import type { SeamlessAdapter } from './seamless'; + +/** A kind's placeholder strategy: a resource kind carries a {@link SeamlessAdapter}; a value kind does not. */ +export interface AssetKindEntry { + readonly adapter?: SeamlessAdapter; + readonly isValue: boolean; +} + +const kinds = new Map(); + +/** Register a kind's placeholder strategy. Idempotent for the same entry; throws on a conflicting adapter. @internal */ +export function registerAssetKind(kind: keyof AssetDefinitions, entry: AssetKindEntry): void { + const existing = kinds.get(kind); + if (existing !== undefined && existing.adapter !== entry.adapter) { + throw new Error(`assetKindRegistry: kind "${kind}" already registered with a different adapter.`); + } + kinds.set(kind, entry); +} + +/** @internal */ +export function getAssetKind(kind: string): AssetKindEntry | undefined { + return kinds.get(kind); +} + +/** Build a meta-stamped placeholder handle (resource) or `AssetRef` (value) for a catalog leaf. @internal */ +export function createLeaf(kind: keyof AssetDefinitions, src: string, opts?: unknown): object { + const entry = kinds.get(kind); + if (entry === undefined) { + throw new Error(`assetKindRegistry: no kind registered for "${kind}". Register it via registerAssetKind().`); + } + + if (entry.isValue) { + return _stampMeta(new AssetRef(), { kind, src, opts }); + } + + if (entry.adapter === undefined) { + throw new Error(`assetKindRegistry: resource kind "${kind}" has no seamless adapter.`); + } + + return _stampMeta(entry.adapter.createPlaceholder(opts) as object, { kind, src, opts }); +} diff --git a/src/resources/seamless.ts b/src/resources/seamless.ts index fc1f990ce..432e03a85 100644 --- a/src/resources/seamless.ts +++ b/src/resources/seamless.ts @@ -3,6 +3,8 @@ import type { LoadStateValue } from '#core/LoadState'; import { logger } from '#core/logging'; import { Texture } from '#rendering/texture/Texture'; +import { registerAssetKind } from './assetKindRegistry'; + /** Pre-sizing options for deferred texture handles (spec §4.1 — the layout-jump fix). */ export interface PreSizeOptions { /** Width to reserve on the placeholder until the payload arrives. */ @@ -141,3 +143,10 @@ export const soundSeamlessAdapter: SeamlessAdapter = { return handle.loadState; }, }; + +// Register core kinds so Assets.from() can build placeholders without a Loader. +registerAssetKind('texture', { adapter: textureSeamlessAdapter, isValue: false }); +registerAssetKind('sound', { adapter: soundSeamlessAdapter, isValue: false }); +for (const valueKind of ['json', 'text', 'csv', 'xml', 'srt', 'vtt', 'binary', 'wasm'] as const) { + registerAssetKind(valueKind, { isValue: true }); +} diff --git a/test/resources/assetKindRegistry.test.ts b/test/resources/assetKindRegistry.test.ts new file mode 100644 index 000000000..7df40f51d --- /dev/null +++ b/test/resources/assetKindRegistry.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { Texture } from '#rendering/texture/Texture'; +import { AssetRef } from '#resources/AssetRef'; +import { createLeaf, getAssetKind } from '#resources/assetKindRegistry'; +import { _readMeta } from '#resources/assetMeta'; +import '#resources/seamless'; // side-effect: registers core kinds + +describe('assetKindRegistry', () => { + it('registers core resource kinds with an adapter', () => { + expect(getAssetKind('texture')?.isValue).toBe(false); + expect(getAssetKind('texture')?.adapter).toBeDefined(); + }); + + it('registers core value kinds without an adapter', () => { + expect(getAssetKind('json')).toEqual({ isValue: true }); + }); + + it('createLeaf builds a meta-stamped placeholder Texture for a resource kind', () => { + const leaf = createLeaf('texture', 'ship.png', { width: 32, height: 16 }); + expect(leaf).toBeInstanceOf(Texture); + expect((leaf as Texture).loadState).toBe('loading'); + expect(_readMeta(leaf)).toEqual({ kind: 'texture', src: 'ship.png', opts: { width: 32, height: 16 } }); + }); + + it('createLeaf builds a meta-stamped AssetRef for a value kind', () => { + const leaf = createLeaf('json', 'level.json'); + expect(leaf).toBeInstanceOf(AssetRef); + expect(_readMeta(leaf)).toEqual({ kind: 'json', src: 'level.json', opts: undefined }); + }); +}); From 31813f714e232a63fcd2dc65702b4c502598cdf5 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:31:38 +0200 Subject: [PATCH 12/85] test(resources): cover asset-kind registry conflict/error paths + tighten guard Add coverage for the two reachable throw paths (registerAssetKind conflict, createLeaf unregistered kind) and tighten the conflict guard in registerAssetKind to also reject a re-registration that flips isValue while keeping the same adapter. --- src/resources/assetKindRegistry.ts | 6 ++--- test/resources/assetKindRegistry.test.ts | 29 +++++++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/resources/assetKindRegistry.ts b/src/resources/assetKindRegistry.ts index 2ac6cd222..a8980d156 100644 --- a/src/resources/assetKindRegistry.ts +++ b/src/resources/assetKindRegistry.ts @@ -11,11 +11,11 @@ export interface AssetKindEntry { const kinds = new Map(); -/** Register a kind's placeholder strategy. Idempotent for the same entry; throws on a conflicting adapter. @internal */ +/** Register a kind's placeholder strategy. Idempotent for the same entry; throws on a conflicting registration. @internal */ export function registerAssetKind(kind: keyof AssetDefinitions, entry: AssetKindEntry): void { const existing = kinds.get(kind); - if (existing !== undefined && existing.adapter !== entry.adapter) { - throw new Error(`assetKindRegistry: kind "${kind}" already registered with a different adapter.`); + if (existing !== undefined && (existing.isValue !== entry.isValue || existing.adapter !== entry.adapter)) { + throw new Error(`assetKindRegistry: kind "${kind}" already registered with a conflicting entry.`); } kinds.set(kind, entry); } diff --git a/test/resources/assetKindRegistry.test.ts b/test/resources/assetKindRegistry.test.ts index 7df40f51d..967ccb959 100644 --- a/test/resources/assetKindRegistry.test.ts +++ b/test/resources/assetKindRegistry.test.ts @@ -1,9 +1,12 @@ +import '#resources/seamless'; // side-effect: registers core kinds + import { describe, expect, it } from 'vitest'; + import { Texture } from '#rendering/texture/Texture'; -import { AssetRef } from '#resources/AssetRef'; -import { createLeaf, getAssetKind } from '#resources/assetKindRegistry'; +import { createLeaf, getAssetKind, registerAssetKind } from '#resources/assetKindRegistry'; import { _readMeta } from '#resources/assetMeta'; -import '#resources/seamless'; // side-effect: registers core kinds +import { AssetRef } from '#resources/AssetRef'; +import { soundSeamlessAdapter } from '#resources/seamless'; describe('assetKindRegistry', () => { it('registers core resource kinds with an adapter', () => { @@ -27,4 +30,24 @@ describe('assetKindRegistry', () => { expect(leaf).toBeInstanceOf(AssetRef); expect(_readMeta(leaf)).toEqual({ kind: 'json', src: 'level.json', opts: undefined }); }); + + it('registerAssetKind throws when re-registering a kind with a different adapter', () => { + // texture is already registered by the '#resources/seamless' side-effect import above. + // Re-registering with a different adapter must throw and must NOT mutate the registry. + expect(() => registerAssetKind('texture', { adapter: soundSeamlessAdapter, isValue: false })).toThrow(/already registered/); + // Confirm the throw left the original registration intact. + expect(getAssetKind('texture')?.adapter).not.toBe(soundSeamlessAdapter); + }); + + it('registerAssetKind throws when re-registering a kind with the same adapter but a flipped isValue', () => { + const before = getAssetKind('texture'); + expect(() => registerAssetKind('texture', { adapter: before?.adapter, isValue: true })).toThrow(/already registered/); + // Confirm the throw left the original registration intact. + expect(getAssetKind('texture')).toEqual(before); + }); + + it('createLeaf throws for a kind that has no registration', () => { + // 'bmFont' is a valid AssetDefinitions key that the core seamless registration does not register. + expect(() => createLeaf('bmFont', 'x.fnt')).toThrow(/no kind registered/); + }); }); From cd96c9bf1883ad7be621b8524e8da4db7ef08e2c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:41:56 +0200 Subject: [PATCH 13/85] =?UTF-8?q?feat(resources):=20Loader.=5Fadopt=20?= =?UTF-8?q?=E2=80=94=20fill=20externally-created=20catalog=20leaves=20in?= =?UTF-8?q?=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Loader._adopt(handle, claimer): registers an externally-created handle-hybrid leaf (a placeholder Texture/Sound or an AssetRef, already meta-stamped by createLeaf) under its normalized _key, claims it, and drives the fetch through the existing _deferred/_refs bookkeeping so the single _storeResource fill site transplants the fetched payload into that exact object. Every consumer holding the leaf pops in place. --- src/resources/Loader.ts | 50 ++++++++++++ test/resources/catalog-adopt.test.ts | 114 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 test/resources/catalog-adopt.test.ts diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index a9a565f3f..49bece6ad 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -11,6 +11,7 @@ import type { AssetDefinitions, AssetInput, InferAssetResource } from './AssetDe import type { AssetFactory } from './AssetFactory'; import type { AssetManifest, LoadBundleOptions } from './AssetManifest'; import { BundleLoadError, defineAssetManifest } from './AssetManifest'; +import { _readMeta } from './assetMeta'; import { AssetRef } from './AssetRef'; import { type Assets, AssetsImpl } from './Assets'; import { CacheFirstStrategy } from './CacheFirstStrategy'; @@ -1602,6 +1603,55 @@ export class Loader { // Internal — loading // ----------------------------------------------------------------------- + /** + * Adopt an externally-created handle-hybrid leaf (from `Assets.from()`) into + * this loader: register it as the deferred/ref handle under its normalized + * key, claim it under `claimer`, and drive the fetch. The existing fill site + * ({@link _storeResource}) transplants the fetched payload into this exact + * object, so every consumer that already holds the leaf pops in. Idempotent + * for a handle already adopted under the same key (no duplicate fetch). + * @internal + */ + public _adopt(handle: object, claimer: symbol): void { + const meta = _readMeta(handle); + + if (meta === undefined) { + throw new Error('Loader._adopt: value is not an Assets.from() leaf (no assetMeta).'); + } + + const ctor = this._assetTypeMap.get(meta.kind); + + if (ctor === undefined) { + throw new Error(`Loader._adopt: no constructor registered for kind "${meta.kind}".`); + } + + const key = this._key(ctor, meta.src); + + if (handle instanceof AssetRef) { + if (!this._refs.has(key)) { + this._refs.set(key, { ref: handle, options: meta.opts }); + this._handleKeys.set(handle, key); + } + + this._claim(key, ctor, meta.src, claimer); + + if (this._resources.get(ctor)?.get(meta.src) === undefined) { + this._startRefFetch(ctor, meta.src, meta.opts); + } + + return; + } + + if (this._deferred.get(key) === undefined && this._resources.get(ctor)?.get(meta.src) === undefined) { + this._deferred.set(key, { handle, options: meta.opts }); + this._handleKeys.set(handle, key); + this._claim(key, ctor, meta.src, claimer); + this._startSeamlessFetch(ctor, meta.src, meta.opts); + } else { + this._claim(key, ctor, meta.src, claimer); + } + } + /** * Seamless single-source resolution: an already-stored asset, an existing * deferred handle (retried in place when `'failed'`), or a fresh diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts new file mode 100644 index 000000000..013dc5500 --- /dev/null +++ b/test/resources/catalog-adopt.test.ts @@ -0,0 +1,114 @@ +import '#resources/seamless'; + +import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; +import { createLeaf } from '#resources/assetKindRegistry'; +import { type AssetRef } from '#resources/AssetRef'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +/** Loader with all core asset bindings (mirrors createCoreLoader in loader-seamless.test.ts / asset-ref.test.ts). */ +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +const mockFetchImage = (): void => { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => new ArrayBuffer(8), + }) as unknown as Response, + ); +}; + +const mockFetchJson = (payload: unknown): void => { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => payload, + text: async () => JSON.stringify(payload), + arrayBuffer: async () => new ArrayBuffer(0), + }) as unknown as Response, + ); +}; + +describe('Loader._adopt', () => { + beforeEach(() => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('fills an externally-created placeholder Texture in place after fetch (identity preserved)', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + // Built with NO loader at all — mirrors what Assets.from() hands back. + const leaf = createLeaf('texture', 'ship.png') as Texture; + + expect(leaf.loadState).toBe('loading'); + expect(leaf.width).toBe(0); + + loader._adopt(leaf, Symbol('claimer')); + + await expect(leaf.loaded).resolves.toBe(leaf); // heals in place — SAME object + expect(leaf.loadState).toBe('ready'); + expect(leaf).toBeInstanceOf(Texture); + expect(leaf.width).toBe(4); + + // The loader's own get() for the same source resolves to the adopted handle. + expect(loader.get(Texture, 'ship.png')).toBe(leaf); + }); + + test('adopting the same handle twice does not restart the fetch (idempotent)', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const leaf = createLeaf('texture', 'ship.png') as Texture; + const claimer = Symbol('claimer'); + + loader._adopt(leaf, claimer); + loader._adopt(leaf, claimer); + + await expect(leaf.loaded).resolves.toBe(leaf); + expect(leaf.loadState).toBe('ready'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('fills an externally-created value leaf (AssetRef) in place after fetch', async () => { + mockFetchJson({ hp: 3 }); + const loader = createCoreLoader(); + + const leaf = createLeaf('json', 'cfg.json') as AssetRef; + + expect(leaf.loadState).toBe('loading'); + expect(() => leaf.value).toThrow("'loading'"); + + loader._adopt(leaf, Symbol('claimer')); + + await expect(leaf.loaded).resolves.toEqual({ hp: 3 }); + expect(leaf.loadState).toBe('ready'); + expect(leaf.value).toEqual({ hp: 3 }); + }); + + test('throws for a value with no assetMeta stamp', () => { + const loader = createCoreLoader(); + + expect(() => loader._adopt({}, Symbol('claimer'))).toThrow('no assetMeta'); + }); +}); From 14ab3ee280f2d91c3f2d771ad7b672211ab1e8b8 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:57:48 +0200 Subject: [PATCH 14/85] fix(resources): _adopt fills already-stored handle in place + registers handleKey Loader._adopt's already-stored branch (resource loaded/stored elsewhere before a leaf is adopted, or a value already stored via load() with no _refs entry yet) only called _claim() and never filled the adopted handle nor registered it in _handleKeys. The adopted leaf hung in 'loading' forever, and release(handle) silently couldn't resolve its key (claim leak). Fix mirrors _getSeamless/_getRef's stored-fast-path exactly: fill the adopted handle in place from the stored donor (adapter.fill for resources, AssetRef._fill for values) instead of swapping to the stored object, so per-catalog identity is preserved, and register _handleKeys so release(handle) works. --- src/resources/Loader.ts | 40 +++++++++++--- test/resources/catalog-adopt.test.ts | 80 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 49bece6ad..f4051f770 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -1631,25 +1631,51 @@ export class Loader { if (!this._refs.has(key)) { this._refs.set(key, { ref: handle, options: meta.opts }); this._handleKeys.set(handle, key); - } - this._claim(key, ctor, meta.src, claimer); + // Mirrors _getRef's stored-fast-path: a value already sitting in + // `_resources` (stored elsewhere before this leaf was adopted) fills + // this ref immediately instead of leaving it 'loading' forever. + const stored = this._resources.get(ctor)?.get(meta.src); - if (this._resources.get(ctor)?.get(meta.src) === undefined) { - this._startRefFetch(ctor, meta.src, meta.opts); + if (stored !== undefined) { + handle._fill(stored); + } else { + this._startRefFetch(ctor, meta.src, meta.opts); + } } + this._claim(key, ctor, meta.src, claimer); + return; } - if (this._deferred.get(key) === undefined && this._resources.get(ctor)?.get(meta.src) === undefined) { + const deferredEntry = this._deferred.get(key); + const stored = this._resources.get(ctor)?.get(meta.src); + + if (deferredEntry === undefined && stored === undefined) { this._deferred.set(key, { handle, options: meta.opts }); this._handleKeys.set(handle, key); this._claim(key, ctor, meta.src, claimer); this._startSeamlessFetch(ctor, meta.src, meta.opts); - } else { - this._claim(key, ctor, meta.src, claimer); + + return; + } + + // Already stored for this key (e.g. loaded elsewhere before this leaf was + // adopted — the core catalog scenario) and this exact handle has not been + // filled/registered yet: transplant the stored donor into THIS handle in + // place (per-catalog identity — do NOT swap to the stored object; the + // caller already holds this leaf) and register it so `release(handle)` + // can resolve its key. A handle already filled by an earlier `_adopt` + // call (or by the normal fetch-completion path) is a no-op here. + if (stored !== undefined && this._handleKeys.get(handle) !== key) { + const adapter = this._seamlessAdapters.get(ctor); + + adapter?.fill(handle, stored); + this._handleKeys.set(handle, key); } + + this._claim(key, ctor, meta.src, claimer); } /** diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts index 013dc5500..8d943bf40 100644 --- a/test/resources/catalog-adopt.test.ts +++ b/test/resources/catalog-adopt.test.ts @@ -6,6 +6,7 @@ import { createLeaf } from '#resources/assetKindRegistry'; import { type AssetRef } from '#resources/AssetRef'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; +import { Json } from '#resources/tokens'; /** Loader with all core asset bindings (mirrors createCoreLoader in loader-seamless.test.ts / asset-ref.test.ts). */ function createCoreLoader(): Loader { @@ -90,6 +91,53 @@ describe('Loader._adopt', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); + test('resource already stored elsewhere before adopt: fills the adopted handle in place, preserves per-catalog identity, and release() finds its claim', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + // "Loaded elsewhere earlier" — the core catalog scenario: some other + // consumer already claimed and fully loaded this source under its own + // scope, well before this leaf is ever adopted. + const stored = loader.get(Texture, 'x.png'); + await stored.loaded; + expect(stored.loadState).toBe('ready'); + expect(stored.width).toBe(4); + + // Built with NO loader at all — mirrors what Assets.from() hands back — + // and is a DISTINCT object from the already-stored resource. + const leaf = createLeaf('texture', 'x.png') as Texture; + expect(leaf.loadState).toBe('loading'); + expect(leaf).not.toBe(stored); + + const claimer = Symbol('adopter'); + loader._adopt(leaf, claimer); + + // Bug: previously only _claim() ran for the already-stored branch, so the + // adopted leaf never healed and stayed 'loading' forever. + expect(leaf.loadState).toBe('ready'); + await expect(leaf.loaded).resolves.toBe(leaf); + expect(leaf.width).toBe(4); + expect(leaf).not.toBe(stored); // filled in place, not swapped — identity preserved + + // Bug: _handleKeys was never registered for this branch, so release(handle) + // silently couldn't resolve the key and the claim leaked. release(handle) + // always targets the app-lifetime root claimer (same scope loader.get() + // claimed under above), so it must now actually drop that scope. + const key = loader['_key'](Texture, 'x.png'); + expect(loader['_claims'].get(key)?.scopes.has(loader['_rootClaimer'])).toBe(true); + + loader.release(leaf); + + expect(loader['_claims'].get(key)?.scopes.has(loader['_rootClaimer'])).toBe(false); + expect(stored.loadState).toBe('ready'); // adopter's own claim still holds it alive + expect(stored.width).toBe(4); + + // Releasing the adopter's own claim too drops the last scope → eviction. + loader._release(key, claimer); + expect(stored.loadState).toBe('loading'); + expect(stored.width).toBe(0); + }); + test('fills an externally-created value leaf (AssetRef) in place after fetch', async () => { mockFetchJson({ hp: 3 }); const loader = createCoreLoader(); @@ -106,6 +154,38 @@ describe('Loader._adopt', () => { expect(leaf.value).toEqual({ hp: 3 }); }); + test('value already stored elsewhere before adopt: fills the adopted AssetRef in place and release() finds its claim', async () => { + mockFetchJson({ hp: 3 }); + const loader = createCoreLoader(); + + // "Loaded elsewhere earlier" — the core catalog scenario: a bulk load() + // (not get()) already resolved this value under its own scope, well + // before this leaf is ever adopted, and — crucially — WITHOUT ever + // creating an AssetRef for the key (load() never touches `_refs`), so + // this exercises the exact stored-raw-value fast path `_getRef` uses. + await loader.load(Json, 'cfg.json'); + + const leaf = createLeaf('json', 'cfg.json') as AssetRef; + expect(leaf.loadState).toBe('loading'); + + const claimer = Symbol('adopter'); + loader._adopt(leaf, claimer); + + // Bug: previously only _claim() ran, so the adopted ref never filled and + // .value stayed stuck throwing "'loading'" forever. + expect(leaf.loadState).toBe('ready'); + await expect(leaf.loaded).resolves.toEqual({ hp: 3 }); + expect(leaf.value).toEqual({ hp: 3 }); + + // Bug: release(handle) couldn't resolve the key for this branch either. + const key = loader['_key'](Json, 'cfg.json'); + expect(loader['_claims'].get(key)?.scopes.has(loader['_rootClaimer'])).toBe(true); + + loader.release(leaf); + + expect(loader['_claims'].get(key)?.scopes.has(loader['_rootClaimer'])).toBe(false); + }); + test('throws for a value with no assetMeta stamp', () => { const loader = createCoreLoader(); From 3ddf927c5b7901402ce79535412e4bf72ddff61b Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 06:29:10 +0200 Subject: [PATCH 15/85] feat(resources): Assets.from handle-hybrid leaves + get/load(catalog) adoption Flip Assets.from() leaves to meta-stamped handle-hybrids (Texture/Sound or AssetRef) built via createLeaf, and route get/load of a catalog or a single meta-stamped leaf through Loader._adopt (fill in place, scope-scoped claim). - Assets.ts: leaves built with createLeaf; InferLeaf types resource kinds as the resource and value kinds as AssetRef via an explicit ValueAssetKind check (object-heuristic mis-types xml/srt/vtt/binary/wasm); export InferAssetsProperties. - Loader.ts: _getClaimed + _loadClaimed adopt catalogs and single leaves; add _createAdoptedQueue reusing LoadingQueue progress/settle machinery; add get(catalog)/get(leaf)/load(leaf) overloads; rewrite unload(container) to release each leaf's claim; document _adopt's already-stored fast path (S7). Breaking (pre-1.0): an already-constructed Asset in a catalog is now converted to a leaf, not passed through by reference. Migrate shipped descriptor-contract tests and add end-to-end catalog get/load tests. --- src/resources/AssetDefinitions.ts | 11 ++ src/resources/Assets.ts | 60 ++++++++--- src/resources/Loader.ts | 147 +++++++++++++++++++++------ test/resources/assets.test.ts | 29 ++++-- test/resources/catalog-adopt.test.ts | 93 +++++++++++++++++ test/resources/loader.test.ts | 58 ++++++----- 6 files changed, 324 insertions(+), 74 deletions(-) diff --git a/src/resources/AssetDefinitions.ts b/src/resources/AssetDefinitions.ts index 42d20a609..2bfb86005 100644 --- a/src/resources/AssetDefinitions.ts +++ b/src/resources/AssetDefinitions.ts @@ -48,6 +48,17 @@ export type AnyAssetConfig = { [K in keyof AssetDefinitions]: { type: K } & AssetDefinitions[K]['config']; }[keyof AssetDefinitions]; +/** + * Kinds whose catalog leaf is a deferred {@link AssetRef} rather than a + * heal-in-place resource handle. This is the type-level mirror of the + * `isValue: true` registrations in `seamless.ts` — keep the two in sync. + * + * A structural `R extends object` heuristic cannot classify these, because + * several value resources (`Document`, `VTTCue[]`, `ArrayBuffer`, + * `WebAssembly.Module`) are object types; only an explicit kind list is correct. + */ +export type ValueAssetKind = 'json' | 'text' | 'csv' | 'xml' | 'srt' | 'vtt' | 'binary' | 'wasm'; + export type AssetInput = AnyAssetConfig | Asset; export type InferAssetResource = diff --git a/src/resources/Assets.ts b/src/resources/Assets.ts index e21f9c75b..bd0a9dd1d 100644 --- a/src/resources/Assets.ts +++ b/src/resources/Assets.ts @@ -1,17 +1,40 @@ import type { Asset } from './Asset'; import { AssetImpl } from './Asset'; -import type { AnyAssetConfig, AssetInput, InferAssetResource } from './AssetDefinitions'; +import type { AnyAssetConfig, AssetDefinitions, AssetInput, ValueAssetKind } from './AssetDefinitions'; +import { createLeaf } from './assetKindRegistry'; +import type { AssetRef } from './AssetRef'; // --------------------------------------------------------------------------- // Helper types // --------------------------------------------------------------------------- +/** + * The handle-hybrid a catalog leaf materializes as: a resource kind's leaf IS + * the placeholder resource (`Texture`, `Sound`, …) that heals in place, while a + * {@link ValueAssetKind}'s leaf is a deferred {@link AssetRef}. + * + * For a plain config the specific kind `K` is known, so value/resource is + * classified precisely. For an already-constructed `Asset` only the resource + * type `T` survives, so we fall back to a structural heuristic — accurate for + * the resource kinds that flow through catalogs. + */ +type InferLeaf = + I extends Asset + ? T extends object + ? T + : AssetRef + : I extends { type: infer K extends keyof AssetDefinitions } + ? K extends ValueAssetKind + ? AssetRef + : AssetDefinitions[K]['resource'] + : never; + export type InferAssetsEntries> = { - [K in keyof M]: Asset>; + [K in keyof M]: InferLeaf; }; -type InferAssetsProperties> = { - readonly [K in keyof M]: Asset>; +export type InferAssetsProperties> = { + readonly [K in keyof M]: InferLeaf; }; // --------------------------------------------------------------------------- @@ -27,16 +50,23 @@ export class AssetsImpl> { throw new Error('An Assets container may not define an asset named "entries": ' + 'that name is reserved for the spread-composition helper.'); } - const entries: Record> = {}; + const entries: Record = {}; for (const key of Object.keys(definition)) { const value = definition[key]; - const assetRef: Asset = value instanceof AssetImpl ? value : new AssetImpl(value as AnyAssetConfig); + // Both a plain config and an already-constructed Asset (which carries its + // `_config`) resolve to `{ type, source, ...opts }`, then to a meta-stamped + // handle-hybrid leaf. An already-constructed Asset is CONVERTED to a leaf — + // it is no longer passed through by reference (pre-1.0 breaking change). + const config = value instanceof AssetImpl ? value._config : (value as AnyAssetConfig); + const { type, source, ...rest } = config; + const opts = Object.keys(rest).length > 0 ? rest : undefined; + const leaf = createLeaf(type, source, opts); - entries[key] = assetRef; + entries[key] = leaf; Object.defineProperty(this, key, { - value: assetRef, + value: leaf, enumerable: true, configurable: false, writable: false, @@ -54,19 +84,21 @@ export class AssetsImpl> { /** * A reusable, typed asset container. * - * Plain configs are converted to `Asset` instances; already-created - * `Asset` values are passed through. The container exposes direct - * typed properties and an `entries` record for spread loading. + * Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS + * a usable placeholder resource (`Texture`/`Sound`) that heals in place once + * adopted by a loader; a value kind's leaf is an {@link AssetRef}. The container + * exposes those leaves as direct typed properties and via an `entries` record. * * @example * ```ts * const TitleAssets = new Assets({ * logo: { type: 'texture', source: '/logo.png' }, - * music: { type: 'music', source: '/title.ogg' }, + * config: { type: 'json', source: '/title.json' }, * }); * - * TitleAssets.logo; // Asset - * loader.load({ ...TitleAssets.entries }); + * TitleAssets.logo; // Texture (placeholder until adopted) + * TitleAssets.config; // AssetRef + * loader.load(TitleAssets); * ``` */ export type Assets> = AssetsImpl & InferAssetsProperties; diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index f4051f770..12ff9bb3f 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -13,7 +13,7 @@ import type { AssetManifest, LoadBundleOptions } from './AssetManifest'; import { BundleLoadError, defineAssetManifest } from './AssetManifest'; import { _readMeta } from './assetMeta'; import { AssetRef } from './AssetRef'; -import { type Assets, AssetsImpl } from './Assets'; +import { type Assets, AssetsImpl, type InferAssetsProperties } from './Assets'; import { CacheFirstStrategy } from './CacheFirstStrategy'; import type { CacheStore } from './CacheStore'; import type { CacheStrategy } from './CacheStrategy'; @@ -729,6 +729,8 @@ export class Loader { public load(asset: Asset): LoadingQueue; public load>(assets: Assets): LoadingQueue>; public load>(config: M): LoadingQueue>; + // Single handle-hybrid leaf (an `Assets.from()` property): adopt + resolve its value. + public load(leaf: T): LoadingQueue; // ----------------------------------------------------------------------- // Loading — extension-based (type inferred from file extension) @@ -796,18 +798,20 @@ export class Loader { return this._createLoadingQueue(claimer, [{ alias, asset }], results => results.get(alias)); } - // 2. Assets container + // 2. Assets container — adopt every handle-hybrid leaf (fill in place, + // claim under `claimer`) and resolve the adopted queue to a map of the + // loaded values/handles. The container's own leaves heal in place. if (arg0 instanceof AssetsImpl) { - const container = arg0 as Assets>; - const items = Object.entries(container.entries).map(([alias, a]) => ({ - alias, - asset: a, - })); + const entries = Object.entries((arg0 as AssetsImpl>).entries) as Array<[string, object]>; - return this._createLoadingQueue(claimer, items, results => { + for (const [, leaf] of entries) { + this._adopt(leaf, claimer); + } + + return this._createAdoptedQueue(entries, results => { const out: Record = {}; - for (const { alias } of items) { + for (const [alias] of entries) { out[alias] = results.get(alias); } @@ -815,6 +819,15 @@ export class Loader { }); } + // 2a. Single meta-stamped leaf (e.g. `load(assets.ship)`) — adopt it and + // resolve its loaded value/handle directly. + if (_readMeta(arg0) !== undefined) { + const leaf = arg0 as object; + this._adopt(leaf, claimer); + + return this._createAdoptedQueue([['value', leaf]], results => results.get('value')); + } + // 2b. Extension-based: single path string with no type token if (typeof arg0 === 'string' && arg1 === undefined) { const path = arg0; @@ -1127,7 +1140,21 @@ export class Loader { * for a non-throwing alternative, or {@link has} to guard the call. */ public get(type: T, alias: string): LoadReturn; - public get(typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { + + /** + * Adopts an {@link Assets} catalog: every handle-hybrid leaf is registered, + * claimed, and driven to load, and the same leaf objects are returned keyed by + * their record key. The catalog's own properties heal in place as payloads + * arrive — the returned map holds those very leaves. + */ + public get>(catalog: Assets): InferAssetsProperties; + + /** + * Adopts a single handle-hybrid leaf (an `Assets.from()` property) and returns + * it — the same object, healing in place once its payload arrives. + */ + public get(leaf: T): T; + public get(typeOrPath: Loadable | string | object, source?: unknown, options?: unknown): unknown { return this._getClaimed(this._rootClaimer, typeOrPath, source, options); } @@ -1140,7 +1167,27 @@ export class Loader { * `'loading'`, which `_getSeamless` alone would not re-fetch). * @internal */ - public _getClaimed(claimer: symbol, typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { + public _getClaimed(claimer: symbol, typeOrPath: Loadable | string | object, source?: unknown, options?: unknown): unknown { + // Assets container — adopt every handle-hybrid leaf (fill in place, claim + // under `claimer`) and return the leaves keyed by their record key. + if (typeOrPath instanceof AssetsImpl) { + const out: Record = {}; + + for (const [k, leaf] of Object.entries(typeOrPath.entries)) { + this._adopt(leaf, claimer); + out[k] = leaf; + } + + return out; + } + + // Single meta-stamped leaf (e.g. `get(assets.ship)`) — adopt and return it. + if (_readMeta(typeOrPath) !== undefined) { + this._adopt(typeOrPath as object, claimer); + + return typeOrPath; + } + if (typeof typeOrPath === 'string') { const path = typeOrPath; const ctor = this._resolveExtensionType(path); @@ -1161,7 +1208,8 @@ export class Loader { return handle; } - const ctor = typeOrPath; + // Not a container, meta-leaf, or path string: a Loadable type token. + const ctor = typeOrPath as Loadable; const adapter = this._seamlessAdapters.get(ctor); if (adapter !== undefined) { @@ -1316,25 +1364,16 @@ export class Loader { } if (arg0 instanceof AssetsImpl) { - const container = arg0 as Assets>; - - for (const [alias, a] of Object.entries(container.entries)) { - const assetRef = a; - const ctor = this._assetTypeMap.get(assetRef.type); - - if (!ctor) continue; - - const identityKey = this._resolveAssetIdentityKey(ctor, assetRef); - const aliasSet = this._identityKeyToAliases.get(identityKey); - - if (aliasSet?.has(alias)) { - // Also remove all other aliases that share this resource identity - for (const a2 of [...aliasSet]) { - this._unloadOne(ctor, a2); - } - } else { - this._unloadOne(ctor, alias); - } + // Under adoption a catalog no longer maps to legacy alias entries: its + // leaves are handle-hybrids claimed under the app-lifetime root scope by + // `get`/`load`. Unloading a catalog therefore RELEASES each leaf's root + // claim — the last release evicts the payload in place (resource handles + // heal to 'loading'). A never-adopted leaf has no registered key, so its + // release is a silent no-op. + const container = arg0 as AssetsImpl>; + + for (const leaf of Object.values(container.entries)) { + this.release(leaf as object); } return this; @@ -1671,6 +1710,11 @@ export class Loader { if (stored !== undefined && this._handleKeys.get(handle) !== key) { const adapter = this._seamlessAdapters.get(ctor); + // §7 accepted gap: this leaf fills once from the stored payload but is not + // entered into per-key bookkeeping, so a later evict+heal of this key will + // not touch it (it keeps the stale payload). Closes when §7 introduces + // per-key multi-handle tracking. Unreachable in S1's usage surface. See + // 07-asset-access-design.md §12. adapter?.fill(handle, stored); this._handleKeys.set(handle, key); } @@ -2000,6 +2044,47 @@ export class Loader { return queue; } + /** + * Progress-aware queue over already-{@link _adopt}ed handle-hybrid leaves. + * + * Mirrors {@link _createLoadingQueue}'s progress/settle machinery, but the + * fetch is already driven by `_adopt`; each item's promise is simply the + * leaf's own readiness promise (`leaf.loaded` — `Promise` for a resource + * handle, `Promise` for an `AssetRef`). No `_claim` here: adoption already + * claimed each key. `buildResult` shapes the resolved values into the return. + * @internal + */ + private _createAdoptedQueue(entries: Array<[string, object]>, buildResult: (results: Map) => T): LoadingQueue { + const results = new Map(); + let notifyFn: ((success: boolean) => void) | null = null; + + const itemPromises = entries.map(([alias, leaf]) => { + const src = _readMeta(leaf)?.src ?? alias; + this._onFgBatchStart(alias, src); + const loaded = (leaf as { loaded: Promise }).loaded; + + return loaded.then( + value => { + results.set(alias, value); + notifyFn?.(true); + this._onFgBatchSettled(alias, true); + }, + error => { + notifyFn?.(false); + this._onFgBatchSettled(alias, false, this._normalizeError(error)); + throw error; + }, + ); + }); + + const promise = Promise.all(itemPromises).then(() => buildResult(results)); + + const queue = new LoadingQueue(promise, entries.length); + notifyFn = queue._notifyItem.bind(queue); + + return queue; + } + /** * Loads a single asset from an `Asset` reference using identity-based * in-flight deduplication. diff --git a/test/resources/assets.test.ts b/test/resources/assets.test.ts index c553a29b5..0c8fe264c 100644 --- a/test/resources/assets.test.ts +++ b/test/resources/assets.test.ts @@ -1,23 +1,40 @@ +import '#resources/seamless'; + +import { Texture } from '#rendering/texture/Texture'; import { Asset } from '#resources/Asset'; +import { _readMeta } from '#resources/assetMeta'; import { Assets } from '#resources/Assets'; describe('Assets', () => { - test('wraps plain configs in Asset instances exposed as direct properties and via entries', () => { + test('materializes plain configs into meta-stamped handle-hybrid leaves, exposed as direct properties and via entries', () => { const bag = new Assets({ logo: { type: 'texture', source: '/logo.png' }, }); - expect(bag.logo.source).toBe('/logo.png'); - expect(bag.logo.type).toBe('texture'); + // The leaf IS a usable placeholder resource (heals in place once adopted), + // carrying its descriptor as non-enumerable asset meta. + expect(bag.logo).toBeInstanceOf(Texture); + expect(_readMeta(bag.logo)).toEqual({ kind: 'texture', src: '/logo.png', opts: undefined }); expect(bag.entries.logo).toBe(bag.logo); }); - test('passes through an already-constructed Asset instance unchanged', () => { + test('carries extra config fields into the leaf meta opts', () => { + const bag = new Assets({ + logo: { type: 'texture', source: '/logo.png', samplerOptions: { minFilter: 'nearest' } }, + }); + + expect(_readMeta(bag.logo)).toEqual({ kind: 'texture', src: '/logo.png', opts: { samplerOptions: { minFilter: 'nearest' } } }); + }); + + test('converts an already-constructed Asset into a fresh handle-hybrid leaf (no longer passed through by reference)', () => { const existing = new Asset({ type: 'texture', source: '/shared.png' }); const bag = new Assets({ logo: existing }); - expect(bag.logo).toBe(existing); - expect(bag.entries.logo).toBe(existing); + // Pre-1.0 breaking change: a catalog value is always materialized as a leaf. + expect(bag.logo).not.toBe(existing); + expect(bag.logo).toBeInstanceOf(Texture); + expect(_readMeta(bag.logo)).toEqual({ kind: 'texture', src: '/shared.png', opts: undefined }); + expect(bag.entries.logo).toBe(bag.logo); }); test('rejects a definition that defines a reserved "entries" key', () => { diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts index 8d943bf40..5c8b6f0c5 100644 --- a/test/resources/catalog-adopt.test.ts +++ b/test/resources/catalog-adopt.test.ts @@ -4,6 +4,7 @@ import { materializeAssetBindings } from '#extensions/materialize'; import { Texture } from '#rendering/texture/Texture'; import { createLeaf } from '#resources/assetKindRegistry'; import { type AssetRef } from '#resources/AssetRef'; +import { Assets } from '#resources/Assets'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; import { Json } from '#resources/tokens'; @@ -192,3 +193,95 @@ describe('Loader._adopt', () => { expect(() => loader._adopt({}, Symbol('claimer'))).toThrow('no assetMeta'); }); }); + +describe('Loader.get / load — Assets catalog adoption (end-to-end)', () => { + beforeEach(() => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('get(catalog) adopts every leaf, returns the SAME leaf objects, and they heal after fetch', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const catalog = new Assets({ + ship: { type: 'texture', source: 'ship.png' }, + logo: { type: 'texture', source: 'logo.png' }, + }); + + const got = loader.get(catalog); + + // Per-catalog identity: the returned map holds the catalog's own leaves. + expect(got.ship).toBe(catalog.ship); + expect(got.logo).toBe(catalog.logo); + expect(catalog.ship.loadState).toBe('loading'); + + await Promise.all([catalog.ship.loaded, catalog.logo.loaded]); + + expect(catalog.ship.loadState).toBe('ready'); + expect(catalog.ship.width).toBe(4); + // The loader's own get() for the same source resolves to the adopted leaf. + expect(loader.get(Texture, 'ship.png')).toBe(catalog.ship); + }); + + test('load(catalog) resolves to a map of loaded values, forwards onProgress, and heals the SAME leaves as get', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const catalog = new Assets({ + ship: { type: 'texture', source: 'ship.png' }, + logo: { type: 'texture', source: 'logo.png' }, + }); + + const progress: number[] = []; + const queue = loader.load(catalog); + queue.onProgress.add(p => progress.push(p.loaded)); + + const result = await queue; + + // A resource leaf's `.loaded` resolves to the handle itself → the resolved + // map holds the catalog's own leaves, which have healed in place. + expect(result.ship).toBe(catalog.ship); + expect(result.logo).toBe(catalog.logo); + expect(catalog.ship.loadState).toBe('ready'); + expect(progress.at(-1)).toBe(2); + }); + + test('load(catalog) resolves a value leaf to its raw parsed value while healing its ref in place', async () => { + mockFetchJson({ hp: 7 }); + const loader = createCoreLoader(); + const catalog = new Assets({ config: { type: 'json', source: 'cfg.json' } }); + + const result = await loader.load(catalog); + + expect(result.config).toEqual({ hp: 7 }); // raw value in the resolved map + expect(catalog.config.value).toEqual({ hp: 7 }); // the ref healed in place + }); + + test('two catalogs with the same source get DISTINCT leaf objects that both heal from ONE fetch (source-keyed dedup)', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const a = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + const b = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + + expect(a.ship).not.toBe(b.ship); // per-catalog identity + + loader.get(a); + await a.ship.loaded; + expect(a.ship.loadState).toBe('ready'); + + // Adopting b's leaf fills it in place from the already-stored payload. + loader.get(b); + await b.ship.loaded; + + expect(b.ship.loadState).toBe('ready'); + expect(b.ship.width).toBe(4); + expect(b.ship).not.toBe(a.ship); // still distinct objects + expect(global.fetch).toHaveBeenCalledTimes(1); // one network fetch for the shared source + }); +}); diff --git a/test/resources/loader.test.ts b/test/resources/loader.test.ts index 68dc11131..72435d549 100644 --- a/test/resources/loader.test.ts +++ b/test/resources/loader.test.ts @@ -1,5 +1,8 @@ -import type { AssetHandler } from '#extensions/Extension'; +import '#resources/seamless'; + +import type { AssetHandler } from '#extensions/Extension'; import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; import { Asset } from '#resources/Asset'; import { encodeContainer } from '#resources/AssetContainer'; import type { AssetFactory } from '#resources/AssetFactory'; @@ -1116,27 +1119,35 @@ describe('Asset / Assets identity and alias semantics', () => { expect(loader.has(MockAssetType, 'heroB')).toBe(false); }); - test('unload(assets) unloads all entries from an Assets container', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); + test('unload(assets) releases every leaf claim, evicting the adopted resources', async () => { + // Intent preserved: unloading a container drops all of its entries. Under + // adoption this means releasing each leaf's root claim → last-claim eviction. + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + const loader = createCoreLoader({ basePath: '/' }); + global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', arrayBuffer: async () => new ArrayBuffer(8) }) as unknown as Response); const container = new Assets({ - hero: { type: 'mockAsset', source: 'hero.dat' }, - logo: { type: 'mockAsset', source: 'logo.dat' }, + hero: { type: 'texture', source: 'hero.png' }, + logo: { type: 'texture', source: 'logo.png' }, }); await loader.load(container); - expect(loader.has(MockAssetType, 'hero')).toBe(true); - expect(loader.has(MockAssetType, 'logo')).toBe(true); + expect(loader.has(Texture, 'hero.png')).toBe(true); + expect(loader.has(Texture, 'logo.png')).toBe(true); + expect((container.hero as Texture).loadState).toBe('ready'); loader.unload(container); - expect(loader.has(MockAssetType, 'hero')).toBe(false); - expect(loader.has(MockAssetType, 'logo')).toBe(false); + // Last claim released → payload evicted; the leaves heal back to 'loading'. + expect(loader.has(Texture, 'hero.png')).toBe(false); + expect(loader.has(Texture, 'logo.png')).toBe(false); + expect((container.hero as Texture).loadState).toBe('loading'); + + vi.unstubAllGlobals(); }); test('aliases are cleared from tracking when underlying asset unloads', async () => { @@ -1172,7 +1183,7 @@ describe('Assets reserved "entries" key', () => { test('does not throw for a normal asset name', () => { expect(() => { new Assets({ - logo: { type: 'mockAsset', source: '/logo.dat' }, + logo: { type: 'texture', source: '/logo.png' }, }); }).not.toThrow(); }); @@ -1920,22 +1931,23 @@ describe('unload() edge cases', () => { expect(loader.has(MockAssetType, 'never.dat')).toBe(false); }); - test('unload(assets) skips container entries whose asset type was never registered', () => { + test('unload(assets) is a silent no-op for a leaf whose kind this loader never bound', () => { + // Intent preserved: a catalog entry the loader doesn't know is skipped, not + // thrown. A bare loader (no core bindings) never adopted the leaf, so its + // release finds no registered key and does nothing. const loader = new Loader({ basePath: '/' }); - const container = new Assets({ orphan: { type: 'mockAsset', source: 'x.dat' } }); + const container = new Assets({ orphan: { type: 'texture', source: 'x.png' } }); expect(() => loader.unload(container)).not.toThrow(); }); - test('unload(assets) falls back to per-alias unload when identity was never tracked', () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - - const container = new Assets({ orphan: { type: 'mockAsset', source: 'never.dat' } }); + test('unload(assets) is a silent no-op when the container was never adopted/loaded', () => { + // Intent preserved: unloading entries that were never tracked does nothing. + const loader = createCoreLoader({ basePath: '/' }); + const container = new Assets({ orphan: { type: 'texture', source: 'never.png' } }); expect(() => loader.unload(container)).not.toThrow(); + expect(loader.has(Texture, 'never.png')).toBe(false); }); }); From 2a1fc1851608b26bb66e795c6484f90ef3ab97c2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 06:43:16 +0200 Subject: [PATCH 16/85] feat(core): scene-scoped catalog adopt + auto-release on destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SceneLoader.load already forwarded Assets catalogs to _loadClaimed untouched (added alongside the scene-scoped loader itself). SceneLoader.get was missing the mirrored catalog/leaf overloads, so `scene.loader.get(catalog)` type-checked against `never` and would not compile. Add the two overloads (mirroring Loader.get(catalog)/Loader.get(leaf)) and widen the get() implementation signature to accept `object`, forwarding arg0 unchanged to _getClaimed — SceneLoader stays a pure scope-passing proxy. Adds test/core/scene-loader-catalog.test.ts covering both get(catalog) and load(catalog): leaves are claimed under the scene's own scope, and scene.destroy() drops the last claim, evicting the payload back to 'loading'. --- src/core/Scene.ts | 8 ++- test/core/scene-loader-catalog.test.ts | 85 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 test/core/scene-loader-catalog.test.ts diff --git a/src/core/Scene.ts b/src/core/Scene.ts index d6b506b80..1832f07c6 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -8,7 +8,7 @@ import type { Texture } from '#rendering/texture/Texture'; import type { Asset } from '#resources/Asset'; import type { AssetInput } from '#resources/AssetDefinitions'; import type { AssetRef } from '#resources/AssetRef'; -import type { Assets } from '#resources/Assets'; +import type { Assets, InferAssetsProperties } from '#resources/Assets'; import type { TextureFactoryOptions } from '#resources/factories/TextureFactory'; import type { BatchValue, ConstrainedLoadable, InferLoadedMap, Loadable, LoadByPath, Loader, LoadReturn, PathExtension } from '#resources/Loader'; import type { LoadingQueue } from '#resources/LoadingQueue'; @@ -125,7 +125,11 @@ class SceneLoader implements Destroyable { public get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef; public get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef; public get(type: T, alias: string): LoadReturn; - public get(typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { + // Adopts an Assets catalog under the scene scope (mirrors Loader.get(catalog)). + public get>(catalog: Assets): InferAssetsProperties; + // Adopts a single handle-hybrid leaf under the scene scope (mirrors Loader.get(leaf)). + public get(leaf: T): T; + public get(typeOrPath: Loadable | string | object, source?: unknown, options?: unknown): unknown { return this._loader._getClaimed(this._scope, typeOrPath, source, options); } diff --git a/test/core/scene-loader-catalog.test.ts b/test/core/scene-loader-catalog.test.ts new file mode 100644 index 000000000..9d8b83e40 --- /dev/null +++ b/test/core/scene-loader-catalog.test.ts @@ -0,0 +1,85 @@ +import '#resources/seamless'; + +import type { Application } from '#core/Application'; +import { Scene } from '#core/Scene'; +import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; +import { Assets } from '#resources/Assets'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +// Mirrors test/resources/catalog-adopt.test.ts's texture harness (createImageBitmap +// stub + fetch mock) combined with test/core/scene-loader.test.ts's fake-Application +// pattern (a real Loader wrapped in `{ loader } as unknown as Application`). +const originalFetch = global.fetch; + +function mockFetchImage(): void { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => new ArrayBuffer(8), + }) as unknown as Response, + ); +} + +function makeSceneWithTextureLoader(): { scene: Scene; loader: Loader } { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + const app = { loader } as unknown as Application; + const scene = new Scene(); + scene.app = app; + + return { scene, loader }; +} + +describe('SceneLoader catalog adopt', () => { + beforeEach(() => { + mockFetchImage(); + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('claims catalog leaves under the scene scope and releases on destroy', async () => { + const { scene, loader } = makeSceneWithTextureLoader(); + const assets = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + + scene.loader.load(assets); + await assets.ship.loaded; + expect(assets.ship.loadState).toBe('ready'); + + const key = loader['_key'](Texture, 'ship.png'); + expect(loader['_claims'].get(key)?.scopes.size).toBe(1); + + scene.destroy(); + // last claim gone → evicted (payload dropped, identity kept, back to 'loading') + expect(assets.ship.loadState).toBe('loading'); + }); + + test('get(catalog) through this.loader adopts every leaf under the scene scope', async () => { + const { scene, loader } = makeSceneWithTextureLoader(); + const assets = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + + const got = scene.loader.get(assets); + expect(got.ship).toBe(assets.ship); + + await assets.ship.loaded; + expect(assets.ship.loadState).toBe('ready'); + expect(assets.ship).toBeInstanceOf(Texture); + + const key = loader['_key'](Texture, 'ship.png'); + expect(loader['_claims'].get(key)?.scopes.size).toBe(1); + + scene.destroy(); + expect(assets.ship.loadState).toBe('loading'); + }); +}); From 79d3f4616fa5f3fa581c77758a476debbb695ef9 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 06:50:49 +0200 Subject: [PATCH 17/85] chore(resources): regen API docs for S1 catalog handle-hybrids Regenerates site/src/content/api/{assets,loader}.json to reflect the S1 handle-hybrid leaf changes: Assets container description now documents resource-leaf-is-the-resource / value-leaf-is-AssetRef semantics, and Loader gains the get(catalog)/get(leaf)/load(leaf) overload docs. --- site/src/content/api/assets.json | 4 +- site/src/content/api/loader.json | 181 ++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/site/src/content/api/assets.json b/site/src/content/api/assets.json index 65a6994c3..2cbe825b9 100644 --- a/site/src/content/api/assets.json +++ b/site/src/content/api/assets.json @@ -1,6 +1,6 @@ { "title": "Assets", - "description": "A reusable, typed asset container. Plain configs are converted to `Asset` instances; already-created `Asset` values are passed through. The container exposes direct typed properties and an `entries` record for spread loading.", + "description": "A reusable, typed asset container. Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an AssetRef. The container exposes those leaves as direct typed properties and via an `entries` record.", "symbol": "Assets", "kind": "type", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "A reusable, typed asset container.", - "Plain configs are converted to `Asset` instances; already-created `Asset` values are passed through. The container exposes direct typed properties and an `entries` record for spread loading." + "Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an AssetRef. The container exposes those leaves as direct typed properties and via an `entries` record." ], "importLine": "import { Assets } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index 803eda927..455af45cb 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 62, + "memberCount": 65, "counts": { "constructors": 1, - "methods": 51, + "methods": 54, "properties": 2, "events": 8 }, @@ -1757,6 +1757,124 @@ "returnType": "LoadReturn", "description": "Retrieves a previously loaded asset by type and alias (legacy lookup form for types without a seamless adapter; replaced by the asset graph in a later slice). Throws if the asset has not been loaded — use peek for a non-throwing alternative, or has to guard the call." }, + { + "name": "get", + "signature": "get(catalog: Assets): InferAssetsProperties", + "signatureTokens": [ + { + "text": "get", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "catalog", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Assets", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "M", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "InferAssetsProperties", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "M", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "catalog", + "type": "Assets", + "optional": false + } + ], + "returnType": "InferAssetsProperties", + "description": "Adopts an Assets catalog: every handle-hybrid leaf is registered, claimed, and driven to load, and the same leaf objects are returned keyed by their record key. The catalog's own properties heal in place as payloads arrive — the returned map holds those very leaves." + }, + { + "name": "get", + "signature": "get(leaf: T): T", + "signatureTokens": [ + { + "text": "get", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "leaf", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + } + ], + "params": [ + { + "name": "leaf", + "type": "T", + "optional": false + } + ], + "returnType": "T", + "description": "Adopts a single handle-hybrid leaf (an Assets.from() property) and returns it — the same object, healing in place once its payload arrives." + }, { "name": "has", "signature": "has(type: Loadable, alias: string): boolean", @@ -2732,6 +2850,65 @@ "returnType": "LoadingQueue>", "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." }, + { + "name": "load", + "signature": "load(leaf: T): LoadingQueue", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "leaf", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "leaf", + "type": "T", + "optional": false + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, { "name": "load", "signature": "load(path: [MatchExtension>>] extends [never] ? never : S): LoadingQueue", From b60fa6c2338bb96aa17e22f43e44bfe729e40a38 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 07:11:44 +0200 Subject: [PATCH 18/85] fix(resources): sound value-leaf load overload + SceneLoader.load leaf parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader.load's single generic leaf overload typed an AssetRef value leaf's result as LoadingQueue>, but AssetRef.loaded resolves to the raw X at runtime. Add a discriminating load(leaf: AssetRef) overload ahead of the generic one so it wins for value leaves. SceneLoader.load was missing the single-leaf overloads that SceneLoader.get already had, so scene.loader.load(assets.ship) typed against the wrong greedy overload. Mirror Loader.load's leaf overloads (including the AssetRef discriminator) verbatim, forwarding unchanged through _loadClaimed. Also correct the _adopt §7 accepted-gap comment: it claimed the stale-fill gap was unreachable in S1's usage surface, but it's reachable via a duplicate source within a single catalog (second leaf hangs at 'loading'). Comment-only, no behavior change. --- src/core/Scene.ts | 5 ++ src/resources/Loader.ts | 10 ++-- test/core/scene-loader-catalog.test.ts | 65 ++++++++++++++++++++++++++ test/resources/catalog-adopt.test.ts | 24 ++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 1832f07c6..afe029946 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -140,6 +140,11 @@ class SceneLoader implements Destroyable { public load>(assets: Assets): LoadingQueue>; // eslint-disable-next-line @typescript-eslint/unified-signatures -- mirrors Loader.load verbatim (rule disabled there too) public load>(config: M): LoadingQueue>; + // Single value-leaf (an `Assets.from()` AssetRef property): mirrors Loader.load(leaf). + // eslint-disable-next-line @typescript-eslint/unified-signatures -- mirrors Loader.load verbatim (rule disabled there too) + public load(leaf: AssetRef): LoadingQueue; + // Single handle-hybrid leaf (an `Assets.from()` property): mirrors Loader.load(leaf). + public load(leaf: T): LoadingQueue; public load(path: [PathExtension] extends [never] ? never : S): LoadingQueue; public load(path: [PathExtension] extends [never] ? never : S): LoadingQueue>; public load(type: ConstrainedLoadable, path: S, options?: unknown): LoadingQueue>; diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 12ff9bb3f..68d4e8719 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -729,6 +729,9 @@ export class Loader { public load(asset: Asset): LoadingQueue; public load>(assets: Assets): LoadingQueue>; public load>(config: M): LoadingQueue>; + // Single value-leaf (an `Assets.from()` AssetRef property): `AssetRef.loaded` resolves + // to the raw value, not the ref — this overload must win over the generic leaf one below. + public load(leaf: AssetRef): LoadingQueue; // Single handle-hybrid leaf (an `Assets.from()` property): adopt + resolve its value. public load(leaf: T): LoadingQueue; @@ -1712,9 +1715,10 @@ export class Loader { // §7 accepted gap: this leaf fills once from the stored payload but is not // entered into per-key bookkeeping, so a later evict+heal of this key will - // not touch it (it keeps the stale payload). Closes when §7 introduces - // per-key multi-handle tracking. Unreachable in S1's usage surface. See - // 07-asset-access-design.md §12. + // not touch it (it keeps the stale payload). Reachable via a duplicate + // source in one catalog (second leaf hangs at 'loading'); no shipped + // example does this. §7's per-key multi-handle tracking closes it and + // should prefer a dev-warn over a silent hang. See 07-asset-access-design.md §12. adapter?.fill(handle, stored); this._handleKeys.set(handle, key); } diff --git a/test/core/scene-loader-catalog.test.ts b/test/core/scene-loader-catalog.test.ts index 9d8b83e40..6c9e2c463 100644 --- a/test/core/scene-loader-catalog.test.ts +++ b/test/core/scene-loader-catalog.test.ts @@ -7,6 +7,7 @@ import { Texture } from '#rendering/texture/Texture'; import { Assets } from '#resources/Assets'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; +import type { LoadingQueue } from '#resources/LoadingQueue'; // Mirrors test/resources/catalog-adopt.test.ts's texture harness (createImageBitmap // stub + fetch mock) combined with test/core/scene-loader.test.ts's fake-Application @@ -25,6 +26,20 @@ function mockFetchImage(): void { ); } +function mockFetchJson(payload: unknown): void { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => payload, + text: async () => JSON.stringify(payload), + arrayBuffer: async () => new ArrayBuffer(0), + }) as unknown as Response, + ); +} + function makeSceneWithTextureLoader(): { scene: Scene; loader: Loader } { const loader = new Loader(); materializeAssetBindings(loader, coreAssetBindings); @@ -82,4 +97,54 @@ describe('SceneLoader catalog adopt', () => { scene.destroy(); expect(assets.ship.loadState).toBe('loading'); }); + + // NEW-1: `SceneLoader.load` was missing the single-leaf overloads that + // `SceneLoader.get` already had, so `scene.loader.load(assets.ship)` typed + // against the wrong (greedy) overload. These mirror Loader.load's leaf + // overloads (including the M1 AssetRef discriminator) verbatim. + test('load(single resource leaf) forwards through this.loader, claiming under the scene scope', async () => { + const { scene, loader } = makeSceneWithTextureLoader(); + const assets = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + + const result = await scene.loader.load(assets.ship); + + expect(result).toBe(assets.ship); // resource leaf resolves to the healed handle itself + expect(assets.ship.loadState).toBe('ready'); + + const key = loader['_key'](Texture, 'ship.png'); + expect(loader['_claims'].get(key)?.scopes.size).toBe(1); + + scene.destroy(); + expect(assets.ship.loadState).toBe('loading'); + }); + + test('load(single value leaf) resolves the raw value, mirroring Loader.load(AssetRef leaf)', async () => { + mockFetchJson({ hp: 3 }); + const { scene } = makeSceneWithTextureLoader(); // loader carries coreAssetBindings, incl. json + const assets = new Assets({ config: { type: 'json', source: 'cfg.json' } }); + + const result = await scene.loader.load(assets.config); + + expect(result).toEqual({ hp: 3 }); // raw value, not the AssetRef itself + expect(assets.config.value).toEqual({ hp: 3 }); // ref healed in place + }); + + test('type-level: SceneLoader.load leaf/catalog overloads mirror Loader.load', () => { + const { scene } = makeSceneWithTextureLoader(); + const assets = new Assets({ + ship: { type: 'texture', source: 'ship.png' }, + config: { type: 'json', source: 'cfg.json' }, + }); + + // Each assertion is wrapped in an uncalled arrow so only the overload + // resolution is checked — invoking `load()` for real here would fire an + // unmocked fetch. + // + // Value leaf (AssetRef): resolves to LoadingQueue, never LoadingQueue>. + expectTypeOf(() => scene.loader.load(assets.config)).returns.toEqualTypeOf>(); + // Resource leaf: resolves to LoadingQueue for the handle type itself. + expectTypeOf(() => scene.loader.load(assets.ship)).returns.toEqualTypeOf>(); + // Catalog form still resolves to the full loaded map. + expectTypeOf(() => scene.loader.load(assets)).returns.toEqualTypeOf>(); + }); }); diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts index 5c8b6f0c5..3ce73a0b7 100644 --- a/test/resources/catalog-adopt.test.ts +++ b/test/resources/catalog-adopt.test.ts @@ -7,6 +7,7 @@ import { type AssetRef } from '#resources/AssetRef'; import { Assets } from '#resources/Assets'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; +import type { LoadingQueue } from '#resources/LoadingQueue'; import { Json } from '#resources/tokens'; /** Loader with all core asset bindings (mirrors createCoreLoader in loader-seamless.test.ts / asset-ref.test.ts). */ @@ -263,6 +264,29 @@ describe('Loader.get / load — Assets catalog adoption (end-to-end)', () => { expect(catalog.config.value).toEqual({ hp: 7 }); // the ref healed in place }); + // M1: `load(leaf)` had a single generic overload (`(leaf: T): + // LoadingQueue`) that types a value leaf's result as the AssetRef itself, + // while at runtime `AssetRef.loaded` resolves to the raw parsed value (see the + // `_createAdoptedQueue` "value leaf" case above `LoadingQueue` is right for a + // resource leaf, but wrong for `AssetRef`). A discriminating `load(leaf: + // AssetRef): LoadingQueue` overload must be declared first so it wins. + test('type-level: load(AssetRef leaf) resolves LoadingQueue, not LoadingQueue>', () => { + const loader = createCoreLoader(); + const catalog = new Assets({ config: { type: 'json', source: 'cfg.json' } }); + const textureCatalog = new Assets({ ship: { type: 'texture', source: 'ship.png' } }); + + // Each assertion is wrapped in an uncalled arrow so only the overload + // resolution is checked — invoking `load()` for real here would fire an + // unmocked fetch. + // + // catalog.config: AssetRef — load() must resolve to the raw value + // type (`unknown`), never to `LoadingQueue>`. + expectTypeOf(() => loader.load(catalog.config)).returns.toEqualTypeOf>(); + + // A resource leaf (Texture) is unaffected: it still resolves to itself. + expectTypeOf(() => loader.load(textureCatalog.ship)).returns.toEqualTypeOf>(); + }); + test('two catalogs with the same source get DISTINCT leaf objects that both heal from ONE fetch (source-keyed dedup)', async () => { mockFetchImage(); const loader = createCoreLoader(); From be3a88f3e21e89cf5407a588da4bccad9d9dac6e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 07:22:34 +0200 Subject: [PATCH 19/85] =?UTF-8?q?feat(resources):=20dev-warn=20on=20duplic?= =?UTF-8?q?ate-source=20adopt=20instead=20of=20silent=20hang=20(=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader._adopt's in-flight-not-yet-stored fall-through previously just claimed a second, different handle for a key already registered under _deferred or _refs, leaving it stuck at 'loading' forever with no diagnostic (Assets.from({ a: 'x.png', b: 'x.png' }) then load(catalog) hung silently). Now emits a logger.warn identifying the duplicate source, while still claiming so refcounting stays correct. The idempotent re-adopt of the SAME handle remains a silent no-op. Full fix (per-key multi-handle tracking) is deferred to §7. --- src/resources/Loader.ts | 27 ++++++++++++++-- test/resources/catalog-adopt.test.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 68d4e8719..c132dd9fe 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -1670,7 +1670,9 @@ export class Loader { const key = this._key(ctor, meta.src); if (handle instanceof AssetRef) { - if (!this._refs.has(key)) { + const existingRef = this._refs.get(key); + + if (existingRef === undefined) { this._refs.set(key, { ref: handle, options: meta.opts }); this._handleKeys.set(handle, key); @@ -1684,6 +1686,16 @@ export class Loader { } else { this._startRefFetch(ctor, meta.src, meta.opts); } + } else if (existingRef.ref !== handle) { + // §7 accepted gap: a different AssetRef is already registered for this + // key and nothing is stored yet, so this ref cannot be filled from + // either path — it will hang at 'loading' until §7's per-key + // multi-handle tracking lands. Surface a dev-warning instead of a + // silent hang. See 07-asset-access-design.md §12. + logger.warn( + `Loader._adopt: duplicate source "${meta.src}" adopted while a different handle for it is still loading — this second handle will not fill until §7 per-key multi-handle tracking. Use a single catalog field for a shared source.`, + { source: 'Loader' }, + ); } this._claim(key, ctor, meta.src, claimer); @@ -1717,10 +1729,19 @@ export class Loader { // entered into per-key bookkeeping, so a later evict+heal of this key will // not touch it (it keeps the stale payload). Reachable via a duplicate // source in one catalog (second leaf hangs at 'loading'); no shipped - // example does this. §7's per-key multi-handle tracking closes it and - // should prefer a dev-warn over a silent hang. See 07-asset-access-design.md §12. + // example does this. §7's per-key multi-handle tracking closes it. adapter?.fill(handle, stored); this._handleKeys.set(handle, key); + } else if (deferredEntry !== undefined && stored === undefined && deferredEntry.handle !== handle) { + // §7 accepted gap: a different handle is already in flight for this key + // and nothing is stored yet, so this handle cannot be filled from either + // path — it will hang at 'loading' until §7's per-key multi-handle + // tracking lands. Surface a dev-warning instead of a silent hang. + // See 07-asset-access-design.md §12. + logger.warn( + `Loader._adopt: duplicate source "${meta.src}" adopted while a different handle for it is still loading — this second handle will not fill until §7 per-key multi-handle tracking. Use a single catalog field for a shared source.`, + { source: 'Loader' }, + ); } this._claim(key, ctor, meta.src, claimer); diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts index 3ce73a0b7..cae15cc4e 100644 --- a/test/resources/catalog-adopt.test.ts +++ b/test/resources/catalog-adopt.test.ts @@ -1,5 +1,6 @@ import '#resources/seamless'; +import { logger } from '#core/logging'; import { materializeAssetBindings } from '#extensions/materialize'; import { Texture } from '#rendering/texture/Texture'; import { createLeaf } from '#resources/assetKindRegistry'; @@ -84,6 +85,7 @@ describe('Loader._adopt', () => { const loader = createCoreLoader(); const leaf = createLeaf('texture', 'ship.png') as Texture; const claimer = Symbol('claimer'); + const warnSpy = vi.spyOn(logger, 'warn'); loader._adopt(leaf, claimer); loader._adopt(leaf, claimer); @@ -91,6 +93,27 @@ describe('Loader._adopt', () => { await expect(leaf.loaded).resolves.toBe(leaf); expect(leaf.loadState).toBe('ready'); expect(global.fetch).toHaveBeenCalledTimes(1); + // Idempotent re-adopt of the SAME handle must stay a silent no-op. + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + test('duplicate source, different handle, still in flight: dev-warns instead of hanging silently (§7 gap)', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const warnSpy = vi.spyOn(logger, 'warn'); + + const a = createLeaf('texture', 'x.png') as Texture; + const b = createLeaf('texture', 'x.png') as Texture; + + loader._adopt(a, Symbol('claimer-a')); + loader._adopt(b, Symbol('claimer-b')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain('duplicate source "x.png"'); + + warnSpy.mockRestore(); }); test('resource already stored elsewhere before adopt: fills the adopted handle in place, preserves per-catalog identity, and release() finds its claim', async () => { @@ -308,4 +331,28 @@ describe('Loader.get / load — Assets catalog adoption (end-to-end)', () => { expect(b.ship).not.toBe(a.ship); // still distinct objects expect(global.fetch).toHaveBeenCalledTimes(1); // one network fetch for the shared source }); + + // §7 accepted gap: a single catalog with two fields pointing at the same + // source produces two DIFFERENT leaves for the same key. The first leaf + // registers and starts the fetch; the second leaf's key is already taken by + // an in-flight (not-yet-stored) handle, so it can't be filled by either the + // fresh-registration path or the already-stored fast path — it hangs at + // 'loading' forever. §7's per-key multi-handle tracking closes this gap; + // until then, `_adopt` dev-warns instead of hanging silently. + test('duplicate source within one catalog: adopting the second leaf while the first is in flight dev-warns exactly once', () => { + mockFetchImage(); + const loader = createCoreLoader(); + const warnSpy = vi.spyOn(logger, 'warn'); + const catalog = new Assets({ + a: { type: 'texture', source: 'x.png' }, + b: { type: 'texture', source: 'x.png' }, + }); + + loader.get(catalog); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain('duplicate source "x.png"'); + + warnSpy.mockRestore(); + }); }); From d8ac823c2b5e347e0189fb6beb70490aa5cae9bf Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 07:52:53 +0200 Subject: [PATCH 20/85] feat(resources): per-key multi-handle fill + per-handle texture samplers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the asset-access design §7 duplicate-source hang: when two distinct handles (or value refs) for one source are adopted while the fetch is still in flight, both now heal from the single source-keyed decode instead of the second silently hanging at 'loading'. _deferred/_refs now track a Set of in-flight handles/refs per key (the first-inserted member is the canonical representative that moves into _resources on store, preserving the old single-handle eviction contract). _adopt joins a distinct handle into the key's set; _storeResource, _onTrackedFailure and the unload-in-flight path fill/fail every member. Decouple the shared decode from per-handle sampler state: the texture adapter applies each handle's own samplerOptions at createPlaceholder and fill() now transplants ONLY the decoded source, so two handles for one source keep independent samplers off one decode. Re-scope the options-conflict warn to fire only on a genuine fetch-relevant difference (e.g. mimeType) for the same source; differing samplerOptions / pre-size are per-handle now and no longer warn. Removes the former duplicate-source dev-warn (replaced by a real fill). The later-evict co-handle orphan remains an accepted §7 remainder (documented inline); no weak-retention this slice. --- src/resources/Loader.ts | 270 +++++++++++++++++------- src/resources/seamless.ts | 32 ++- test/resources/catalog-adopt.test.ts | 118 +++++++++-- test/resources/loader-seamless.test.ts | 37 +++- test/resources/seamless-adapter.test.ts | 19 +- 5 files changed, 365 insertions(+), 111 deletions(-) diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index c132dd9fe..b896494e8 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -334,15 +334,22 @@ export class Loader { // ── Seamless deferred handles (asset-system v2) ─────────────────────────── // Adapter per seamless type; handles pending or failed, keyed by _key(type, source). - // Successful fills remove the entry (the handle moves to _resources); failed - // entries stay so a later get() retries and heals the SAME handle in place. + // Each entry tracks the SET of distinct handles in flight for the key (§7 + // multi-handle fill): two catalog leaves for one source share a single + // source-keyed decode yet are each filled in place. The first handle inserted + // is the representative — the object that becomes the canonical `_resources` + // entry on store, mirroring the old single-handle contract. Successful fills + // remove the entry (the representative moves to _resources); failed entries + // stay so a later get() retries and heals the SAME handles in place. private readonly _seamlessAdapters = new Map>(); - private readonly _deferred = new Map(); + private readonly _deferred = new Map; readonly options: unknown }>(); // Value-asset refs (asset-system v2 §4.6): the ref is the stable identity — // entries persist for the loader's lifetime (fill keeps them, fail keeps - // them for retry), unlike _deferred whose handles move into _resources. - private readonly _refs = new Map; readonly options: unknown }>(); + // them for retry), unlike _deferred whose handles move into _resources. Like + // _deferred, an entry tracks the SET of distinct refs adopted for one source + // so a single fetch fills every in-flight ref (§7 multi-handle fill). + private readonly _refs = new Map>; readonly options: unknown }>(); private readonly _valueTokens: ReadonlySet = new Set([Json, TextAsset, CsvAsset, XmlAsset, SubtitleAsset, BinaryAsset, WasmAsset]); // ── Refcount / claims (asset-system v2 §4.7) ────────────────────────────── @@ -1671,32 +1678,35 @@ export class Loader { if (handle instanceof AssetRef) { const existingRef = this._refs.get(key); + const stored = this._resources.get(ctor)?.get(meta.src); if (existingRef === undefined) { - this._refs.set(key, { ref: handle, options: meta.opts }); + this._refs.set(key, { refs: new Set([handle]), options: meta.opts }); this._handleKeys.set(handle, key); // Mirrors _getRef's stored-fast-path: a value already sitting in // `_resources` (stored elsewhere before this leaf was adopted) fills // this ref immediately instead of leaving it 'loading' forever. - const stored = this._resources.get(ctor)?.get(meta.src); - if (stored !== undefined) { handle._fill(stored); } else { this._startRefFetch(ctor, meta.src, meta.opts); } - } else if (existingRef.ref !== handle) { - // §7 accepted gap: a different AssetRef is already registered for this - // key and nothing is stored yet, so this ref cannot be filled from - // either path — it will hang at 'loading' until §7's per-key - // multi-handle tracking lands. Surface a dev-warning instead of a - // silent hang. See 07-asset-access-design.md §12. - logger.warn( - `Loader._adopt: duplicate source "${meta.src}" adopted while a different handle for it is still loading — this second handle will not fill until §7 per-key multi-handle tracking. Use a single catalog field for a shared source.`, - { source: 'Loader' }, - ); + } else if (!existingRef.refs.has(handle)) { + // A distinct ref for a key already in flight (or already stored): join + // the key's ref set so the single fetch fills it too (§7 multi-handle + // fill). If the value already converged, fill immediately; otherwise a + // conflicting FETCH option (source-keyed decode can't differ) warns. + existingRef.refs.add(handle); + this._handleKeys.set(handle, key); + + if (stored !== undefined) { + handle._fill(stored); + } else { + this._warnOnFetchOptionConflict(ctor, meta.src, key, existingRef.options, meta.opts); + } } + // else: the SAME ref re-adopted — Set membership makes this a no-op. this._claim(key, ctor, meta.src, claimer); @@ -1707,7 +1717,7 @@ export class Loader { const stored = this._resources.get(ctor)?.get(meta.src); if (deferredEntry === undefined && stored === undefined) { - this._deferred.set(key, { handle, options: meta.opts }); + this._deferred.set(key, { handles: new Set([handle]), options: meta.opts }); this._handleKeys.set(handle, key); this._claim(key, ctor, meta.src, claimer); this._startSeamlessFetch(ctor, meta.src, meta.opts); @@ -1715,34 +1725,33 @@ export class Loader { return; } - // Already stored for this key (e.g. loaded elsewhere before this leaf was - // adopted — the core catalog scenario) and this exact handle has not been - // filled/registered yet: transplant the stored donor into THIS handle in - // place (per-catalog identity — do NOT swap to the stored object; the - // caller already holds this leaf) and register it so `release(handle)` - // can resolve its key. A handle already filled by an earlier `_adopt` - // call (or by the normal fetch-completion path) is a no-op here. if (stored !== undefined && this._handleKeys.get(handle) !== key) { + // Already stored for this key (e.g. loaded elsewhere before this leaf was + // adopted — the core catalog scenario) and this exact handle has not been + // filled/registered yet: transplant the stored donor into THIS handle in + // place (per-catalog identity — do NOT swap to the stored object; the + // caller already holds this leaf) and register it so `release(handle)` + // can resolve its key. + // + // §7 remainder: this co-handle fills once from the stored payload but is + // NOT entered into the (already-cleared) deferred set, so a LATER + // evict+heal of this key will not touch it (it keeps the stale payload). + // Weak-retention over the full lifetime is the §7 follow-up; out of scope. const adapter = this._seamlessAdapters.get(ctor); - // §7 accepted gap: this leaf fills once from the stored payload but is not - // entered into per-key bookkeeping, so a later evict+heal of this key will - // not touch it (it keeps the stale payload). Reachable via a duplicate - // source in one catalog (second leaf hangs at 'loading'); no shipped - // example does this. §7's per-key multi-handle tracking closes it. adapter?.fill(handle, stored); this._handleKeys.set(handle, key); - } else if (deferredEntry !== undefined && stored === undefined && deferredEntry.handle !== handle) { - // §7 accepted gap: a different handle is already in flight for this key - // and nothing is stored yet, so this handle cannot be filled from either - // path — it will hang at 'loading' until §7's per-key multi-handle - // tracking lands. Surface a dev-warning instead of a silent hang. - // See 07-asset-access-design.md §12. - logger.warn( - `Loader._adopt: duplicate source "${meta.src}" adopted while a different handle for it is still loading — this second handle will not fill until §7 per-key multi-handle tracking. Use a single catalog field for a shared source.`, - { source: 'Loader' }, - ); + } else if (deferredEntry !== undefined && stored === undefined && !deferredEntry.handles.has(handle)) { + // A distinct handle is in flight for this key and nothing is stored yet: + // join the key's handle set so `_storeResource` fills THIS handle too + // (§7 multi-handle fill — this is the former silent hang). A conflicting + // FETCH option (source-keyed decode can't differ) warns; differing + // per-handle sampler options are fine (each handle carries its own). + deferredEntry.handles.add(handle); + this._handleKeys.set(handle, key); + this._warnOnFetchOptionConflict(ctor, meta.src, key, deferredEntry.options, meta.opts); } + // else: the SAME handle re-adopted, or already filled — a no-op. this._claim(key, ctor, meta.src, claimer); } @@ -1763,24 +1772,23 @@ export class Loader { const entry = this._deferred.get(key); if (entry !== undefined) { - if (options !== undefined && !this._areOptionsEquivalent(entry.options, options)) { - logger.warn(`get(${this._describeType(type)}, "${source}"): conflicting options ignored — the first call's options win.`, { - source: 'Loader', - once: `loader:seamless-options:${key}`, - }); - } + // get() reuses the representative handle (first-wins); only a conflicting + // FETCH option warns — per-handle sampler/pre-size differences are fine. + this._warnOnFetchOptionConflict(type, source, key, entry.options, options); + + const representative = this._representative(entry.handles); - if (adapter.stateOf(entry.handle) === 'failed') { - adapter.begin(entry.handle); + if (representative !== undefined && adapter.stateOf(representative) === 'failed') { + adapter.begin(representative); this._startSeamlessFetch(type, source, entry.options); } - return entry.handle; + return representative; } const handle = adapter.createPlaceholder(options); - this._deferred.set(key, { handle, options }); + this._deferred.set(key, { handles: new Set([handle as object]), options }); this._handleKeys.set(handle as object, key); this._startSeamlessFetch(type, source, options); @@ -1804,24 +1812,25 @@ export class Loader { const entry = this._refs.get(key); if (entry !== undefined) { - if (options !== undefined && !this._areOptionsEquivalent(entry.options, options)) { - logger.warn(`get(${this._describeType(type)}, "${source}"): conflicting options ignored — the first call's options win.`, { - source: 'Loader', - once: `loader:seamless-options:${key}`, - }); - } + // get() reuses the representative ref (first-wins); only a conflicting + // FETCH option warns. + this._warnOnFetchOptionConflict(type, source, key, entry.options, options); - if (entry.ref.loadState === 'failed') { - entry.ref._begin(); - this._startRefFetch(type, source, entry.options); - } + const representative = this._representative(entry.refs); + + if (representative !== undefined) { + if (representative.loadState === 'failed') { + representative._begin(); + this._startRefFetch(type, source, entry.options); + } - return entry.ref; + return representative; + } } const ref = new AssetRef(); - this._refs.set(key, { ref, options }); + this._refs.set(key, { refs: new Set([ref]), options }); this._handleKeys.set(ref, key); const stored = this._resources.get(type)?.get(source); @@ -1927,15 +1936,17 @@ export class Loader { // unclaimed — handle; §4.7 minimal). Only a payload that already converged // into `_resources` is dropped in place here. const deferred = this._deferred.get(key); - const handle = deferred?.handle ?? this._resources.get(type)?.get(source); + const handle = this._representative(deferred?.handles) ?? this._resources.get(type)?.get(source); if (adapter !== undefined && deferred === undefined && handle !== undefined) { adapter.evict(handle); this._resources.get(type)?.delete(source); // The original options were consumed when the payload converged into // `_resources` (the pre-fill `_deferred` entry is gone); the re-fetch - // re-derives them from the manifest entry, if any. - this._deferred.set(key, { handle, options: undefined }); + // re-derives them from the manifest entry, if any. Only the canonical + // representative is re-armed here — co-handles filled alongside it keep + // their payload (the §7 remainder gap). + this._deferred.set(key, { handles: new Set([handle as object]), options: undefined }); this._handleKeys.set(handle as object, key); this._evicted.add(key); // A load that just settled may leave a resolved-but-not-yet-cleaned @@ -2509,7 +2520,13 @@ export class Loader { const deferredEntry = this._deferred.get(key); if (deferredEntry !== undefined) { - this._seamlessAdapters.get(type)?.fail(deferredEntry.handle, err); + const adapter = this._seamlessAdapters.get(type); + + // Fail EVERY in-flight handle for the key so all co-adopters settle. + for (const handle of deferredEntry.handles) { + adapter?.fail(handle, err); + } + this.onError.dispatch(type, alias, err); return; @@ -2518,7 +2535,10 @@ export class Loader { const refEntry = this._refs.get(key); if (refEntry !== undefined) { - refEntry.ref._fail(err); + for (const ref of refEntry.refs) { + ref._fail(err); + } + this.onError.dispatch(type, alias, err); } } @@ -2555,6 +2575,55 @@ export class Loader { return entry.path === path && this._areOptionsEquivalent(entry.options, options); } + /** The representative (first-inserted) member of a handle/ref set, or `undefined` if empty. @internal */ + private _representative(members: ReadonlySet | undefined): T | undefined { + return members === undefined ? undefined : members.values().next().value; + } + + /** + * Warn once per key when a second handle/ref for the same source carries an + * incompatible FETCH option (e.g. a different `mimeType`): the decode is + * source-keyed, so only the first call's fetch options take effect and the + * later one is silently dropped. Per-handle sampler / pre-size options never + * conflict — each handle carries its own — so they are stripped before the + * comparison and never warn. A `undefined` second option is a plain reuse. + * @internal + */ + private _warnOnFetchOptionConflict(type: AssetConstructor, source: string, key: string, existingOptions: unknown, newOptions: unknown): void { + if (newOptions === undefined || this._fetchOptionsEquivalent(existingOptions, newOptions)) { + return; + } + + logger.warn(`get(${this._describeType(type)}, "${source}"): conflicting options ignored — the first call's options win.`, { + source: 'Loader', + once: `loader:seamless-options:${key}`, + }); + } + + /** Structural equality of the FETCH-relevant option subset (per-handle sampler / pre-size keys stripped). @internal */ + private _fetchOptionsEquivalent(left: unknown, right: unknown): boolean { + return this._areOptionsEquivalent(this._stripPerHandleOptions(left), this._stripPerHandleOptions(right)); + } + + /** Drop the per-handle option keys (`samplerOptions`, `width`, `height`) that never gate the shared decode. @internal */ + private _stripPerHandleOptions(options: unknown): unknown { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + return options; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(options as Record)) { + if (key === 'samplerOptions' || key === 'width' || key === 'height') { + continue; + } + + result[key] = value; + } + + return result; + } + private _areOptionsEquivalent(left: unknown, right: unknown): boolean { if (Object.is(left, right)) { return true; @@ -2652,13 +2721,22 @@ export class Loader { const preventedEntry = this._deferred.get(key); if (preventedEntry !== undefined) { - this._seamlessAdapters.get(type)?.fail(preventedEntry.handle, new Error(`Asset "${alias}" was unloaded while its fetch was in flight.`)); + const adapter = this._seamlessAdapters.get(type); + const unloadError = new Error(`Asset "${alias}" was unloaded while its fetch was in flight.`); + + for (const handle of preventedEntry.handles) { + adapter?.fail(handle, unloadError); + } } const preventedRef = this._refs.get(key); - if (preventedRef?.ref.loadState === 'loading') { - preventedRef.ref._fail(new Error(`Asset "${alias}" was unloaded while its fetch was in flight.`)); + if (preventedRef !== undefined) { + for (const ref of preventedRef.refs) { + if (ref.loadState === 'loading') { + ref._fail(new Error(`Asset "${alias}" was unloaded while its fetch was in flight.`)); + } + } } return resource; @@ -2671,30 +2749,60 @@ export class Loader { const deferredEntry = this._deferred.get(key); let filledDeferredHandle = false; - if (deferredEntry !== undefined && deferredEntry.handle !== resource) { + if (deferredEntry !== undefined) { const adapter = this._seamlessAdapters.get(type); + let representative: object | undefined; + + // Fill EVERY in-flight handle for the key from the single decoded donor + // (§7 multi-handle fill). The first handle is the representative — it + // becomes the canonical `_resources` entry, mirroring the old + // single-handle contract (which object is canonical for eviction). + for (const handle of deferredEntry.handles) { + representative ??= handle; + + if (handle === resource || adapter === undefined) { + continue; + } - if (adapter !== undefined) { // A non-get producer (load(), bundle, background) may store into a key // whose handle is 'failed' (e.g. an earlier get() 404'd). fill() → settle() // must run from a re-armed state so `.loaded` re-materializes a resolved // promise; without begin() the handle would read 'ready' while its cached - // `.loaded` stayed permanently rejected. - if (adapter.stateOf(deferredEntry.handle) === 'failed') { - adapter.begin(deferredEntry.handle); + // `.loaded` stayed permanently rejected. Skip a handle already 'ready' + // (filled by an earlier producer) — filling twice is a no-op at best. + const state = adapter.stateOf(handle); + + if (state === 'ready') { + continue; + } + + if (state === 'failed') { + adapter.begin(handle); } - adapter.fill(deferredEntry.handle, resource); + adapter.fill(handle, resource); } this._deferred.delete(key); - resource = deferredEntry.handle; - filledDeferredHandle = true; + + if (representative !== undefined && representative !== resource) { + resource = representative; + filledDeferredHandle = true; + } } // Value-asset refs fill from whatever producer stores the value; the raw - // value stays the stored resource (load() keeps resolving it). - this._refs.get(key)?.ref._fill(resource); + // value stays the stored resource (load() keeps resolving it). Fill every + // in-flight ref for the key (§7 multi-handle fill). + const refEntry = this._refs.get(key); + + if (refEntry !== undefined) { + for (const ref of refEntry.refs) { + if (ref.loadState !== 'ready') { + ref._fill(resource); + } + } + } let typeResources = this._resources.get(type); if (!typeResources) { diff --git a/src/resources/seamless.ts b/src/resources/seamless.ts index 432e03a85..88f212979 100644 --- a/src/resources/seamless.ts +++ b/src/resources/seamless.ts @@ -1,6 +1,7 @@ import { Sound } from '#audio/Sound'; import type { LoadStateValue } from '#core/LoadState'; import { logger } from '#core/logging'; +import type { SamplerOptions } from '#rendering/texture/Sampler'; import { Texture } from '#rendering/texture/Texture'; import { registerAssetKind } from './assetKindRegistry'; @@ -13,6 +14,18 @@ export interface PreSizeOptions { height?: number; } +/** + * Options honoured by {@link textureSeamlessAdapter.createPlaceholder}: pre-size + * reservation plus the handle's OWN sampler state. Sampler options are per-handle + * — applied to the placeholder here and NOT overwritten by {@link fill} — so two + * handles for one source can carry independent samplers off a single shared decode. + * @internal + */ +export interface DeferredTextureOptions extends PreSizeOptions { + /** Per-handle sampler/upload state for the placeholder; independent of the shared decode. */ + samplerOptions?: Partial; +} + /** * Per-type strategy for seamless deferred asset handles (asset-system v2). * @@ -39,9 +52,11 @@ export interface SeamlessAdapter { } /** - * Seamless adapter for {@link Texture}: placeholder is an empty (0×0) texture; - * fill transplants the decoded source plus sampler state (the donor carries - * any factory options from the original call); fail shows the shared + * Seamless adapter for {@link Texture}: placeholder is an empty (0×0) texture + * carrying its OWN per-handle sampler state (applied at + * {@link textureSeamlessAdapter.createPlaceholder} from `samplerOptions`); fill + * transplants ONLY the decoded source, so two handles for one source share a + * single decode yet keep independent samplers; fail shows the shared * {@link Texture.missing} checker — visible in production too. * @internal */ @@ -51,8 +66,10 @@ const presizes = new WeakMap(); export const textureSeamlessAdapter: SeamlessAdapter = { createPlaceholder(options?: unknown): Texture { - const handle = new Texture(null); - const { width, height } = (options ?? {}) as PreSizeOptions; + const { width, height, samplerOptions } = (options ?? {}) as DeferredTextureOptions; + // Bake the handle's own sampler state in now; fill() transplants only the + // decoded source, so a shared decode never overwrites it. + const handle = new Texture(null, samplerOptions); if (typeof width === 'number' && typeof height === 'number') { handle.setSize(width, height); @@ -72,9 +89,8 @@ export const textureSeamlessAdapter: SeamlessAdapter = { const expected = presizes.get(handle); presizes.delete(handle); - handle.setScaleMode(donor.scaleMode).setWrapMode(donor.wrapMode).setPremultiplyAlpha(donor.premultiplyAlpha); - handle.generateMipMap = donor.generateMipMap; - handle.flipY = donor.flipY; + // Transplant ONLY the decoded source — the handle keeps the per-handle + // sampler state applied at createPlaceholder (do NOT copy the donor's). handle.setSource(donor.source); if (expected !== undefined && (handle.width !== expected.width || handle.height !== expected.height)) { diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts index cae15cc4e..f672fe747 100644 --- a/test/resources/catalog-adopt.test.ts +++ b/test/resources/catalog-adopt.test.ts @@ -3,6 +3,7 @@ import '#resources/seamless'; import { logger } from '#core/logging'; import { materializeAssetBindings } from '#extensions/materialize'; import { Texture } from '#rendering/texture/Texture'; +import { ScaleModes } from '#rendering/types'; import { createLeaf } from '#resources/assetKindRegistry'; import { type AssetRef } from '#resources/AssetRef'; import { Assets } from '#resources/Assets'; @@ -99,7 +100,7 @@ describe('Loader._adopt', () => { warnSpy.mockRestore(); }); - test('duplicate source, different handle, still in flight: dev-warns instead of hanging silently (§7 gap)', async () => { + test('duplicate source, two distinct handles adopted while in flight: BOTH heal from ONE fetch, no warn (§7 multi-handle fill)', async () => { mockFetchImage(); const loader = createCoreLoader(); const warnSpy = vi.spyOn(logger, 'warn'); @@ -107,11 +108,99 @@ describe('Loader._adopt', () => { const a = createLeaf('texture', 'x.png') as Texture; const b = createLeaf('texture', 'x.png') as Texture; + loader._adopt(a, Symbol('claimer-a')); + loader._adopt(b, Symbol('claimer-b')); // second distinct handle, first still in flight + + await Promise.all([a.loaded, b.loaded]); + + expect(a.loadState).toBe('ready'); + expect(b.loadState).toBe('ready'); + expect(a).not.toBe(b); // distinct objects, both filled in place + expect(a.width).toBe(4); + expect(b.width).toBe(4); + expect(global.fetch).toHaveBeenCalledTimes(1); // ONE decode shared across both handles + // Same (default) sampler on both → the former §7 hang-warn must NOT fire. + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + test('duplicate source, two handles with DIFFERENT samplerOptions: one fetch, independent per-handle samplers (Q2)', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + const a = createLeaf('texture', 'x.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }) as Texture; + const b = createLeaf('texture', 'x.png', { samplerOptions: { scaleMode: ScaleModes.Linear } }) as Texture; + + expect(a.scaleMode).toBe(ScaleModes.Nearest); // applied at createPlaceholder + expect(b.scaleMode).toBe(ScaleModes.Linear); + + loader._adopt(a, Symbol('claimer-a')); + loader._adopt(b, Symbol('claimer-b')); + + await Promise.all([a.loaded, b.loaded]); + + expect(a.loadState).toBe('ready'); + expect(b.loadState).toBe('ready'); + expect(a.width).toBe(4); // shared decode reached both + expect(b.width).toBe(4); + // fill transplanted ONLY the decoded source — each handle kept its OWN sampler. + expect(a.scaleMode).toBe(ScaleModes.Nearest); + expect(b.scaleMode).toBe(ScaleModes.Linear); + expect(a.scaleMode).not.toBe(b.scaleMode); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('duplicate source in flight: a failing fetch fails BOTH co-handles', async () => { + global.fetch = vi.fn(async () => ({ ok: false, status: 404, statusText: 'Not Found' }) as unknown as Response); + const loader = createCoreLoader(); + + const a = createLeaf('texture', 'x.png') as Texture; + const b = createLeaf('texture', 'x.png') as Texture; + loader._adopt(a, Symbol('claimer-a')); loader._adopt(b, Symbol('claimer-b')); + await expect(a.loaded).rejects.toThrow(); + await expect(b.loaded).rejects.toThrow(); + expect(a.loadState).toBe('failed'); + expect(b.loadState).toBe('failed'); + }); + + test('duplicate source, two value refs (json) adopted while in flight: BOTH fill from ONE fetch', async () => { + mockFetchJson({ hp: 5 }); + const loader = createCoreLoader(); + + const a = createLeaf('json', 'cfg.json') as AssetRef; + const b = createLeaf('json', 'cfg.json') as AssetRef; + + loader._adopt(a, Symbol('claimer-a')); + loader._adopt(b, Symbol('claimer-b')); + + await Promise.all([a.loaded, b.loaded]); + + expect(a.value).toEqual({ hp: 5 }); + expect(b.value).toEqual({ hp: 5 }); + expect(a).not.toBe(b); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('duplicate source in flight with a genuinely conflicting FETCH option (mimeType) warns once; sampler differences do not', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const warnSpy = vi.spyOn(logger, 'warn'); + + const a = createLeaf('texture', 'x.png', { mimeType: 'image/png' }) as Texture; + const b = createLeaf('texture', 'x.png', { mimeType: 'image/webp' }) as Texture; + + loader._adopt(a, Symbol('claimer-a')); + loader._adopt(b, Symbol('claimer-b')); + + await Promise.all([a.loaded, b.loaded]); + + // Different mimeType for one source cannot share a decode → the first call wins, second warns. expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toContain('duplicate source "x.png"'); + expect(warnSpy.mock.calls[0]?.[0]).toContain('first call'); warnSpy.mockRestore(); }); @@ -332,14 +421,12 @@ describe('Loader.get / load — Assets catalog adoption (end-to-end)', () => { expect(global.fetch).toHaveBeenCalledTimes(1); // one network fetch for the shared source }); - // §7 accepted gap: a single catalog with two fields pointing at the same - // source produces two DIFFERENT leaves for the same key. The first leaf - // registers and starts the fetch; the second leaf's key is already taken by - // an in-flight (not-yet-stored) handle, so it can't be filled by either the - // fresh-registration path or the already-stored fast path — it hangs at - // 'loading' forever. §7's per-key multi-handle tracking closes this gap; - // until then, `_adopt` dev-warns instead of hanging silently. - test('duplicate source within one catalog: adopting the second leaf while the first is in flight dev-warns exactly once', () => { + // §7 fix: a single catalog with two fields pointing at the same source + // produces two DIFFERENT leaves for the same key. The first leaf registers + // and starts the fetch; the second distinct leaf is added to the key's + // in-flight handle set, so ONE decode heals BOTH leaves in place — no hang, + // no warn (same sampler). + test('duplicate source within one catalog: both leaves heal from ONE fetch (no hang, no warn)', async () => { mockFetchImage(); const loader = createCoreLoader(); const warnSpy = vi.spyOn(logger, 'warn'); @@ -350,8 +437,15 @@ describe('Loader.get / load — Assets catalog adoption (end-to-end)', () => { loader.get(catalog); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toContain('duplicate source "x.png"'); + await Promise.all([catalog.a.loaded, catalog.b.loaded]); + + expect(catalog.a.loadState).toBe('ready'); + expect(catalog.b.loadState).toBe('ready'); + expect(catalog.a).not.toBe(catalog.b); + expect(catalog.a.width).toBe(4); + expect(catalog.b.width).toBe(4); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); diff --git a/test/resources/loader-seamless.test.ts b/test/resources/loader-seamless.test.ts index af61ab8b2..b212e25eb 100644 --- a/test/resources/loader-seamless.test.ts +++ b/test/resources/loader-seamless.test.ts @@ -3,6 +3,7 @@ import { expectTypeOf } from 'vitest'; import { logger, LogSeverity } from '#core/logging'; import { materializeAssetBindings } from '#extensions/materialize'; import { Texture } from '#rendering/texture/Texture'; +import { ScaleModes } from '#rendering/types'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; import { textureSeamlessAdapter } from '#resources/seamless'; @@ -170,7 +171,7 @@ describe('Loader seamless get (Texture)', () => { expect(gradient.loadState).toBe('ready'); }); - test('conflicting options warn once and the first call wins', async () => { + test('conflicting FETCH options (mimeType) warn once and the first call wins', async () => { mockFetchImage(); const loader = createCoreLoader(); const warnings: string[] = []; @@ -179,16 +180,42 @@ describe('Loader seamless get (Texture)', () => { }); try { - const handle = loader.get(Texture, 'ship.png', { samplerOptions: { flipY: true } }); + const handle = loader.get(Texture, 'ship.png', { mimeType: 'image/png' }); - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: false } }); - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: false } }); + // A different mimeType for one source cannot share the source-keyed decode. + loader.get(Texture, 'ship.png', { mimeType: 'image/webp' }); + loader.get(Texture, 'ship.png', { mimeType: 'image/webp' }); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('first call'); await handle.loaded; - expect(handle.flipY).toBe(true); // first options reached the factory; fill transplanted them + expect(handle.loadState).toBe('ready'); + } finally { + removeSink(); + } + }); + + test('differing per-handle samplerOptions across get() do NOT warn; the first sampler wins on the shared handle', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const warnings: string[] = []; + const removeSink = logger.addSink(entry => { + if (entry.severity === LogSeverity.Warning) warnings.push(entry.message); + }); + + try { + // get() returns the SAME handle per source; sampler options are per-handle + // now, so a later differing sampler is silently first-wins (no warn). Use a + // distinct handle (e.g. an Assets catalog leaf) for an independent sampler. + const handle = loader.get(Texture, 'ship.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }); + + expect(handle).toBe(loader.get(Texture, 'ship.png', { samplerOptions: { scaleMode: ScaleModes.Linear } })); + expect(warnings).toHaveLength(0); + expect(handle.scaleMode).toBe(ScaleModes.Nearest); // first call's sampler, baked at createPlaceholder + + await handle.loaded; + expect(handle.scaleMode).toBe(ScaleModes.Nearest); // fill transplanted source only — sampler kept } finally { removeSink(); } diff --git a/test/resources/seamless-adapter.test.ts b/test/resources/seamless-adapter.test.ts index e4cb3454a..9835c6d7f 100644 --- a/test/resources/seamless-adapter.test.ts +++ b/test/resources/seamless-adapter.test.ts @@ -18,8 +18,17 @@ describe('textureSeamlessAdapter', () => { expect(handle.height).toBe(0); }); - test('fill transplants source and sampler state in place and settles ready', async () => { - const handle = textureSeamlessAdapter.createPlaceholder(); + test('createPlaceholder applies the handle-OWN sampler options from samplerOptions', () => { + const handle = textureSeamlessAdapter.createPlaceholder({ samplerOptions: { flipY: true, generateMipMap: false } }); + + expect(handle.flipY).toBe(true); + expect(handle.generateMipMap).toBe(false); + }); + + test('fill transplants ONLY the decoded source and settles ready — the handle keeps its own sampler', async () => { + // Per-handle sampler: the placeholder carries flipY:false; fill must NOT copy + // the donor's flipY:true (shared decode, independent samplers). + const handle = textureSeamlessAdapter.createPlaceholder({ samplerOptions: { flipY: false, generateMipMap: true } }); const versionBefore = handle.version; const canvas = document.createElement('canvas'); @@ -31,10 +40,10 @@ describe('textureSeamlessAdapter', () => { textureSeamlessAdapter.fill(handle, donor); expect(handle.loadState).toBe('ready'); - expect(handle.source).toBe(donor.source); + expect(handle.source).toBe(donor.source); // decoded source transplanted expect(handle.width).toBe(16); - expect(handle.flipY).toBe(true); - expect(handle.generateMipMap).toBe(false); + expect(handle.flipY).toBe(false); // kept its OWN sampler, not the donor's + expect(handle.generateMipMap).toBe(true); expect(handle.version).toBeGreaterThan(versionBefore); await expect(handle.loaded).resolves.toBe(handle); }); From 3ffa55a9f3834f1d6a691c7c35e592ef5facbfbe Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:03:23 +0200 Subject: [PATCH 21/85] fix(resources): seamless get() bakes manifest samplerOptions into the placeholder _getSeamless created a fresh placeholder from the raw get() options only, never folding in the manifest/backgroundLoad-registered options for that source the way _loadSingle resolves its fetch options (options ?? entry?.options). A texture backgroundLoad()'d with samplerOptions and then fetched via a bare get() silently rendered with the default sampler instead of the registered one. Resolve the placeholder options identically to _loadSingle for a brand-new deferred entry only; existing deferred entries keep reusing their stored options unchanged. Also harden the empty-handle-set path: if a live deferred entry's handle set were ever empty, _getSeamless used to return undefined; now it falls through and creates a fresh placeholder instead. --- src/resources/Loader.ts | 21 +++++++++++++++------ test/resources/loader-seamless.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index b896494e8..300513e92 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -1778,15 +1778,24 @@ export class Loader { const representative = this._representative(entry.handles); - if (representative !== undefined && adapter.stateOf(representative) === 'failed') { - adapter.begin(representative); - this._startSeamlessFetch(type, source, entry.options); - } + if (representative !== undefined) { + if (adapter.stateOf(representative) === 'failed') { + adapter.begin(representative); + this._startSeamlessFetch(type, source, entry.options); + } - return representative; + return representative; + } + // else: the handle set is (unexpectedly) empty — fall through and treat + // this as no live handle, creating a fresh placeholder below. } - const handle = adapter.createPlaceholder(options); + // Bake the SAME options `_loadSingle` would fetch with (raw `get()` options, + // else the manifest/backgroundLoad-registered options for this source) into + // the placeholder, so a bare `get()` after a `backgroundLoad(..., options)` + // renders with the registered sampler options instead of the default. + const resolvedOptions = options ?? this._getManifestEntry(type, source)?.options; + const handle = adapter.createPlaceholder(resolvedOptions); this._deferred.set(key, { handles: new Set([handle as object]), options }); this._handleKeys.set(handle as object, key); diff --git a/test/resources/loader-seamless.test.ts b/test/resources/loader-seamless.test.ts index b212e25eb..c628d8533 100644 --- a/test/resources/loader-seamless.test.ts +++ b/test/resources/loader-seamless.test.ts @@ -221,6 +221,31 @@ describe('Loader seamless get (Texture)', () => { } }); + test('backgroundLoad-registered samplerOptions are baked into a later bare get() placeholder', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + loader.backgroundLoad(Texture, 'ship.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }); + + const handle = loader.get(Texture, 'ship.png'); + + // Same options the fetch path would resolve via `options ?? entry?.options` + // (§ manifest fold) must be baked at createPlaceholder, not just the fetch. + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + + await handle.loaded; + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + }); + + test('inline get() options still win over any registered manifest entry (no manifest here)', () => { + mockFetchImage(); + const loader = createCoreLoader(); + + const handle = loader.get(Texture, 'ship.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }); + + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + }); + test('same options (deep-equal) do not warn', () => { mockFetchImage(); const loader = createCoreLoader(); From 1f0a07024d4b294144372459728cf17719f56cf6 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:35:18 +0200 Subject: [PATCH 22/85] =?UTF-8?q?feat(resources):=20shared=20AssetStatus?= =?UTF-8?q?=20projection=20of=20LoadState=20(=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a uniform AssetStatus interface (state/ready/error) and the _statusFields() helper that projects it from a handle's internal LoadState, for later asset-handle/ref status-channel wiring. - _statusFields is generic over LoadState's Owner type parameter: LoadState is invariant in Owner (appears contravariantly in the settle/fail resolvers), so a fixed LoadState parameter rejected every concrete LoadState call site under strict TS. - Re-export AssetStatus from the resources barrel (src/resources/index.ts), matching how other public types are exposed there. - Add src/**/*.test.ts to the "exojs" vitest project's include globs so colocated tests (as specified for this task) are discovered alongside the existing test/**/*.test.ts suite. --- src/resources/AssetStatus.test.ts | 26 ++++++++++++++++++++++++++ src/resources/AssetStatus.ts | 28 ++++++++++++++++++++++++++++ src/resources/index.ts | 1 + vitest.config.ts | 5 ++++- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/resources/AssetStatus.test.ts create mode 100644 src/resources/AssetStatus.ts diff --git a/src/resources/AssetStatus.test.ts b/src/resources/AssetStatus.test.ts new file mode 100644 index 000000000..ff0c98ebe --- /dev/null +++ b/src/resources/AssetStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { LoadState } from '#core/LoadState'; + +import { _statusFields } from './AssetStatus'; + +describe('_statusFields', () => { + it('projects a fresh LoadState as ready with no error', () => { + const ls = new LoadState(); + expect(_statusFields(ls)).toEqual({ state: 'ready', ready: true, error: null }); + }); + + it('projects a loading LoadState as not ready', () => { + const ls = new LoadState(); + ls.begin(); + expect(_statusFields(ls)).toEqual({ state: 'loading', ready: false, error: null }); + }); + + it('projects a failed LoadState with its error', () => { + const ls = new LoadState(); + ls.begin(); + const err = new Error('boom'); + ls.fail(err); + expect(_statusFields(ls)).toEqual({ state: 'failed', ready: false, error: err }); + }); +}); diff --git a/src/resources/AssetStatus.ts b/src/resources/AssetStatus.ts new file mode 100644 index 000000000..b8ea19f13 --- /dev/null +++ b/src/resources/AssetStatus.ts @@ -0,0 +1,28 @@ +import type { LoadState, LoadStateValue } from '#core/LoadState'; + +/** + * Uniform, read-only load-status projection shared by every asset handle and + * ref (asset-system v2 §6). A public view of the handle's internal + * {@link LoadState} — no new status object is created. + */ +export interface AssetStatus { + /** Current load lifecycle: `'loading' | 'ready' | 'failed'`. */ + readonly state: LoadStateValue; + /** `true` exactly when {@link state} is `'ready'`. */ + readonly ready: boolean; + /** The error the last load failed with, or `null` outside `'failed'`. */ + readonly error: Error | null; +} + +/** + * Compute the {@link AssetStatus} fields from a {@link LoadState}. Generic + * over the state's owner type — `LoadState` is invariant in `Owner` + * (it appears contravariantly in the internal settle/fail resolvers), so a + * fixed `LoadState` parameter would reject every concrete + * `LoadState` call site. + * @internal + */ +export function _statusFields(ls: LoadState): AssetStatus { + const state = ls.value; + return { state, ready: state === 'ready', error: ls.error }; +} diff --git a/src/resources/index.ts b/src/resources/index.ts index 7ee52cd2a..02c84b65a 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -7,6 +7,7 @@ export { BundleLoadError, defineAssetManifest } from './AssetManifest'; export { AssetRef } from './AssetRef'; export type { InferAssetsEntries } from './Assets'; export { Assets } from './Assets'; +export type { AssetStatus } from './AssetStatus'; export { CacheFirstStrategy } from './CacheFirstStrategy'; export type { CacheStore } from './CacheStore'; export type { CacheRequest, CacheStrategy } from './CacheStrategy'; diff --git a/vitest.config.ts b/vitest.config.ts index b67c19761..f9c73eba4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -128,7 +128,10 @@ export default defineConfig({ createJsdomTestProject({ name: 'exojs', alias: aliasConfig, - include: ['test/**/*.test.ts'], + // `src/**/*.test.ts` picks up colocated tests for asset-system v2 + // internals (e.g. `src/resources/AssetStatus.test.ts`) alongside the + // existing `test/**` suite. + include: ['test/**/*.test.ts', 'src/**/*.test.ts'], exclude: ['test/rendering/browser/**/*.test.ts', 'test/perf/rendering/**/*.test.ts'], }), createJsdomTestProject({ From e5fb7c1f1e3646b2def7ac92d719e6446242980f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:37:17 +0200 Subject: [PATCH 23/85] test(core): update root-index type inventory snapshot for AssetStatus The AssetStatus interface is now re-exported from src/resources/index.ts (and therefore from the root src/index.ts barrel), so the committed export-inventory snapshot needs the new "AssetStatus: interface" entry. --- test/core/__snapshots__/root-index-type-inventory.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap index cc463adff..72270e8f9 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -22,6 +22,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "AssetLoaderContext: interface", "AssetManifest: interface", "AssetRef: class", + "AssetStatus: interface", "Assets: type alias", "AtlasMode: type alias", "AttributeType: type alias", From f19580a7bf8cefe94918417b4662c88a255b2fa8 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:43:30 +0200 Subject: [PATCH 24/85] refactor(resources): AssetStatus interface-only; keep tests in test/ tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the unused _statusFields helper (the per-handle state/ready/error getters read LoadState inline) and revert the vitest colocated-test include — asset-system tests live under test/resources/ per repo convention. --- src/resources/AssetStatus.test.ts | 26 -------------------------- src/resources/AssetStatus.ts | 23 ++++++----------------- vitest.config.ts | 5 +---- 3 files changed, 7 insertions(+), 47 deletions(-) delete mode 100644 src/resources/AssetStatus.test.ts diff --git a/src/resources/AssetStatus.test.ts b/src/resources/AssetStatus.test.ts deleted file mode 100644 index ff0c98ebe..000000000 --- a/src/resources/AssetStatus.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { LoadState } from '#core/LoadState'; - -import { _statusFields } from './AssetStatus'; - -describe('_statusFields', () => { - it('projects a fresh LoadState as ready with no error', () => { - const ls = new LoadState(); - expect(_statusFields(ls)).toEqual({ state: 'ready', ready: true, error: null }); - }); - - it('projects a loading LoadState as not ready', () => { - const ls = new LoadState(); - ls.begin(); - expect(_statusFields(ls)).toEqual({ state: 'loading', ready: false, error: null }); - }); - - it('projects a failed LoadState with its error', () => { - const ls = new LoadState(); - ls.begin(); - const err = new Error('boom'); - ls.fail(err); - expect(_statusFields(ls)).toEqual({ state: 'failed', ready: false, error: err }); - }); -}); diff --git a/src/resources/AssetStatus.ts b/src/resources/AssetStatus.ts index b8ea19f13..4c36c44a3 100644 --- a/src/resources/AssetStatus.ts +++ b/src/resources/AssetStatus.ts @@ -1,9 +1,11 @@ -import type { LoadState, LoadStateValue } from '#core/LoadState'; +import type { LoadStateValue } from '#core/LoadState'; /** - * Uniform, read-only load-status projection shared by every asset handle and - * ref (asset-system v2 §6). A public view of the handle's internal - * {@link LoadState} — no new status object is created. + * Uniform, read-only load-status contract surfaced by every asset handle and + * ref (asset-system v2 §6). A public projection of the handle's internal + * `LoadState`: `Texture`, `Sound`, and {@link AssetRef} each expose `state`, + * `ready`, and `error` directly (plus their own `loaded` promise), so they + * structurally satisfy this interface. No separate status object is created. */ export interface AssetStatus { /** Current load lifecycle: `'loading' | 'ready' | 'failed'`. */ @@ -13,16 +15,3 @@ export interface AssetStatus { /** The error the last load failed with, or `null` outside `'failed'`. */ readonly error: Error | null; } - -/** - * Compute the {@link AssetStatus} fields from a {@link LoadState}. Generic - * over the state's owner type — `LoadState` is invariant in `Owner` - * (it appears contravariantly in the internal settle/fail resolvers), so a - * fixed `LoadState` parameter would reject every concrete - * `LoadState` call site. - * @internal - */ -export function _statusFields(ls: LoadState): AssetStatus { - const state = ls.value; - return { state, ready: state === 'ready', error: ls.error }; -} diff --git a/vitest.config.ts b/vitest.config.ts index f9c73eba4..b67c19761 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -128,10 +128,7 @@ export default defineConfig({ createJsdomTestProject({ name: 'exojs', alias: aliasConfig, - // `src/**/*.test.ts` picks up colocated tests for asset-system v2 - // internals (e.g. `src/resources/AssetStatus.test.ts`) alongside the - // existing `test/**` suite. - include: ['test/**/*.test.ts', 'src/**/*.test.ts'], + include: ['test/**/*.test.ts'], exclude: ['test/rendering/browser/**/*.test.ts', 'test/perf/rendering/**/*.test.ts'], }), createJsdomTestProject({ From 6e9d0c6984d347ecb3890811331c395868008639 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:51:30 +0200 Subject: [PATCH 25/85] =?UTF-8?q?feat(resources,rendering,audio):=20state/?= =?UTF-8?q?ready/error=20status=20getters=20on=20AssetRef,=20Texture,=20So?= =?UTF-8?q?und=20(=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/content/api/asset-ref.json | 75 ++++++++++- site/src/content/api/asset-status.json | 121 ++++++++++++++++++ site/src/content/api/data-texture.json | 75 ++++++++++- site/src/content/api/loader.json | 75 ++++++++++- site/src/content/api/sound.json | 75 ++++++++++- site/src/content/api/texture.json | 75 ++++++++++- src/audio/Sound.ts | 15 +++ src/rendering/texture/Texture.ts | 15 +++ src/resources/AssetRef.ts | 15 +++ test/audio/sound.test.ts | 18 +++ .../texture/texture-load-state.test.ts | 15 +++ test/resources/asset-ref.test.ts | 24 ++++ test/resources/asset-status.test.ts | 14 ++ 13 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 site/src/content/api/asset-status.json create mode 100644 test/resources/asset-status.test.ts diff --git a/site/src/content/api/asset-ref.json b/site/src/content/api/asset-ref.json index 8ba67dbbb..e69ac8c08 100644 --- a/site/src/content/api/asset-ref.json +++ b/site/src/content/api/asset-ref.json @@ -6,11 +6,11 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 4, + "memberCount": 7, "counts": { "constructors": 1, "methods": 0, - "properties": 3, + "properties": 6, "events": 0 }, "sections": [ @@ -78,6 +78,35 @@ "id": "properties", "title": "Properties", "members": [ + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, { "name": "loaded", "signature": "loaded: Promise", @@ -132,6 +161,48 @@ "returnType": null, "description": "Load lifecycle of this ref: 'loading' | 'ready' | 'failed'." }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle: 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "value", "signature": "value: T", diff --git a/site/src/content/api/asset-status.json b/site/src/content/api/asset-status.json new file mode 100644 index 000000000..acc9ef9cb --- /dev/null +++ b/site/src/content/api/asset-status.json @@ -0,0 +1,121 @@ +{ + "title": "AssetStatus", + "description": "Uniform, read-only load-status contract surfaced by every asset handle and ref (asset-system v2 §6). A public projection of the handle's internal `LoadState`: `Texture`, `Sound`, and AssetRef each expose `state`, `ready`, and `error` directly (plus their own `loaded` promise), so they structurally satisfy this interface. No separate status object is created.", + "symbol": "AssetStatus", + "kind": "interface", + "subsystem": "resources", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 3, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 3, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Uniform, read-only load-status contract surfaced by every asset handle and ref (asset-system v2 §6). A public projection of the handle's internal `LoadState`: `Texture`, `Sound`, and AssetRef each expose `state`, `ready`, and `error` directly (plus their own `loaded` promise), so they structurally satisfy this interface. No separate status object is created." + ], + "importLine": "import { AssetStatus } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Current load lifecycle: 'loading' | 'ready' | 'failed'." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/resources/AssetStatus.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetStatus.ts" + } + } + ], + "sourcePath": "src/resources/AssetStatus.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetStatus.ts" +} diff --git a/site/src/content/api/data-texture.json b/site/src/content/api/data-texture.json index 32ab2de1c..8e9827131 100644 --- a/site/src/content/api/data-texture.json +++ b/site/src/content/api/data-texture.json @@ -6,11 +6,11 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 33, + "memberCount": 36, "counts": { "constructors": 1, "methods": 12, - "properties": 20, + "properties": 23, "events": 0 }, "sections": [ @@ -874,6 +874,35 @@ "returnType": null, "description": "" }, + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, { "name": "flipY", "signature": "flipY: boolean", @@ -1033,6 +1062,27 @@ "returnType": null, "description": "" }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, { "name": "scaleMode", "signature": "scaleMode: ScaleModes", @@ -1096,6 +1146,27 @@ "returnType": null, "description": "" }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle: 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "version", "signature": "version: number", diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index 455af45cb..327469206 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 65, + "memberCount": 66, "counts": { "constructors": 1, - "methods": 54, + "methods": 55, "properties": 2, "events": 8 }, @@ -2850,6 +2850,77 @@ "returnType": "LoadingQueue>", "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." }, + { + "name": "load", + "signature": "load(leaf: AssetRef): LoadingQueue", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "leaf", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetRef", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "leaf", + "type": "AssetRef", + "optional": false + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, { "name": "load", "signature": "load(leaf: T): LoadingQueue", diff --git a/site/src/content/api/sound.json b/site/src/content/api/sound.json index 02b7e3ff2..9a029075f 100644 --- a/site/src/content/api/sound.json +++ b/site/src/content/api/sound.json @@ -6,11 +6,11 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 27, + "memberCount": 30, "counts": { "constructors": 1, "methods": 9, - "properties": 17, + "properties": 20, "events": 0 }, "sections": [ @@ -762,6 +762,35 @@ "returnType": null, "description": "Playable duration in seconds — the full buffer, or the clip span for a Sound.clip." }, + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, { "name": "loaded", "signature": "loaded: Promise", @@ -929,6 +958,27 @@ "returnType": null, "description": "Sound priority. Used by the LowestPriority pool strategy. Higher values indicate higher priority (less likely to be evicted)." }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, { "name": "refDistance", "signature": "refDistance: number", @@ -971,6 +1021,27 @@ "returnType": null, "description": "Falloff steepness. Higher values attenuate faster with distance." }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle: 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "velocity", "signature": "velocity: Vector | null", diff --git a/site/src/content/api/texture.json b/site/src/content/api/texture.json index 707d85d06..c9179712e 100644 --- a/site/src/content/api/texture.json +++ b/site/src/content/api/texture.json @@ -6,11 +6,11 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 29, + "memberCount": 32, "counts": { "constructors": 1, "methods": 10, - "properties": 18, + "properties": 21, "events": 0 }, "sections": [ @@ -674,6 +674,35 @@ "returnType": null, "description": "" }, + { + "name": "error", + "signature": "error: Error | null", + "signatureTokens": [ + { + "text": "error", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Error", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "The error the last load failed with, or null outside 'failed'." + }, { "name": "flipY", "signature": "flipY: boolean", @@ -833,6 +862,27 @@ "returnType": null, "description": "" }, + { + "name": "ready", + "signature": "ready: boolean", + "signatureTokens": [ + { + "text": "ready", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true exactly when state is 'ready'." + }, { "name": "scaleMode", "signature": "scaleMode: ScaleModes", @@ -896,6 +946,27 @@ "returnType": null, "description": "" }, + { + "name": "state", + "signature": "state: LoadStateValue", + "signatureTokens": [ + { + "text": "state", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadStateValue", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Load lifecycle: 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "version", "signature": "version: number", diff --git a/src/audio/Sound.ts b/src/audio/Sound.ts index 27d7146a5..41c52283b 100644 --- a/src/audio/Sound.ts +++ b/src/audio/Sound.ts @@ -179,6 +179,21 @@ export class Sound implements Playable { return this._loadState.value; } + /** Load lifecycle: `'loading' | 'ready' | 'failed'` (asset-system v2 §6). */ + public get state(): LoadStateValue { + return this._loadState.value; + } + + /** `true` exactly when {@link state} is `'ready'`. */ + public get ready(): boolean { + return this._loadState.value === 'ready'; + } + + /** The error the last load failed with, or `null` outside `'failed'`. */ + public get error(): Error | null { + return this._loadState.error; + } + /** * Promise that settles with this sound once its payload has loaded — * resolved immediately for `'ready'` sounds, rejected with the load error diff --git a/src/rendering/texture/Texture.ts b/src/rendering/texture/Texture.ts index 13cbc19b2..d53f39ac3 100644 --- a/src/rendering/texture/Texture.ts +++ b/src/rendering/texture/Texture.ts @@ -214,6 +214,21 @@ export class Texture { return this._loadState.value; } + /** Load lifecycle: `'loading' | 'ready' | 'failed'` (asset-system v2 §6). */ + public get state(): LoadStateValue { + return this._loadState.value; + } + + /** `true` exactly when {@link state} is `'ready'`. */ + public get ready(): boolean { + return this._loadState.value === 'ready'; + } + + /** The error the last load failed with, or `null` outside `'failed'`. */ + public get error(): Error | null { + return this._loadState.error; + } + /** * Promise that settles with this texture once its payload has loaded — * resolved immediately for `'ready'` textures, rejected with the load error diff --git a/src/resources/AssetRef.ts b/src/resources/AssetRef.ts index 51ce93ea9..29afdb676 100644 --- a/src/resources/AssetRef.ts +++ b/src/resources/AssetRef.ts @@ -22,6 +22,21 @@ export class AssetRef { return this._loadState.value; } + /** Load lifecycle: `'loading' | 'ready' | 'failed'` (asset-system v2 §6). */ + public get state(): LoadStateValue { + return this._loadState.value; + } + + /** `true` exactly when {@link state} is `'ready'`. */ + public get ready(): boolean { + return this._loadState.value === 'ready'; + } + + /** The error the last load failed with, or `null` outside `'failed'`. */ + public get error(): Error | null { + return this._loadState.error; + } + /** * Promise settling with the parsed value. Re-materialized when a failed * load is retried — read it fresh from this getter across load cycles. diff --git a/test/audio/sound.test.ts b/test/audio/sound.test.ts index c0817c99f..3144f8c52 100644 --- a/test/audio/sound.test.ts +++ b/test/audio/sound.test.ts @@ -248,3 +248,21 @@ describe('Sound', () => { mutedSound.destroy(); }); }); + +describe('Sound status channel', () => { + test('a directly constructed sound is ready', () => { + const sound = new Sound(createAudioBufferStub()); + expect(sound.state).toBe('ready'); + expect(sound.ready).toBe(true); + expect(sound.error).toBeNull(); + sound.destroy(); + }); + + test('a placeholder in loading state is not ready', () => { + const sound = new Sound(createAudioBufferStub()); + sound._loadState.begin(); + expect(sound.state).toBe('loading'); + expect(sound.ready).toBe(false); + sound.destroy(); + }); +}); diff --git a/test/rendering/texture/texture-load-state.test.ts b/test/rendering/texture/texture-load-state.test.ts index b16e7255c..afc7dcfa0 100644 --- a/test/rendering/texture/texture-load-state.test.ts +++ b/test/rendering/texture/texture-load-state.test.ts @@ -26,3 +26,18 @@ describe('Texture load state', () => { await expect(pending).resolves.toBe(texture); }); }); + +describe('Texture status channel', () => { + it('a directly constructed texture is ready', () => { + const tex = new Texture(null); + expect(tex.state).toBe('ready'); + expect(tex.ready).toBe(true); + expect(tex.error).toBeNull(); + }); + it('a placeholder in loading state is not ready', () => { + const tex = new Texture(null); + tex._loadState.begin(); + expect(tex.state).toBe('loading'); + expect(tex.ready).toBe(false); + }); +}); diff --git a/test/resources/asset-ref.test.ts b/test/resources/asset-ref.test.ts index 587160319..547b64639 100644 --- a/test/resources/asset-ref.test.ts +++ b/test/resources/asset-ref.test.ts @@ -103,3 +103,27 @@ describe('AssetRef value assets', () => { expectTypeOf(loader.get(TextAsset, 'a.txt')).toEqualTypeOf>(); }); }); + +describe('AssetRef status channel', () => { + it('a constructed ref is loading with no error', () => { + const ref = new AssetRef(); + expect(ref.state).toBe('loading'); + expect(ref.ready).toBe(false); + expect(ref.error).toBeNull(); + }); + it('becomes ready after _fill', () => { + const ref = new AssetRef(); + ref._fill(7); + expect(ref.state).toBe('ready'); + expect(ref.ready).toBe(true); + expect(ref.error).toBeNull(); + }); + it('surfaces the failure error', () => { + const ref = new AssetRef(); + const err = new Error('nope'); + ref._fail(err); + expect(ref.state).toBe('failed'); + expect(ref.ready).toBe(false); + expect(ref.error).toBe(err); + }); +}); diff --git a/test/resources/asset-status.test.ts b/test/resources/asset-status.test.ts new file mode 100644 index 000000000..a968adbb0 --- /dev/null +++ b/test/resources/asset-status.test.ts @@ -0,0 +1,14 @@ +import { assertType, describe, it } from 'vitest'; + +import type { AssetStatus } from '#resources/AssetStatus'; +import { AssetRef } from '#resources/AssetRef'; +import { Texture } from '#rendering/texture/Texture'; +import { Sound } from '#audio/Sound'; + +describe('AssetStatus is satisfied by every handle/ref', () => { + it('AssetRef, Texture, Sound are assignable to AssetStatus', () => { + assertType(new AssetRef()); + assertType(new Texture(null)); + assertType(new Sound(null)); + }); +}); From 5e06c02dd7857393416962b441cd5e786cc0141a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 08:59:35 +0200 Subject: [PATCH 26/85] =?UTF-8?q?feat(resources):=20extension=E2=86=92kind?= =?UTF-8?q?=20registry=20with=20loud=20bare-suffix=20collision=20(=C2=A75.?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/resources/extensionKindRegistry.ts | 58 ++++++++++++++++++++ test/resources/extensionKindRegistry.test.ts | 35 ++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/resources/extensionKindRegistry.ts create mode 100644 test/resources/extensionKindRegistry.test.ts diff --git a/src/resources/extensionKindRegistry.ts b/src/resources/extensionKindRegistry.ts new file mode 100644 index 000000000..7a811688a --- /dev/null +++ b/src/resources/extensionKindRegistry.ts @@ -0,0 +1,58 @@ +import type { AssetDefinitions } from './AssetDefinitions'; + +const extToKind = new Map(); + +function normalizeExt(ext: string): string { + return ext.replace(/^\.+/, '').toLowerCase(); +} + +/** + * Map a file suffix to an asset kind for bare-string path inference in + * `Assets.from()` / `get()` / `load()` (asset-system v2 §5). + * + * Compound suffixes (`atlas.json`) are allowed and win over their bare tail + * (`json`) via longest-suffix-first resolution. Registering a **bare** suffix + * already claimed by a *different* kind is a **loud conflict** (§5.1): it throws + * naming both kinds and pointing to the compound-suffix / `X.of()` escape + * hatches, replacing the old silent clobber. Idempotent for the same + * `(ext, kind)` pair. + */ +export function registerExtensionKind(ext: string, kind: keyof AssetDefinitions): void { + const key = normalizeExt(ext); + const existing = extToKind.get(key); + if (existing !== undefined && existing !== kind) { + throw new Error( + `extensionKindRegistry: suffix ".${key}" is already registered as kind "${existing}", ` + + `cannot also register it as "${kind}". Use a compound suffix (e.g. "${kind}.${key}") ` + + `or annotate individual assets with X.of() instead of a bare path.`, + ); + } + extToKind.set(key, kind); +} + +/** The kind registered for a bare/compound suffix, or `undefined`. @internal */ +export function getExtensionKind(ext: string): (keyof AssetDefinitions) | undefined { + return extToKind.get(normalizeExt(ext)); +} + +/** + * Resolve a path string to its asset kind — basename-only, longest-suffix-first + * (mirrors the type-level `MatchExtension`). Query/hash are stripped. Returns + * `undefined` when no dot-suffix of the basename is registered. @internal + */ +export function resolveKindByPath(path: string): (keyof AssetDefinitions) | undefined { + const noQuery = path.split(/[?#]/)[0] ?? path; + const basename = noQuery.split('/').pop() ?? noQuery; + const parts = basename.toLowerCase().split('.'); + for (let i = 1; i < parts.length; i++) { + const suffix = parts.slice(i).join('.'); + const kind = extToKind.get(suffix); + if (kind !== undefined) return kind; + } + return undefined; +} + +/** Test-only reset of the module-level registry. @internal */ +export function _resetExtensionKindsForTest(): void { + extToKind.clear(); +} diff --git a/test/resources/extensionKindRegistry.test.ts b/test/resources/extensionKindRegistry.test.ts new file mode 100644 index 000000000..046c052a4 --- /dev/null +++ b/test/resources/extensionKindRegistry.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerExtensionKind, resolveKindByPath, _resetExtensionKindsForTest } from '#resources/extensionKindRegistry'; + +describe('extensionKindRegistry', () => { + beforeEach(() => _resetExtensionKindsForTest()); + + it('resolves a bare suffix to its kind, basename-only, case-insensitive', () => { + registerExtensionKind('png', 'texture'); + expect(resolveKindByPath('sprites/Ship.PNG')).toBe('texture'); + expect(resolveKindByPath('sprites/ship.png?v=2')).toBe('texture'); + }); + + it('prefers the longest registered compound suffix', () => { + registerExtensionKind('json', 'json'); + registerExtensionKind('atlas.json', 'texture'); // stand-in kind for the test + expect(resolveKindByPath('ui/hero.atlas.json')).toBe('texture'); + expect(resolveKindByPath('data/config.json')).toBe('json'); + }); + + it('returns undefined for an unregistered suffix', () => { + expect(resolveKindByPath('a/b.xyz')).toBeUndefined(); + }); + + it('is idempotent for the same (ext, kind)', () => { + registerExtensionKind('png', 'texture'); + expect(() => registerExtensionKind('png', 'texture')).not.toThrow(); + }); + + it('throws a loud conflict naming both kinds + the escape hatches on a clashing bare-suffix reregistration (§5.1)', () => { + registerExtensionKind('json', 'json'); + expect(() => registerExtensionKind('json', 'texture')).toThrow(/json/); + expect(() => registerExtensionKind('json', 'texture')).toThrow(/texture/); + expect(() => registerExtensionKind('json', 'texture')).toThrow(/compound suffix|\.of\(\)/); + }); +}); From cd296950001e3dc99afb8bad61b9f8c8fa1ed5f4 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:07:01 +0200 Subject: [PATCH 27/85] =?UTF-8?q?feat(resources):=20register=20core=20exte?= =?UTF-8?q?nsion=E2=86=92kind=20mappings=20for=20bare-path=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes import-sort lint on extensionKindRegistry.test.ts from the prior commit. --- src/resources/seamless.ts | 35 ++++++++++++++++++ .../extensionKindRegistry.core.test.ts | 37 +++++++++++++++++++ test/resources/extensionKindRegistry.test.ts | 5 ++- 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 test/resources/extensionKindRegistry.core.test.ts diff --git a/src/resources/seamless.ts b/src/resources/seamless.ts index 88f212979..b5753ebdf 100644 --- a/src/resources/seamless.ts +++ b/src/resources/seamless.ts @@ -4,7 +4,9 @@ import { logger } from '#core/logging'; import type { SamplerOptions } from '#rendering/texture/Sampler'; import { Texture } from '#rendering/texture/Texture'; +import type { AssetDefinitions } from './AssetDefinitions'; import { registerAssetKind } from './assetKindRegistry'; +import { registerExtensionKind } from './extensionKindRegistry'; /** Pre-sizing options for deferred texture handles (spec §4.1 — the layout-jump fix). */ export interface PreSizeOptions { @@ -166,3 +168,36 @@ registerAssetKind('sound', { adapter: soundSeamlessAdapter, isValue: false }); for (const valueKind of ['json', 'text', 'csv', 'xml', 'srt', 'vtt', 'binary', 'wasm'] as const) { registerAssetKind(valueKind, { isValue: true }); } + +// Core suffix → kind registrations for bare-path inference (asset-system v2 §5). +// Mirror of ExtensionTypeMap plus the value-kind suffixes (json/txt/csv/xml/…). +const coreExtensionKinds: ReadonlyArray<[string, keyof AssetDefinitions]> = [ + ['png', 'texture'], + ['jpg', 'texture'], + ['jpeg', 'texture'], + ['webp', 'texture'], + ['avif', 'texture'], + ['gif', 'texture'], + ['ogg', 'sound'], + ['mp3', 'sound'], + ['wav', 'sound'], + ['m4a', 'sound'], + ['aac', 'sound'], + ['fnt', 'bmFont'], + ['woff', 'font'], + ['woff2', 'font'], + ['ttf', 'font'], + ['otf', 'font'], + ['json', 'json'], + ['txt', 'text'], + ['csv', 'csv'], + ['xml', 'xml'], + ['vtt', 'vtt'], + ['srt', 'srt'], + ['bin', 'binary'], + ['wasm', 'wasm'], + ['svg', 'svg'], +]; +for (const [ext, kind] of coreExtensionKinds) { + registerExtensionKind(ext, kind); +} diff --git a/test/resources/extensionKindRegistry.core.test.ts b/test/resources/extensionKindRegistry.core.test.ts new file mode 100644 index 000000000..c8e2d16ad --- /dev/null +++ b/test/resources/extensionKindRegistry.core.test.ts @@ -0,0 +1,37 @@ +import '#resources/seamless'; // trigger core registrations at import + +import { describe, expect, it } from 'vitest'; + +import { resolveKindByPath } from '#resources/extensionKindRegistry'; + +describe('core extension→kind registrations', () => { + it.each([ + ['a/b.png', 'texture'], + ['a/b.jpg', 'texture'], + ['a/b.jpeg', 'texture'], + ['a/b.webp', 'texture'], + ['a/b.avif', 'texture'], + ['a/b.gif', 'texture'], + ['a/b.ogg', 'sound'], + ['a/b.mp3', 'sound'], + ['a/b.wav', 'sound'], + ['a/b.m4a', 'sound'], + ['a/b.aac', 'sound'], + ['a/b.fnt', 'bmFont'], + ['a/b.woff', 'font'], + ['a/b.woff2', 'font'], + ['a/b.ttf', 'font'], + ['a/b.otf', 'font'], + ['a/b.json', 'json'], + ['a/b.txt', 'text'], + ['a/b.csv', 'csv'], + ['a/b.xml', 'xml'], + ['a/b.vtt', 'vtt'], + ['a/b.srt', 'srt'], + ['a/b.bin', 'binary'], + ['a/b.wasm', 'wasm'], + ['a/b.svg', 'svg'], + ])('resolves %s → %s', (path, kind) => { + expect(resolveKindByPath(path)).toBe(kind); + }); +}); diff --git a/test/resources/extensionKindRegistry.test.ts b/test/resources/extensionKindRegistry.test.ts index 046c052a4..f46b73950 100644 --- a/test/resources/extensionKindRegistry.test.ts +++ b/test/resources/extensionKindRegistry.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { registerExtensionKind, resolveKindByPath, _resetExtensionKindsForTest } from '#resources/extensionKindRegistry'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { _resetExtensionKindsForTest, registerExtensionKind, resolveKindByPath } from '#resources/extensionKindRegistry'; describe('extensionKindRegistry', () => { beforeEach(() => _resetExtensionKindsForTest()); From 25ff921b46eb2fd762f215381aabe525c38f8bb3 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:12:11 +0200 Subject: [PATCH 28/85] =?UTF-8?q?fix(resources):=20restrict=20bare-path=20?= =?UTF-8?q?ext=E2=86=92kind=20to=20leaf-capable=20kinds=20+=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only texture/sound (seamless) and the 8 value kinds can build a placeholder leaf via createLeaf; drop font/bmFont/svg suffix inference (loaded via X.of or config, bare-path support is a follow-up). Also autofix import order in asset-status.test.ts (pre-existing lint from the A2A3 task). --- src/resources/seamless.ts | 13 ++++++------- test/resources/asset-status.test.ts | 6 +++--- test/resources/extensionKindRegistry.core.test.ts | 13 +++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/resources/seamless.ts b/src/resources/seamless.ts index b5753ebdf..3b468e8ba 100644 --- a/src/resources/seamless.ts +++ b/src/resources/seamless.ts @@ -170,7 +170,12 @@ for (const valueKind of ['json', 'text', 'csv', 'xml', 'srt', 'vtt', 'binary', ' } // Core suffix → kind registrations for bare-path inference (asset-system v2 §5). -// Mirror of ExtensionTypeMap plus the value-kind suffixes (json/txt/csv/xml/…). +// Restricted to LEAF-CAPABLE kinds — those registered above with either a +// seamless adapter (texture, sound) or `isValue: true` (json/text/csv/xml/vtt/ +// srt/binary/wasm). `createLeaf` can only build a placeholder for those, so a +// bare path is only inferable for them. Non-leaf resource kinds (font, bmFont, +// music, image, video, svg) are loaded via `X.of(...)` or an explicit config; +// bare-path support for them is a follow-up (needs a placeholder strategy). const coreExtensionKinds: ReadonlyArray<[string, keyof AssetDefinitions]> = [ ['png', 'texture'], ['jpg', 'texture'], @@ -183,11 +188,6 @@ const coreExtensionKinds: ReadonlyArray<[string, keyof AssetDefinitions]> = [ ['wav', 'sound'], ['m4a', 'sound'], ['aac', 'sound'], - ['fnt', 'bmFont'], - ['woff', 'font'], - ['woff2', 'font'], - ['ttf', 'font'], - ['otf', 'font'], ['json', 'json'], ['txt', 'text'], ['csv', 'csv'], @@ -196,7 +196,6 @@ const coreExtensionKinds: ReadonlyArray<[string, keyof AssetDefinitions]> = [ ['srt', 'srt'], ['bin', 'binary'], ['wasm', 'wasm'], - ['svg', 'svg'], ]; for (const [ext, kind] of coreExtensionKinds) { registerExtensionKind(ext, kind); diff --git a/test/resources/asset-status.test.ts b/test/resources/asset-status.test.ts index a968adbb0..125bfee14 100644 --- a/test/resources/asset-status.test.ts +++ b/test/resources/asset-status.test.ts @@ -1,9 +1,9 @@ import { assertType, describe, it } from 'vitest'; -import type { AssetStatus } from '#resources/AssetStatus'; -import { AssetRef } from '#resources/AssetRef'; -import { Texture } from '#rendering/texture/Texture'; import { Sound } from '#audio/Sound'; +import { Texture } from '#rendering/texture/Texture'; +import { AssetRef } from '#resources/AssetRef'; +import type { AssetStatus } from '#resources/AssetStatus'; describe('AssetStatus is satisfied by every handle/ref', () => { it('AssetRef, Texture, Sound are assignable to AssetStatus', () => { diff --git a/test/resources/extensionKindRegistry.core.test.ts b/test/resources/extensionKindRegistry.core.test.ts index c8e2d16ad..c36018573 100644 --- a/test/resources/extensionKindRegistry.core.test.ts +++ b/test/resources/extensionKindRegistry.core.test.ts @@ -17,11 +17,6 @@ describe('core extension→kind registrations', () => { ['a/b.wav', 'sound'], ['a/b.m4a', 'sound'], ['a/b.aac', 'sound'], - ['a/b.fnt', 'bmFont'], - ['a/b.woff', 'font'], - ['a/b.woff2', 'font'], - ['a/b.ttf', 'font'], - ['a/b.otf', 'font'], ['a/b.json', 'json'], ['a/b.txt', 'text'], ['a/b.csv', 'csv'], @@ -30,8 +25,14 @@ describe('core extension→kind registrations', () => { ['a/b.srt', 'srt'], ['a/b.bin', 'binary'], ['a/b.wasm', 'wasm'], - ['a/b.svg', 'svg'], ])('resolves %s → %s', (path, kind) => { expect(resolveKindByPath(path)).toBe(kind); }); + + it.each([['a/b.woff'], ['a/b.fnt'], ['a/b.svg'], ['a/b.mp4']])( + 'does NOT infer non-leaf-capable / unregistered suffix %s (use X.of or a config)', + path => { + expect(resolveKindByPath(path)).toBeUndefined(); + }, + ); }); From 7e1d7d40026bb416094f3d127ffab838ad7b7b53 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:17:43 +0200 Subject: [PATCH 29/85] feat(resources): type-level ExtensionKindMap + KindByPath/LeafForPath inference --- src/resources/AssetDefinitions.ts | 68 ++++++++++++++++++++ test/resources/assetDefinitions.type.test.ts | 23 +++++++ 2 files changed, 91 insertions(+) create mode 100644 test/resources/assetDefinitions.type.test.ts diff --git a/src/resources/AssetDefinitions.ts b/src/resources/AssetDefinitions.ts index 2bfb86005..0ebcbff94 100644 --- a/src/resources/AssetDefinitions.ts +++ b/src/resources/AssetDefinitions.ts @@ -8,6 +8,7 @@ import type { Texture } from '#rendering/texture/Texture'; import type { Video } from '#rendering/video/Video'; import type { Asset } from './Asset'; +import type { AssetRef } from './AssetRef'; export interface AssetDefinitions { bmFont: { resource: BmFont; config: { source: string } }; @@ -63,3 +64,70 @@ export type AssetInput = AnyAssetConfig | Asset; export type InferAssetResource = I extends Asset ? T : I extends { type: infer K extends keyof AssetDefinitions } ? AssetDefinitions[K]['resource'] : never; + +// --------------------------------------------------------------------------- +// Type-level bare-path inference (mirror of the runtime extensionKindRegistry) +// --------------------------------------------------------------------------- + +/** + * Type-level twin of the runtime `extensionKindRegistry`: file suffix → asset + * kind, for bare-string path inference in `Assets.from()`/`get()`/`load()` + * (asset-system v2 §5). Restricted to LEAF-CAPABLE kinds, exactly mirroring the + * runtime `registerExtensionKind` calls in `seamless.ts`. Extend by declaration + * merging, like {@link ExtensionTypeMap}: + * ```ts + * declare module '@codexo/exojs' { + * interface ExtensionKindMap { 'atlas.json': 'spriteAtlas'; } + * } + * ``` + */ +export interface ExtensionKindMap { + png: 'texture'; jpg: 'texture'; jpeg: 'texture'; webp: 'texture'; avif: 'texture'; gif: 'texture'; + ogg: 'sound'; mp3: 'sound'; wav: 'sound'; m4a: 'sound'; aac: 'sound'; + json: 'json'; txt: 'text'; csv: 'csv'; xml: 'xml'; + vtt: 'vtt'; srt: 'srt'; bin: 'binary'; wasm: 'wasm'; +} + +/** Last path segment (after the final `/`). */ +type KindBasename = S extends `${string}/${infer R}` ? KindBasename : S; +/** Strip a trailing `?query`/`#fragment`. */ +type KindStripQuery = S extends `${infer P}?${string}` ? P : S extends `${infer P}#${string}` ? P : S; +/** Longest registered dot-suffix of a basename, or `never`. */ +type MatchKind = S extends `${string}.${infer Rest}` + ? Lowercase extends keyof ExtensionKindMap + ? ExtensionKindMap[Lowercase] + : MatchKind + : never; + +/** The asset kind inferred from a path literal, or `never` when unregistered. */ +export type KindByPath = MatchKind>>; + +/** The resource type of a kind. */ +export type ResourceForKind = AssetDefinitions[K]['resource']; + +/** + * The handle-hybrid leaf type a bare path string materializes as: a resource + * kind yields its resource (`Texture`/`Sound`), a {@link ValueAssetKind} yields + * a deferred `AssetRef`. `unknown` when the suffix is unregistered. + */ +export type LeafForPath = [KindByPath] extends [never] + ? unknown + : KindByPath extends ValueAssetKind + ? AssetRef>> + : ResourceForKind>; + +/** A single catalog field input: a bare path string, an `X.of()` descriptor, or an explicit config. */ +export type CatalogEntry = string | Asset | AnyAssetConfig; + +/** The leaf type a {@link CatalogEntry} materializes as. */ +export type InferCatalogLeaf = + E extends string ? LeafForPath + : E extends Asset ? (T extends object ? T : AssetRef) + : E extends { type: infer K extends keyof AssetDefinitions } + ? K extends ValueAssetKind ? AssetRef : AssetDefinitions[K]['resource'] + : never; + +// Compile-time guard: every ExtensionKindMap value is a real AssetDefinitions kind. +type AssertKindMapValid = ExtensionKindMap[keyof ExtensionKindMap] extends keyof AssetDefinitions ? true : never; +const _extensionKindMapIsValid: AssertKindMapValid = true; +void _extensionKindMapIsValid; diff --git a/test/resources/assetDefinitions.type.test.ts b/test/resources/assetDefinitions.type.test.ts new file mode 100644 index 000000000..9f81b5bd7 --- /dev/null +++ b/test/resources/assetDefinitions.type.test.ts @@ -0,0 +1,23 @@ +import { describe, expectTypeOf,it } from 'vitest'; + +import type { Sound } from '#audio/Sound'; +import type { Texture } from '#rendering/texture/Texture'; +import type { KindByPath, LeafForPath } from '#resources/AssetDefinitions'; +import type { AssetRef } from '#resources/AssetRef'; + +describe('KindByPath / LeafForPath', () => { + it('maps a png path to texture kind + Texture leaf', () => { + expectTypeOf>().toEqualTypeOf<'texture'>(); + expectTypeOf>().toEqualTypeOf(); + }); + it('maps an ogg path to Sound leaf', () => { + expectTypeOf>().toEqualTypeOf(); + }); + it('maps a json path to AssetRef leaf (value kind)', () => { + expectTypeOf>().toEqualTypeOf>(); + }); + it('resolves an unregistered suffix to never / unknown', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); +}); From 91291f95beabd08d2d6f3565087caf049425ab48 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:27:41 +0200 Subject: [PATCH 30/85] =?UTF-8?q?feat(resources):=20Assets.from()=20accept?= =?UTF-8?q?s=20bare=20paths=20+=20.of=20+=20configs=20(=C2=A74.1,=20=C2=A7?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/content/api/assets.json | 4 +- src/resources/Assets.ts | 101 ++++++++++++++++++----------- src/resources/Loader.ts | 3 +- test/resources/assets.test.ts | 22 +++++++ test/resources/assets.type.test.ts | 15 +++++ 5 files changed, 103 insertions(+), 42 deletions(-) create mode 100644 test/resources/assets.type.test.ts diff --git a/site/src/content/api/assets.json b/site/src/content/api/assets.json index 2cbe825b9..896cc4f89 100644 --- a/site/src/content/api/assets.json +++ b/site/src/content/api/assets.json @@ -1,6 +1,6 @@ { "title": "Assets", - "description": "A reusable, typed asset container. Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an AssetRef. The container exposes those leaves as direct typed properties and via an `entries` record.", + "description": "A reusable, typed asset container. Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an `AssetRef`. The container exposes those leaves as direct typed properties and via an `entries` record.", "symbol": "Assets", "kind": "type", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "A reusable, typed asset container.", - "Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an AssetRef. The container exposes those leaves as direct typed properties and via an `entries` record." + "Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS a usable placeholder resource (`Texture`/`Sound`) that heals in place once adopted by a loader; a value kind's leaf is an `AssetRef`. The container exposes those leaves as direct typed properties and via an `entries` record." ], "importLine": "import { Assets } from '@codexo/exojs'", "sourceLink": null diff --git a/src/resources/Assets.ts b/src/resources/Assets.ts index bd0a9dd1d..6eaa564b3 100644 --- a/src/resources/Assets.ts +++ b/src/resources/Assets.ts @@ -1,39 +1,25 @@ -import type { Asset } from './Asset'; import { AssetImpl } from './Asset'; -import type { AnyAssetConfig, AssetDefinitions, AssetInput, ValueAssetKind } from './AssetDefinitions'; +import type { AnyAssetConfig, CatalogEntry, InferCatalogLeaf } from './AssetDefinitions'; import { createLeaf } from './assetKindRegistry'; -import type { AssetRef } from './AssetRef'; +import { resolveKindByPath } from './extensionKindRegistry'; // --------------------------------------------------------------------------- // Helper types // --------------------------------------------------------------------------- /** - * The handle-hybrid a catalog leaf materializes as: a resource kind's leaf IS - * the placeholder resource (`Texture`, `Sound`, …) that heals in place, while a - * {@link ValueAssetKind}'s leaf is a deferred {@link AssetRef}. - * - * For a plain config the specific kind `K` is known, so value/resource is - * classified precisely. For an already-constructed `Asset` only the resource - * type `T` survives, so we fall back to a structural heuristic — accurate for - * the resource kinds that flow through catalogs. + * The handle-hybrid a catalog leaf materializes as, delegating to + * {@link InferCatalogLeaf}: a resource kind's leaf IS the placeholder resource + * (`Texture`, `Sound`, …) that heals in place, while a value kind's leaf is a + * deferred `AssetRef`. A bare path string is classified by its file suffix. */ -type InferLeaf = - I extends Asset - ? T extends object - ? T - : AssetRef - : I extends { type: infer K extends keyof AssetDefinitions } - ? K extends ValueAssetKind - ? AssetRef - : AssetDefinitions[K]['resource'] - : never; - -export type InferAssetsEntries> = { +type InferLeaf = InferCatalogLeaf; + +export type InferAssetsEntries> = { [K in keyof M]: InferLeaf; }; -export type InferAssetsProperties> = { +export type InferAssetsProperties> = { readonly [K in keyof M]: InferLeaf; }; @@ -41,8 +27,31 @@ export type InferAssetsProperties> = { // Internal implementation // --------------------------------------------------------------------------- +/** + * Normalize a single catalog entry to a plain `{ type, source, ...opts }` + * config. A bare path string is resolved to its asset kind by file suffix + * (asset-system v2 §5); an unregistered/ambiguous suffix throws a guiding + * error pointing at `registerAsset()`, compound suffixes, or `X.of()`. An + * already-constructed `Asset` contributes its `_config`; a plain config passes + * through unchanged. + */ +function _normalizeEntry(value: CatalogEntry): AnyAssetConfig { + if (typeof value === 'string') { + const kind = resolveKindByPath(value); + if (kind === undefined) { + throw new Error( + `Assets: no asset kind is registered for the extension of "${value}". ` + + `Register one via registerAsset(), use a compound suffix, or annotate with X.of().`, + ); + } + const config = { type: kind, source: value }; + return config as AnyAssetConfig; + } + return value instanceof AssetImpl ? value._config : (value as AnyAssetConfig); +} + /** @internal */ -export class AssetsImpl> { +export class AssetsImpl> { public readonly entries: InferAssetsEntries; public constructor(definition: M) { @@ -52,13 +61,13 @@ export class AssetsImpl> { const entries: Record = {}; - for (const key of Object.keys(definition)) { - const value = definition[key]; - // Both a plain config and an already-constructed Asset (which carries its - // `_config`) resolve to `{ type, source, ...opts }`, then to a meta-stamped - // handle-hybrid leaf. An already-constructed Asset is CONVERTED to a leaf — - // it is no longer passed through by reference (pre-1.0 breaking change). - const config = value instanceof AssetImpl ? value._config : (value as AnyAssetConfig); + for (const [key, value] of Object.entries(definition)) { + // A bare path string, an already-constructed Asset (which carries its + // `_config`), or a plain config all normalize to `{ type, source, ...opts }`, + // then to a meta-stamped handle-hybrid leaf. An already-constructed Asset is + // CONVERTED to a leaf — it is no longer passed through by reference (pre-1.0 + // breaking change). + const config = _normalizeEntry(value); const { type, source, ...rest } = config; const opts = Object.keys(rest).length > 0 ? rest : undefined; const leaf = createLeaf(type, source, opts); @@ -86,14 +95,14 @@ export class AssetsImpl> { * * Each field is materialized as a handle-hybrid leaf: a resource kind's leaf IS * a usable placeholder resource (`Texture`/`Sound`) that heals in place once - * adopted by a loader; a value kind's leaf is an {@link AssetRef}. The container + * adopted by a loader; a value kind's leaf is an `AssetRef`. The container * exposes those leaves as direct typed properties and via an `entries` record. * * @example * ```ts - * const TitleAssets = new Assets({ - * logo: { type: 'texture', source: '/logo.png' }, - * config: { type: 'json', source: '/title.json' }, + * const TitleAssets = Assets.from({ + * logo: 'sprites/logo.png', // bare path — kind inferred from suffix + * config: { type: 'json', source: '/title.json' }, * }); * * TitleAssets.logo; // Texture (placeholder until adopted) @@ -101,8 +110,22 @@ export class AssetsImpl> { * loader.load(TitleAssets); * ``` */ -export type Assets> = AssetsImpl & InferAssetsProperties; +export type Assets> = AssetsImpl & InferAssetsProperties; + +type AssetsConstructorFn = new >(definition: M) => Assets; + +type AssetsFacade = AssetsConstructorFn & { + /** + * Build a typed catalog. Each field may be a bare path string (kind inferred + * from its suffix), an `X.of()` descriptor, or an explicit config. Bare + * strings only resolve for leaf-capable kinds; ambiguous/unregistered + * suffixes need `X.of()`. (asset-system v2 §4.1, §5) + */ + from>(definition: M): Assets; +}; -type AssetsConstructorFn = new >(definition: M) => Assets; +(AssetsImpl as unknown as { from: unknown }).from = function from>(definition: M): Assets { + return new (AssetsImpl as unknown as AssetsConstructorFn)(definition as never); +}; -export const Assets = AssetsImpl as unknown as AssetsConstructorFn; +export const Assets = AssetsImpl as unknown as AssetsFacade; diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 300513e92..e821906e3 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -1183,7 +1183,8 @@ export class Loader { if (typeOrPath instanceof AssetsImpl) { const out: Record = {}; - for (const [k, leaf] of Object.entries(typeOrPath.entries)) { + const entries = Object.entries((typeOrPath as AssetsImpl>).entries) as Array<[string, object]>; + for (const [k, leaf] of entries) { this._adopt(leaf, claimer); out[k] = leaf; } diff --git a/test/resources/assets.test.ts b/test/resources/assets.test.ts index 0c8fe264c..c1cfe315f 100644 --- a/test/resources/assets.test.ts +++ b/test/resources/assets.test.ts @@ -1,8 +1,10 @@ import '#resources/seamless'; +import { Sound } from '#audio/Sound'; import { Texture } from '#rendering/texture/Texture'; import { Asset } from '#resources/Asset'; import { _readMeta } from '#resources/assetMeta'; +import { AssetRef } from '#resources/AssetRef'; import { Assets } from '#resources/Assets'; describe('Assets', () => { @@ -41,3 +43,23 @@ describe('Assets', () => { expect(() => new Assets({ entries: { type: 'texture', source: '/x.png' } } as never)).toThrow(/reserved/); }); }); + +describe('Assets.from bare-string inference', () => { + it('builds a Texture leaf from a .png string, meta-stamped', () => { + const a = Assets.from({ ship: 'sprites/ship.png' }); + expect(a.ship).toBeInstanceOf(Texture); + expect(_readMeta(a.ship)).toMatchObject({ kind: 'texture', src: 'sprites/ship.png' }); + }); + it('builds a Sound leaf from an .ogg string', () => { + expect(Assets.from({ boom: 'sfx/boom.ogg' }).boom).toBeInstanceOf(Sound); + }); + it('builds an AssetRef leaf from a .json string', () => { + expect(Assets.from({ level: 'levels/1.json' }).level).toBeInstanceOf(AssetRef); + }); + it('throws a guiding error for an unregistered suffix', () => { + expect(() => Assets.from({ x: 'a/b.zzz' })).toThrow(/no asset kind|X\.of\(\)/); + }); + it('still accepts explicit configs (existing form) unchanged', () => { + expect(Assets.from({ ship: { type: 'texture', source: 's.png' } }).ship).toBeInstanceOf(Texture); + }); +}); diff --git a/test/resources/assets.type.test.ts b/test/resources/assets.type.test.ts new file mode 100644 index 000000000..ddcdb2667 --- /dev/null +++ b/test/resources/assets.type.test.ts @@ -0,0 +1,15 @@ +import '#resources/seamless'; + +import { describe, expectTypeOf, it } from 'vitest'; + +import type { Texture } from '#rendering/texture/Texture'; +import { type AssetRef } from '#resources/AssetRef'; +import { Assets } from '#resources/Assets'; + +describe('Assets.from types', () => { + it('infers Texture + AssetRef leaves from bare strings', () => { + const a = Assets.from({ ship: 'a.png', level: 'b.json' }); + expectTypeOf(a.ship).toEqualTypeOf(); + expectTypeOf(a.level).toEqualTypeOf>(); + }); +}); From 63b1bff6c8991f16070687252b93f3716f73f7eb Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:43:45 +0200 Subject: [PATCH 31/85] =?UTF-8?q?feat(resources):=20bare-path=20get()/load?= =?UTF-8?q?()=20infers=20value=20kinds=20=E2=86=92=20AssetRef=20(=C2=A74.2?= =?UTF-8?q?,=20=C2=A74.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/resources/Loader.ts | 86 ++++++++++++++++++++++++++++---- test/resources/asset-ref.test.ts | 80 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 10 deletions(-) diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index e821906e3..90dcecd40 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -7,7 +7,7 @@ import type { Texture } from '#rendering/texture/Texture'; import { Asset, AssetImpl } from './Asset'; import { parseContainer } from './AssetContainer'; -import type { AssetDefinitions, AssetInput, InferAssetResource } from './AssetDefinitions'; +import type { AssetDefinitions, AssetInput, InferAssetResource, KindByPath, LeafForPath, ResourceForKind } from './AssetDefinitions'; import type { AssetFactory } from './AssetFactory'; import type { AssetManifest, LoadBundleOptions } from './AssetManifest'; import { BundleLoadError, defineAssetManifest } from './AssetManifest'; @@ -17,6 +17,7 @@ import { type Assets, AssetsImpl, type InferAssetsProperties } from './Assets'; import { CacheFirstStrategy } from './CacheFirstStrategy'; import type { CacheStore } from './CacheStore'; import type { CacheStrategy } from './CacheStrategy'; +import { resolveKindByPath } from './extensionKindRegistry'; import type { TextureFactoryOptions } from './factories/TextureFactory'; import type { AssetConstructor } from './FactoryRegistry'; import { FactoryRegistry } from './FactoryRegistry'; @@ -352,6 +353,36 @@ export class Loader { private readonly _refs = new Map>; readonly options: unknown }>(); private readonly _valueTokens: ReadonlySet = new Set([Json, TextAsset, CsvAsset, XmlAsset, SubtitleAsset, BinaryAsset, WasmAsset]); + /** The value-asset token for a value kind name, or `undefined` for non-value kinds. @internal */ + private _valueTokenForKind(kind: keyof AssetDefinitions): AssetConstructor | undefined { + switch (kind) { + case 'json': + return Json; + case 'text': + return TextAsset; + case 'csv': + return CsvAsset; + case 'xml': + return XmlAsset; + case 'vtt': + case 'srt': + return SubtitleAsset; + case 'binary': + return BinaryAsset; + case 'wasm': + return WasmAsset; + case 'texture': + case 'sound': + case 'music': + case 'image': + case 'video': + case 'svg': + case 'font': + case 'bmFont': + return undefined; + } + } + // ── Refcount / claims (asset-system v2 §4.7) ────────────────────────────── /** App-lifetime claim scope for direct `app.loader.get/load(…)` calls. @internal */ private readonly _rootClaimer = Symbol('app-loader'); @@ -774,6 +805,9 @@ export class Loader { public load(path: [PathExtension] extends [never] ? never : S): LoadingQueue; // Inferred form — R comes from ExtensionTypeMap. public load(path: [PathExtension] extends [never] ? never : S): LoadingQueue>; + // Value-inclusive form — a bare value suffix (json/txt/csv/…) resolves the raw + // resource value (asset-system v2 §4.4); `ResourceForKind<'json'>` = `unknown`. + public load(path: [KindByPath] extends [never] ? never : S, options?: unknown): LoadingQueue>>; // ----------------------------------------------------------------------- // Loading — generic overloads (return type inferred from class) @@ -841,7 +875,16 @@ export class Loader { // 2b. Extension-based: single path string with no type token if (typeof arg0 === 'string' && arg1 === undefined) { const path = arg0; - const ctor = this._resolveExtensionType(path); + let ctor = this._resolveExtensionType(path); + + // Value-kind fallback: a bare path whose suffix maps to a value kind + // resolves the raw value via the value token (asset-system v2 §4.4). + if (ctor === undefined) { + const kind = resolveKindByPath(path); + const valueToken = kind !== undefined ? this._valueTokenForKind(kind) : undefined; + + if (valueToken !== undefined) ctor = valueToken; + } if (ctor === undefined) { throw new Error(`Loader: no type registered for any extension of "${path}". Register one via loader.registerExtension().`); @@ -1129,6 +1172,13 @@ export class Loader { */ public get(path: LoadByPath extends Texture | Sound ? S : never, options?: unknown): LoadByPath; + /** + * Bare-path seamless/value access: a resource suffix yields its handle, a + * value suffix yields a stable {@link AssetRef}, inferred from the file + * extension. Broader fallback below the resource-only overload above. + */ + public get(path: [KindByPath] extends [never] ? never : S, options?: unknown): LeafForPath; + /** * Seamless access to a value asset: returns a stable {@link AssetRef} * synchronously and never throws. The ref fills when the payload arrives @@ -1203,20 +1253,36 @@ export class Loader { const path = typeOrPath; const ctor = this._resolveExtensionType(path); - if (ctor === undefined) { - throw new Error(`Loader: no type registered for any extension of "${path}". Register one via loader.registerExtension().`); + if (ctor !== undefined) { + const pathAdapter = this._seamlessAdapters.get(ctor); + + if (pathAdapter !== undefined) { + const handle = this._getSeamless(ctor, pathAdapter, path, source); + this._claim(this._key(ctor, path), ctor, path, claimer); + + return handle; + } } - const pathAdapter = this._seamlessAdapters.get(ctor); + // Value-kind fallback: a bare path whose suffix maps to a value kind → + // stable AssetRef (asset-system v2 §4.2/§4.4). get()'s string overload + // passes the second arg as options via `source`. + const kind = resolveKindByPath(path); + const valueToken = kind !== undefined ? this._valueTokenForKind(kind) : undefined; + + if (valueToken !== undefined) { + const ref = this._getRef(valueToken, path, source); + this._claim(this._key(valueToken, path), valueToken, path, claimer); - if (pathAdapter === undefined) { - throw new Error(`Loader: type ${this._describeType(ctor)} inferred from "${path}" has no seamless adapter — use load() instead.`); + return ref; } - const handle = this._getSeamless(ctor, pathAdapter, path, source); - this._claim(this._key(ctor, path), ctor, path, claimer); + // Neither seamless nor value: keep the existing guidance errors. + if (ctor === undefined) { + throw new Error(`Loader: no type registered for any extension of "${path}". Register one via loader.registerExtension().`); + } - return handle; + throw new Error(`Loader: type ${this._describeType(ctor)} inferred from "${path}" has no seamless adapter — use load() instead.`); } // Not a container, meta-leaf, or path string: a Loadable type token. diff --git a/test/resources/asset-ref.test.ts b/test/resources/asset-ref.test.ts index 547b64639..a00588f70 100644 --- a/test/resources/asset-ref.test.ts +++ b/test/resources/asset-ref.test.ts @@ -1,6 +1,9 @@ +import '#resources/seamless'; // trigger core extension→kind registrations at import + import { expectTypeOf } from 'vitest'; import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; import { AssetRef } from '#resources/AssetRef'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; @@ -104,6 +107,83 @@ describe('AssetRef value assets', () => { }); }); +describe('bare-path get()/load() for value kinds (§4.2/§4.4)', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + test('get() returns an AssetRef for a .json path instead of throwing', () => { + mockFetchJson({ hp: 3 }); + const loader = createCoreLoader(); + + const ref = loader.get('data/config.json'); + + expect(ref).toBeInstanceOf(AssetRef); + }); + + test('get() returns an AssetRef for a .txt path', () => { + mockFetchJson('hello'); + const loader = createCoreLoader(); + + expect(loader.get('a/b.txt')).toBeInstanceOf(AssetRef); + }); + + test('bare-path get() shares ref identity with the token form', () => { + mockFetchJson({ a: 1 }); + const loader = createCoreLoader(); + + const viaPath = loader.get('cfg.json'); + const viaToken = loader.get(Json, 'cfg.json'); + + expect(viaPath).toBe(viaToken); + }); + + test('bare .json get() ref fills with the parsed value', async () => { + mockFetchJson({ ok: true }); + const loader = createCoreLoader(); + + const ref = loader.get('cfg.json') as AssetRef; + + await expect(ref.loaded).resolves.toEqual({ ok: true }); + }); + + test('await load() on a bare .json path resolves the parsed value', async () => { + mockFetchJson({ hp: 7 }); + const loader = createCoreLoader(); + + const value = await loader.load('cfg.json'); + + expect(value).toEqual({ hp: 7 }); + }); + + test('get() still returns a Texture for a .png path (seamless unchanged)', () => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 16, height: 16 })), + ); + mockFetchJson({}); + const loader = createCoreLoader(); + + expect(loader.get('a/b.png')).toBeInstanceOf(Texture); + + vi.unstubAllGlobals(); + }); + + test('get() for an unregistered suffix still throws with guidance', () => { + const loader = createCoreLoader(); + + expect(() => loader.get('theme.custom' as never)).toThrow('no type registered'); + }); + + test('type-level: bare value path → AssetRef, resource path → resource', () => { + const loader = createCoreLoader(); + + expectTypeOf(loader.get('a.json')).toEqualTypeOf>(); + expectTypeOf(loader.get('a.txt')).toEqualTypeOf>(); + expectTypeOf(loader.get('a.png')).toEqualTypeOf(); + }); +}); + describe('AssetRef status channel', () => { it('a constructed ref is loading with no error', () => { const ref = new AssetRef(); From 55cdecabd0c7700aa7e83bf638019836faaa9555 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 09:49:25 +0200 Subject: [PATCH 32/85] feat(resources): _makeAsset descriptor factory for X.of() statics --- src/resources/Asset.ts | 11 +++++++++++ test/resources/asset.test.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 test/resources/asset.test.ts diff --git a/src/resources/Asset.ts b/src/resources/Asset.ts index 532512135..b0308ebe8 100644 --- a/src/resources/Asset.ts +++ b/src/resources/Asset.ts @@ -39,3 +39,14 @@ export interface Asset { type AssetConstructorFn = new (config: { type: K } & AssetDefinitions[K]['config']) => Asset; export const Asset = AssetImpl as unknown as AssetConstructorFn; + +/** + * Build an {@link Asset} descriptor for an `X.of(source, opts?)` annotation + * static (asset-system v2 §5). Lives here (not in each resource class) so + * `Texture`/`Sound`/… import only this light POJO factory from `#resources/Asset`, + * with no runtime edge back into the `#resources` barrel. + * @internal + */ +export function _makeAsset(kind: K, source: string, opts?: object): Asset { + return new AssetImpl({ type: kind, source, ...(opts ?? {}) } as AnyAssetConfig) as unknown as Asset; +} diff --git a/test/resources/asset.test.ts b/test/resources/asset.test.ts new file mode 100644 index 000000000..e9ed768d5 --- /dev/null +++ b/test/resources/asset.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { _makeAsset, AssetImpl } from '#resources/Asset'; + +describe('_makeAsset', () => { + it('builds an AssetImpl with kind + source + spread opts', () => { + const a = _makeAsset('texture', 's.png', { mimeType: 'image/png' }); + expect(a).toBeInstanceOf(AssetImpl); + expect(a._config).toMatchObject({ type: 'texture', source: 's.png', mimeType: 'image/png' }); + }); + + it('works with no opts', () => { + expect(_makeAsset('json', 'a.json')._config).toEqual({ type: 'json', source: 'a.json' }); + }); +}); From b263ad8f158de05eb219298b2df5e08d61c5e1a0 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 10:07:58 +0200 Subject: [PATCH 33/85] =?UTF-8?q?feat(resources,rendering,audio):=20X.of()?= =?UTF-8?q?=20annotation=20statics=20on=20tokens=20+=20resource=20classes?= =?UTF-8?q?=20(=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `.of(source, opts?)` statics to the value-asset tokens (Json, TextAsset, CsvAsset, XmlAsset, SubtitleAsset, BinaryAsset, WasmAsset, SvgAsset, ImageAsset, FontAsset) and to Texture/Sound/AudioStream/Video/BmFont, each building an Asset descriptor via _makeAsset. Resource classes import _makeAsset + type Asset from the specific #resources/Asset module (never the #resources barrel) to avoid a runtime import cycle back into resources. Also drops the now-unnecessary outer `as unknown as Asset` cast in _makeAsset (AssetImpl structurally satisfies Asset for any T since _resource is optional) and regenerates the core API docs for the touched classes. --- site/src/content/api/audio-stream.json | 88 +++++- site/src/content/api/binary-asset.json | 72 ++++- site/src/content/api/bm-font.json | 72 ++++- site/src/content/api/csv-asset.json | 80 +++++- site/src/content/api/data-texture.json | 96 ++++++- site/src/content/api/font-asset.json | 149 ++++++++++- site/src/content/api/image-asset.json | 72 ++++- site/src/content/api/json.json | 72 ++++- site/src/content/api/loader.json | 324 ++++++++++++++++++++++- site/src/content/api/sound.json | 192 +++++++++++++- site/src/content/api/subtitle-asset.json | 76 +++++- site/src/content/api/svg-asset.json | 72 ++++- site/src/content/api/text-asset.json | 72 ++++- site/src/content/api/texture.json | 96 ++++++- site/src/content/api/video.json | 88 +++++- site/src/content/api/wasm-asset.json | 72 ++++- site/src/content/api/xml-asset.json | 72 ++++- src/audio/AudioStream.ts | 13 + src/audio/Sound.ts | 16 ++ src/rendering/text/BmFont.ts | 12 + src/rendering/texture/Texture.ts | 14 + src/rendering/video/Video.ts | 12 + src/resources/Asset.ts | 3 +- src/resources/tokens.ts | 106 +++++++- test/resources/of-statics.test.ts | 52 ++++ 25 files changed, 1944 insertions(+), 49 deletions(-) create mode 100644 test/resources/of-statics.test.ts diff --git a/site/src/content/api/audio-stream.json b/site/src/content/api/audio-stream.json index d305b38f0..6910c7860 100644 --- a/site/src/content/api/audio-stream.json +++ b/site/src/content/api/audio-stream.json @@ -6,10 +6,10 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 9, + "memberCount": 10, "counts": { "constructors": 1, - "methods": 2, + "methods": 3, "properties": 6, "events": 0 }, @@ -220,6 +220,90 @@ "params": [], "returnType": "void", "description": "Stop the active voice and release the media graph." + }, + { + "name": "of", + "signature": "of(source: string, options?: object): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "object", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "AudioStream", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "object", + "optional": true + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a streaming-audio asset, for Assets.from({...}) / loader.get(...) / loader.load(...) (asset-system v2 §5). Prefer a bare 'x.mp3' string when the suffix is unambiguous; use AudioStream.of(...) for dynamic paths, ambiguous suffixes, or per-asset options." } ], "paragraphs": [], diff --git a/site/src/content/api/binary-asset.json b/site/src/content/api/binary-asset.json index 672c498db..ee30de030 100644 --- a/site/src/content/api/binary-asset.json +++ b/site/src/content/api/binary-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "ArrayBuffer", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a raw binary asset (asset-system v2 §5). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/bm-font.json b/site/src/content/api/bm-font.json index 078631dde..bdb37aff9 100644 --- a/site/src/content/api/bm-font.json +++ b/site/src/content/api/bm-font.json @@ -6,10 +6,10 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 3, + "memberCount": 4, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 2, "events": 0 }, @@ -115,6 +115,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "BmFont", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a BMFont asset, for Assets.from({...}) / loader.get(...) / loader.load(...) (asset-system v2 §5). Prefer a bare 'font.fnt' string when the suffix is unambiguous; use BmFont.of(...) for dynamic paths or ambiguous suffixes." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "properties", "title": "Properties", diff --git a/site/src/content/api/csv-asset.json b/site/src/content/api/csv-asset.json index d03397cb2..29326ea86 100644 --- a/site/src/content/api/csv-asset.json +++ b/site/src/content/api/csv-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,82 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a CSV asset (asset-system v2 §5). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/data-texture.json b/site/src/content/api/data-texture.json index 8e9827131..81892b1bd 100644 --- a/site/src/content/api/data-texture.json +++ b/site/src/content/api/data-texture.json @@ -6,10 +6,10 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 36, + "memberCount": 37, "counts": { "constructors": 1, - "methods": 12, + "methods": 13, "properties": 23, "events": 0 }, @@ -768,6 +768,98 @@ ], "returnType": "Texture", "description": "Create a solid-colour texture of the given square size (default 1×1). Accepts a Color instance or any CSS colour string; a Color with alpha below 1 is rendered with that alpha. Generalizes the fixed Texture.black/Texture.white helpers." + }, + { + "name": "of", + "signature": "of(source: string, options?: TextureFactoryOptions & PreSizeOptions): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "TextureFactoryOptions", + "kind": "type" + }, + { + "text": " & ", + "kind": "punctuation" + }, + { + "text": "PreSizeOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "TextureFactoryOptions & PreSizeOptions", + "optional": true + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a texture asset, for Assets.from({...}) / loader.get(...) / loader.load(...) (asset-system v2 §5). Prefer a bare 'x.png' string when the suffix is unambiguous; use Texture.of(...) for dynamic paths, ambiguous suffixes, or per-asset options." } ], "paragraphs": [], diff --git a/site/src/content/api/font-asset.json b/site/src/content/api/font-asset.json index 114de0357..8a125e65a 100644 --- a/site/src/content/api/font-asset.json +++ b/site/src/content/api/font-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,151 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string, options: { addToDocument?: boolean; descriptors?: FontFaceDescriptors; family: string }): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "{ ", + "kind": "punctuation" + }, + { + "text": "addToDocument", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "descriptors", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "FontFaceDescriptors", + "kind": "type" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "family", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": " }", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "FontFace", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "{ addToDocument?: boolean; descriptors?: FontFaceDescriptors; family: string }", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a font-face asset (asset-system v2 §5). family is required — it names the FontFace registered with the document. Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/image-asset.json b/site/src/content/api/image-asset.json index 784ad812f..681ce3f50 100644 --- a/site/src/content/api/image-asset.json +++ b/site/src/content/api/image-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "HTMLImageElement", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a plain -loaded image asset (asset-system v2 §5) — no GPU upload, unlike Texture.of. Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/json.json b/site/src/content/api/json.json index f35c98b34..e38f0df65 100644 --- a/site/src/content/api/json.json +++ b/site/src/content/api/json.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a JSON asset (asset-system v2 §5). Supply T to type the parsed value: Json.of('levels/1.json'). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index 327469206..a6e78e091 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 66, + "memberCount": 68, "counts": { "constructors": 1, - "methods": 55, + "methods": 57, "properties": 2, "events": 8 }, @@ -740,7 +740,7 @@ }, { "name": "get", - "signature": "get(path: LoadByPath extends Sound | Texture ? S : never, options?: unknown): LoadByPath", + "signature": "get(path: LoadByPath extends Texture | Sound ? S : never, options?: unknown): LoadByPath", "signatureTokens": [ { "text": "get", @@ -787,7 +787,7 @@ "kind": "punctuation" }, { - "text": "Sound", + "text": "Texture", "kind": "type" }, { @@ -795,7 +795,7 @@ "kind": "punctuation" }, { - "text": "Texture", + "text": "Sound", "kind": "type" }, { @@ -862,7 +862,7 @@ "params": [ { "name": "path", - "type": "LoadByPath extends Sound | Texture ? S : never", + "type": "LoadByPath extends Texture | Sound ? S : never", "optional": false }, { @@ -874,6 +874,150 @@ "returnType": "LoadByPath", "description": "Seamless access by path alone — the asset type is inferred from the file extension (basename-only, longest-suffix-first; see ExtensionTypeMap). Accepts only paths whose inferred type has a seamless adapter (compile-time gate); dynamic strings resolving to an unregistered extension or a non-seamless type throw with guidance." }, + { + "name": "get", + "signature": "get(path: [KindByPath] extends [never] ? never : S, options?: unknown): LeafForPath", + "signatureTokens": [ + { + "text": "get", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "path", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "KindByPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "extends", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "never", + "kind": "keyword" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": " ? ", + "kind": "punctuation" + }, + { + "text": "never", + "kind": "keyword" + }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LeafForPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "path", + "type": "[KindByPath] extends [never] ? never : S", + "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true + } + ], + "returnType": "LeafForPath", + "description": "Bare-path seamless/value access: a resource suffix yields its handle, a value suffix yields a stable AssetRef, inferred from the file extension. Broader fallback below the resource-only overload above." + }, { "name": "get", "signature": "get(type: typeof Json, source: string, options?: unknown): AssetRef", @@ -3278,6 +3422,174 @@ "returnType": "LoadingQueue>", "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." }, + { + "name": "load", + "signature": "load(path: [KindByPath] extends [never] ? never : S, options?: unknown): LoadingQueue>>", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "path", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "KindByPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "extends", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "never", + "kind": "keyword" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": " ? ", + "kind": "punctuation" + }, + { + "text": "never", + "kind": "keyword" + }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "unknown", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "LoadingQueue", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "ResourceForKind", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "KindByPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "path", + "type": "[KindByPath] extends [never] ? never : S", + "optional": false + }, + { + "name": "options", + "type": "unknown", + "optional": true + } + ], + "returnType": "LoadingQueue>>", + "description": "Fetches and processes one or more assets of the given type. - **Single path** — resolves with the finished asset. - **Array of paths** — resolves with an ordered array of assets. - **Record** — resolves with a record whose keys match the input keys. - **Asset** — single typed asset reference. - **Assets** — typed asset container; keys become aliases. - **Config map** — inline { alias: { type, source, … } } definition. In-flight and already-loaded assets are de-duplicated: calling load for the same (type, alias) pair while a fetch is in progress attaches to the existing promise rather than issuing a second request. Supply a custom options object to pass factory-specific configuration (e.g. audio decoding hints or image format overrides)." + }, { "name": "load", "signature": "load(type: ConstrainedLoadable, path: S, options?: unknown): LoadingQueue>", diff --git a/site/src/content/api/sound.json b/site/src/content/api/sound.json index 9a029075f..b162e0a81 100644 --- a/site/src/content/api/sound.json +++ b/site/src/content/api/sound.json @@ -6,10 +6,10 @@ "subsystem": "audio", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 30, + "memberCount": 31, "counts": { "constructors": 1, - "methods": 9, + "methods": 10, "properties": 20, "events": 0 }, @@ -597,6 +597,194 @@ ], "returnType": "this", "description": "" + }, + { + "name": "of", + "signature": "of(source: string, options?: { playbackOptions?: Partial; poolSize?: number; sprites?: Readonly> }): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "{ ", + "kind": "punctuation" + }, + { + "text": "playbackOptions", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Partial", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "PlaybackOptions", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "poolSize", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": "; ", + "kind": "punctuation" + }, + { + "text": "sprites", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Readonly", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Record", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "AudioSpriteClip", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": " }", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Sound", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "{ playbackOptions?: Partial; poolSize?: number; sprites?: Readonly> }", + "optional": true + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a sound asset, for Assets.from({...}) / loader.get(...) / loader.load(...) (asset-system v2 §5). Prefer a bare 'x.ogg' string when the suffix is unambiguous; use Sound.of(...) for dynamic paths, ambiguous suffixes, or per-asset options." } ], "paragraphs": [], diff --git a/site/src/content/api/subtitle-asset.json b/site/src/content/api/subtitle-asset.json index ad62e53bb..8d972756c 100644 --- a/site/src/content/api/subtitle-asset.json +++ b/site/src/content/api/subtitle-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,78 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "VTTCue", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a subtitle asset (asset-system v2 §5). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/svg-asset.json b/site/src/content/api/svg-asset.json index 9c58865a6..a71fffc3c 100644 --- a/site/src/content/api/svg-asset.json +++ b/site/src/content/api/svg-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "HTMLImageElement", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for an SVG asset (asset-system v2 §5). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/text-asset.json b/site/src/content/api/text-asset.json index dad5dc297..bea982135 100644 --- a/site/src/content/api/text-asset.json +++ b/site/src/content/api/text-asset.json @@ -6,10 +6,10 @@ "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 1, + "memberCount": 2, "counts": { "constructors": 1, - "methods": 0, + "methods": 1, "properties": 0, "events": 0 }, @@ -63,6 +63,74 @@ "importLine": null, "sourceLink": null }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "of", + "signature": "of(source: string): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a plain-text asset (asset-system v2 §5). Use in Assets.from({...}) or loader.get(...)/loader.load(...)." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, { "id": "source", "title": "Source", diff --git a/site/src/content/api/texture.json b/site/src/content/api/texture.json index c9179712e..b199ef103 100644 --- a/site/src/content/api/texture.json +++ b/site/src/content/api/texture.json @@ -6,10 +6,10 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 32, + "memberCount": 33, "counts": { "constructors": 1, - "methods": 10, + "methods": 11, "properties": 21, "events": 0 }, @@ -622,6 +622,98 @@ ], "returnType": "Texture", "description": "Create a solid-colour texture of the given square size (default 1×1). Accepts a Color instance or any CSS colour string; a Color with alpha below 1 is rendered with that alpha. Generalizes the fixed Texture.black/Texture.white helpers." + }, + { + "name": "of", + "signature": "of(source: string, options?: TextureFactoryOptions & PreSizeOptions): Asset", + "signatureTokens": [ + { + "text": "of", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "source", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "options", + "kind": "param" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "TextureFactoryOptions", + "kind": "type" + }, + { + "text": " & ", + "kind": "punctuation" + }, + { + "text": "PreSizeOptions", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Texture", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "source", + "type": "string", + "optional": false + }, + { + "name": "options", + "type": "TextureFactoryOptions & PreSizeOptions", + "optional": true + } + ], + "returnType": "Asset", + "description": "Annotation descriptor for a texture asset, for Assets.from({...}) / loader.get(...) / loader.load(...) (asset-system v2 §5). Prefer a bare 'x.png' string when the suffix is unambiguous; use Texture.of(...) for dynamic paths, ambiguous suffixes, or per-asset options." } ], "paragraphs": [], diff --git a/site/src/content/api/video.json b/site/src/content/api/video.json index 81a80eb03..bc8ccb28d 100644 --- a/site/src/content/api/video.json +++ b/site/src/content/api/video.json @@ -6,10 +6,10 @@ "subsystem": "rendering", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 116, + "memberCount": 117, "counts": { "constructors": 1, - "methods": 49, + "methods": 50, "properties": 51, "events": 15 }, @@ -2384,6 +2384,90 @@ "returnType": "this", "description": "" }, + { + "name": "of", + "signature": "of(source: string, options?: object): Asset