diff --git a/examples/application-scenes/camera-basic.js b/examples/application-scenes/camera-basic.js index f8a9fb6c3..53cea523b 100644 --- a/examples/application-scenes/camera-basic.js +++ b/examples/application-scenes/camera-basic.js @@ -1,5 +1,5 @@ // Auto-generated from camera-basic.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -17,11 +17,9 @@ class CameraBasicScene extends Scene { grid; uiBar; zoom = 1; - async load(loader) { - this.bunny = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { const { width, height } = this.app.canvas; + this.bunny = new Sprite(this.loader.get('image/ship-a.png')); this.bunny.setAnchor(0.5).setPosition(width / 2, height / 2); this.grid = new Graphics(); this.grid.lineWidth = 1; diff --git a/examples/application-scenes/camera-basic.ts b/examples/application-scenes/camera-basic.ts index c94a466de..2deb33386 100644 --- a/examples/application-scenes/camera-basic.ts +++ b/examples/application-scenes/camera-basic.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/application-scenes/multi-view-split-screen.js index f8b536397..70733ca2f 100644 --- a/examples/application-scenes/multi-view-split-screen.js +++ b/examples/application-scenes/multi-view-split-screen.js @@ -1,5 +1,5 @@ // Auto-generated from multi-view-split-screen.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Keyboard, Scene, Sprite, Texture, View } from '@codexo/exojs'; +import { Application, Color, Graphics, Keyboard, Scene, Sprite, View } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -29,11 +29,9 @@ class SplitScreenScene extends Scene { up: 0, down: 0, }; - async load(loader) { - this.texture = await loader.load(Texture, 'image/ship-a.png'); - } init() { const { width, height } = this.app.canvas; + this.texture = this.loader.get('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); this.divider = new Graphics(); diff --git a/examples/application-scenes/multi-view-split-screen.ts b/examples/application-scenes/multi-view-split-screen.ts index 28aa96e87..6859cb633 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('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.js b/examples/application-scenes/pause-and-resume.js index 019b348fe..4d8149937 100644 --- a/examples/application-scenes/pause-and-resume.js +++ b/examples/application-scenes/pause-and-resume.js @@ -1,5 +1,5 @@ // Auto-generated from pause-and-resume.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,11 +15,9 @@ const app = new Application({ class PauseResumeScene extends Scene { sprite; label; - async load(loader) { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); this.label = new Text('Space or click: pause update', { fillColor: Color.white, fontSize: 16 }); diff --git a/examples/application-scenes/pause-and-resume.ts b/examples/application-scenes/pause-and-resume.ts index a963f89e2..f8278cb4b 100644 --- a/examples/application-scenes/pause-and-resume.ts +++ b/examples/application-scenes/pause-and-resume.ts @@ -1,4 +1,4 @@ -import { Application, Color, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/application-scenes/picture-in-picture.js index 139fad7f4..989d71475 100644 --- a/examples/application-scenes/picture-in-picture.js +++ b/examples/application-scenes/picture-in-picture.js @@ -1,5 +1,5 @@ // Auto-generated from picture-in-picture.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Texture, View } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, View } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -18,11 +18,9 @@ class PictureInPictureScene extends Scene { sprite; velocity = 220; frame; - async load(loader) { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get('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/picture-in-picture.ts b/examples/application-scenes/picture-in-picture.ts index 9ba5ff915..a47d2f5fa 100644 --- a/examples/application-scenes/picture-in-picture.ts +++ b/examples/application-scenes/picture-in-picture.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sprite, Texture, View } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, View } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/application-scenes/scene-lifecycle.js index 802fbb1f4..ed4467081 100644 --- a/examples/application-scenes/scene-lifecycle.js +++ b/examples/application-scenes/scene-lifecycle.js @@ -12,18 +12,38 @@ const app = new Application({ basePath: 'assets/', }, }); +// 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 { events; counter = 0; drawCount = 0; timer; text; - async load() { - this.events = ['load']; - } - init() { + async init() { 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('ship.png'); + // const data = (await this.loader.load(Asset.kind('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); this.text = new Text('', { fillColor: Color.white, fontSize: 18 }); this.text.setAnchor(0.5); @@ -43,6 +63,7 @@ class LifecycleScene extends Scene { context.render(this.text); } destroy() { + // destroy() is the single teardown hook — no separate unload() step. this.events.push('destroy'); super.destroy(); } diff --git a/examples/application-scenes/scene-lifecycle.ts b/examples/application-scenes/scene-lifecycle.ts index 4763d4b78..c69f1eba2 100644 --- a/examples/application-scenes/scene-lifecycle.ts +++ b/examples/application-scenes/scene-lifecycle.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Text, Time, Timer } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text, Time, Timer } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('ship.png'); + // const data = (await this.loader.load(Asset.kind('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/audio-basics/audio-buses.js b/examples/audio-basics/audio-buses.js index 351b246cc..68fda1894 100644 --- a/examples/audio-basics/audio-buses.js +++ b/examples/audio-basics/audio-buses.js @@ -1,5 +1,5 @@ // Auto-generated from audio-buses.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -32,11 +32,7 @@ class AudioBusesScene extends Scene { rowY = []; sfxButton = { x: 0, y: 0, w: 0, h: 0 }; hud; - async load(loader) { - await loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); - await loader.load(Sound, { sfx: assets.demo.audio.uiClick }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Centre the bus mixer horizontally and spread the bars across the wide // 16:9 canvas. @@ -44,8 +40,14 @@ class AudioBusesScene extends Scene { this.trackX = (width - this.trackW) / 2; 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(Assets.from({ music: Asset.kind('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)); this.sfxLabel = new Text('Play SFX ▶', { fillColor: new Color(20, 20, 20), fontSize: 20 }).setPosition(this.sfxButton.x + 92, this.sfxButton.y + 7); diff --git a/examples/audio-basics/audio-buses.ts b/examples/audio-basics/audio-buses.ts index 9d83d5808..8d3af53f6 100644 --- a/examples/audio-basics/audio-buses.ts +++ b/examples/audio-basics/audio-buses.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(Assets.from({ music: Asset.kind('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.js b/examples/audio-basics/crossfade-tracks.js index b78bf46d5..6976345c3 100644 --- a/examples/audio-basics/crossfade-tracks.js +++ b/examples/audio-basics/crossfade-tracks.js @@ -1,5 +1,5 @@ // Auto-generated from crossfade-tracks.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, crossFade, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, crossFade, Graphics, Scene, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -34,19 +34,18 @@ class CrossfadeTracksScene extends Scene { meterBX = 0; meterBaseY = 0; hud; - async load(loader) { - await loader.load(AudioStream, { a: assets.demo.audio.musicA, b: assets.demo.audio.musicB }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Spread the two meters across the wide canvas: each sits a third of the // way in from its side, centred on the meter width. this.meterAX = width * 0.33 - METER_W / 2; 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(Assets.from({ a: Asset.kind('music', assets.demo.audio.musicA), b: Asset.kind('music', 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' }) .setAnchor(0.5, 0.5) diff --git a/examples/audio-basics/crossfade-tracks.ts b/examples/audio-basics/crossfade-tracks.ts index 440478cdb..1e6c6a548 100644 --- a/examples/audio-basics/crossfade-tracks.ts +++ b/examples/audio-basics/crossfade-tracks.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, crossFade, Graphics, Scene, Text, type Voice } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, crossFade, Graphics, Scene, Text, type Voice } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(Assets.from({ a: Asset.kind('music', assets.demo.audio.musicA), b: Asset.kind('music', 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.js b/examples/audio-basics/music-loop.js index 8eed904a5..da0d09a47 100644 --- a/examples/audio-basics/music-loop.js +++ b/examples/audio-basics/music-loop.js @@ -1,5 +1,5 @@ // Auto-generated from music-loop.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -20,16 +20,15 @@ class MusicLoopScene extends Scene { bar = { x: 0, y: 0, w: 0, h: 28 }; hud; panel; - async load(loader) { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Wide progress bar centred horizontally on the 16:9 canvas. this.bar = { x: width * 0.15, y: height * 0.5, w: width * 0.7, h: 28 }; // 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(Assets.from({ track: Asset.kind('music', 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, // and all live control (volume, rate, loop, seek) lives on it. diff --git a/examples/audio-basics/music-loop.ts b/examples/audio-basics/music-loop.ts index e38850ef9..cf820db11 100644 --- a/examples/audio-basics/music-loop.ts +++ b/examples/audio-basics/music-loop.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, type Loopable, type Pausable, type RatePitched, Scene, type Seekable, Text, type Voice } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Graphics, type Loopable, type Pausable, type RatePitched, Scene, type Seekable, Text, type Voice } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(Assets.from({ track: Asset.kind('music', 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.js b/examples/audio-basics/play-sound.js index 0a958ae7c..5ecbc3cd2 100644 --- a/examples/audio-basics/play-sound.js +++ b/examples/audio-basics/play-sound.js @@ -1,5 +1,5 @@ // Auto-generated from play-sound.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, Scene, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,14 +15,14 @@ class PlaySoundScene extends Scene { sounds; text; index = 0; - async load(loader) { - await Promise.all(SOUND_KEYS.map(key => loader.load(Sound, { [key]: assets.demo.audio[key] }))); - } - init(loader) { + init() { 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/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.js b/examples/audio-basics/random-pitch-pool.js index af95a237b..aeb3f00ce 100644 --- a/examples/audio-basics/random-pitch-pool.js +++ b/examples/audio-basics/random-pitch-pool.js @@ -1,5 +1,5 @@ // Auto-generated from random-pitch-pool.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Keyboard, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -27,15 +27,15 @@ class RandomPitchPoolScene extends Scene { trackY = 0; trackHalf = 0; hud; - async load(loader) { - await loader.load(Sound, { blip: assets.demo.audio.impactLight }); - } - init(loader) { + init() { 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(); this.label = new Text('Hold Space to retrigger with a random pitch', { fillColor: Color.white, fontSize: 22, align: 'center' }) 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.js b/examples/audio-basics/sound-pool.js index aaaa2fc4a..6d4ce5659 100644 --- a/examples/audio-basics/sound-pool.js +++ b/examples/audio-basics/sound-pool.js @@ -1,5 +1,5 @@ // Auto-generated from sound-pool.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Keyboard, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -27,14 +27,14 @@ class SoundPoolScene extends Scene { clock = 0; evictions = 0; hud; - async load(loader) { + init() { + const { width, height } = this.app.canvas; // 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 }); - } - init(loader) { - const { width, height } = this.app.canvas; - this.sound = loader.get(Sound, 'shot'); + // 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(); this.label = new Text('Hold Space to fire faster than voices finish', { fillColor: Color.white, fontSize: 22, align: 'center' }) 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.js b/examples/audio-fx/compressor.js index 2b7177c97..fcd8cfb53 100644 --- a/examples/audio-fx/compressor.js +++ b/examples/audio-fx/compressor.js @@ -1,5 +1,5 @@ // Auto-generated from compressor.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { CompressorEffect } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -35,10 +35,7 @@ class CompressorScene extends Scene { rowY = []; meterY = 0; hud; - async load(loader) { - await loader.load(AudioStream, { music: 'audio/demo-loop-main.ogg' }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Wide horizontal bars centred on the 16:9 canvas; labels sit to the left. this.barW = width * 0.45; @@ -46,7 +43,9 @@ class CompressorScene extends Scene { this.labelX = width * 0.1; 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(Assets.from({ music: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = music; this.filter = new CompressorEffect(); app.audio.music.addEffect(this.filter); this.gfx = new Graphics(); diff --git a/examples/audio-fx/compressor.ts b/examples/audio-fx/compressor.ts index 90be06874..221c1ee97 100644 --- a/examples/audio-fx/compressor.ts +++ b/examples/audio-fx/compressor.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { CompressorEffect } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Assets.from({ music: Asset.kind('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.js b/examples/audio-fx/ducking.js index f2ea9ec1d..cd71a5123 100644 --- a/examples/audio-fx/ducking.js +++ b/examples/audio-fx/ducking.js @@ -1,5 +1,5 @@ // Auto-generated from ducking.ts — edit the .ts source, not this file. -import { Application, AudioBus, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioBus, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { AudioAnalyser, DuckingEffect } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -31,19 +31,20 @@ class DuckingScene extends Scene { musicBarY = 0; voiceBarY = 0; hud; - async load(loader) { - await loader.load(AudioStream, { music: assets.demo.audio.musicLoop }); - await loader.load(Sound, { voice: assets.demo.voice.congratulations }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Wide meters centred on the 16:9 canvas. this.barX = width * 0.1; this.barW = width * 0.8; 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(Assets.from({ music: Asset.kind('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 }); app.audio.registerBus(this.voiceBus); diff --git a/examples/audio-fx/ducking.ts b/examples/audio-fx/ducking.ts index 57b5d87c6..53b89e0f6 100644 --- a/examples/audio-fx/ducking.ts +++ b/examples/audio-fx/ducking.ts @@ -1,4 +1,4 @@ -import { Application, AudioBus, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioBus, AudioStream, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { AudioAnalyser, DuckingEffect } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Assets.from({ music: Asset.kind('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.js b/examples/audio-fx/reverb-and-delay.js index f0a155843..643291112 100644 --- a/examples/audio-fx/reverb-and-delay.js +++ b/examples/audio-fx/reverb-and-delay.js @@ -1,5 +1,5 @@ // Auto-generated from reverb-and-delay.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -27,14 +27,14 @@ class ReverbAndDelayScene extends Scene { pad = { x: 0, y: 0, w: 0, h: 0 }; hud; panel; - async load(loader) { - await loader.load(Sound, { sfx: 'audio/impact-light.ogg' }); - } - init(loader) { + init() { 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 }); this.delay = new DelayEffect({ wet: 0.35, delaySeconds: 0.25, feedback: 0.45 }); 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.js b/examples/audio-fx/vocoder.js index 8a264f4c4..fb27534fa 100644 --- a/examples/audio-fx/vocoder.js +++ b/examples/audio-fx/vocoder.js @@ -1,5 +1,5 @@ // Auto-generated from vocoder.ts — edit the .ts source, not this file. -import { Application, AudioBus, AudioGenerator, Color, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, AudioBus, AudioGenerator, Color, Scene, Text } from '@codexo/exojs'; import { VocoderEffect } from '@codexo/exojs-audio-fx'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -26,17 +26,17 @@ class VocoderScene extends Scene { phraseLabel; tapPrompt; hud; - async load(loader) { - await loader.load(Sound, Object.fromEntries(PHRASES.map(phrase => [phrase.key, phrase.asset]))); - } - init(loader) { + init() { const { width, height } = this.app.canvas; // The spoken voice is the modulator: route every phrase onto its own bus // so the vocoder can read its spectral envelope. this.modulatorBus = new AudioBus('modulator', { parent: app.audio.master }); 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 — + // use the explicit Sound token form. + this.phrases.set(phrase.key, this.loader.get(Asset.kind('sound', phrase.asset))); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); app.audio.sound.addEffect(this.vocoder); diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index 07dbaf504..598bd2ee1 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -1,4 +1,4 @@ -import { Application, AudioBus, AudioGenerator, Color, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, AudioBus, AudioGenerator, Color, Scene, Sound, Text } from '@codexo/exojs'; import { VocoderEffect } from '@codexo/exojs-audio-fx'; import { mountControlPanel, mountControls } from '@examples/runtime'; @@ -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,10 @@ 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 — + // use the explicit Sound token form. + this.phrases.set(phrase.key, this.loader.get(Asset.kind('sound', phrase.asset))); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); diff --git a/examples/beat-detection/beat-sync-pulse.js b/examples/beat-detection/beat-sync-pulse.js index a4691efcf..3b8a8c83d 100644 --- a/examples/beat-detection/beat-sync-pulse.js +++ b/examples/beat-detection/beat-sync-pulse.js @@ -1,5 +1,5 @@ // Auto-generated from beat-sync-pulse.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Scene, Sprite, Text, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Scene, Sprite, Text, Vector } from '@codexo/exojs'; import { BeatDetector } from '@codexo/exojs-audio-fx'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem, } from '@codexo/exojs-particles'; import { mountControlPanel, mountControls } from '@examples/runtime'; @@ -27,19 +27,17 @@ class BeatSyncPulseScene extends Scene { burst; hud; tapPrompt; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png', particle: 'image/particle-light.png' }); - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - init(loader) { + async init() { 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(Assets.from({ track: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = track; + this.sprite = new Sprite(this.loader.get('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('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/beat-sync-pulse.ts b/examples/beat-detection/beat-sync-pulse.ts index dc68f3c0a..e5b8397cf 100644 --- a/examples/beat-detection/beat-sync-pulse.ts +++ b/examples/beat-detection/beat-sync-pulse.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Scene, Sprite, Text, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Scene, Sprite, Text, Vector } from '@codexo/exojs'; import { BeatDetector } from '@codexo/exojs-audio-fx'; import { AlphaFadeOverLifetime, @@ -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(Assets.from({ track: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = track; + this.sprite = new Sprite(this.loader.get('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('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.js b/examples/beat-detection/frequency-bands.js index 227e60cbb..c18012692 100644 --- a/examples/beat-detection/frequency-bands.js +++ b/examples/beat-detection/frequency-bands.js @@ -1,5 +1,5 @@ // Auto-generated from frequency-bands.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -41,11 +41,10 @@ class FrequencyBandsScene extends Scene { levels = new Array(BAND_COUNT).fill(0); hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - init(loader) { - this.music = loader.get(AudioStream, 'track'); + async init() { + // AudioStream has no seamless adapter — await it explicitly. + const { track } = await this.loader.load(Assets.from({ track: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = track; this.analyser = new AudioAnalyser({ fftSize: 2048, smoothingTimeConstant: 0.75 }); this.analyser.source = this.app.audio.music; // Log-spaced bin boundaries across the spectrum. Index 0 (DC) is skipped diff --git a/examples/beat-detection/frequency-bands.ts b/examples/beat-detection/frequency-bands.ts index 67f8d3de8..33688d346 100644 --- a/examples/beat-detection/frequency-bands.ts +++ b/examples/beat-detection/frequency-bands.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Assets.from({ track: Asset.kind('music', '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.js b/examples/beat-detection/tempo-tracking.js index c4c42ad53..30b379918 100644 --- a/examples/beat-detection/tempo-tracking.js +++ b/examples/beat-detection/tempo-tracking.js @@ -1,5 +1,5 @@ // Auto-generated from tempo-tracking.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -27,13 +27,12 @@ class TempoTrackingScene extends Scene { onsetPeak = 0.001; hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { track: 'audio/demo-loop-main.ogg' }); - } - init(loader) { + async init() { 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(Assets.from({ track: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = track; this.detector = new BeatDetector(); this.detector.source = this.app.audio.music; this.readout = new Text('BPM —', { fillColor: Color.white, fontSize: 40 }); diff --git a/examples/beat-detection/tempo-tracking.ts b/examples/beat-detection/tempo-tracking.ts index badd43022..7525abec6 100644 --- a/examples/beat-detection/tempo-tracking.ts +++ b/examples/beat-detection/tempo-tracking.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Assets.from({ track: Asset.kind('music', 'audio/demo-loop-main.ogg') })); + this.music = track; this.detector = new BeatDetector(); this.detector.source = this.app.audio.music; diff --git a/examples/custom-renderers/custom-render-pass.js b/examples/custom-renderers/custom-render-pass.js index 5b1b00abc..501b44e29 100644 --- a/examples/custom-renderers/custom-render-pass.js +++ b/examples/custom-renderers/custom-render-pass.js @@ -1,5 +1,5 @@ // Auto-generated from custom-render-pass.ts — edit the .ts source, not this file. -import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -18,17 +18,14 @@ class CustomRenderPassScene extends Scene { between; pipeline; angle = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.back = new Sprite(loader.get(Texture, 'bunny')) + this.back = new Sprite(this.loader.get('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('image/ship-a.png')) .setAnchor(0.5) .setPosition(width / 2 + 200, height / 2) .setScale(2.2) diff --git a/examples/custom-renderers/custom-render-pass.ts b/examples/custom-renderers/custom-render-pass.ts index 830c64534..d1363db5f 100644 --- a/examples/custom-renderers/custom-render-pass.ts +++ b/examples/custom-renderers/custom-render-pass.ts @@ -1,4 +1,4 @@ -import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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('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.js b/examples/custom-renderers/custom-triangle-renderer.js index f3ba094af..53c72ffc6 100644 --- a/examples/custom-renderers/custom-triangle-renderer.js +++ b/examples/custom-renderers/custom-triangle-renderer.js @@ -138,9 +138,6 @@ class CustomTriangleRendererScene extends Scene { draw() { this.triangleRenderer.draw(); } - unload() { - this.triangleRenderer?.destroy(); - } destroy() { this.triangleRenderer?.destroy(); } 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/debug-layer/asset-browser.js b/examples/debug-layer/asset-browser.js index e05dce84b..473605f04 100644 --- a/examples/debug-layer/asset-browser.js +++ b/examples/debug-layer/asset-browser.js @@ -1,5 +1,6 @@ // Auto-generated from asset-browser.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, FontAsset, Graphics, Json, Scene, Sprite, Spritesheet, SvgAsset, Text, Texture, } from '@codexo/exojs'; +import { Asset } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, Spritesheet, Text, Texture, } from '@codexo/exojs'; // Dynamic category accessor: maps a category key to the correct sub-object // in the hierarchical assets catalog. Technical assets live under // assets.technical; everything else is under assets.demo. @@ -141,12 +142,9 @@ class AssetBrowserScene extends Scene { assetLoader = null; loadedCats = new Set(); loadingCats = new Set(); - async load(loader) { - this.assetLoader = loader; + async init() { + this.assetLoader = this.loader; await this.ensureCategory(this.cat); - } - init(loader) { - 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)); this.app.input.onMouseWheel.add(v => this.onWheel(v.y)); @@ -167,229 +165,146 @@ class AssetBrowserScene extends Scene { } async loadCategory(catId) { const loader = this.assetLoader; + // Every category below fetches its assets by real (dynamic, non-literal) + // path via `X.of(path)` + `load()` — the old alias-keyed batch-record form + // (`load(Type, { alias: path })` + `get(Type, alias)`) is gone; there is no + // alias indirection to reconstruct, so each item is loaded directly and its + // resolved handle/value is stored under the catalog's own key. switch (catId) { case 'textures': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.textures ?? {})) { - batch[`tex_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.textures ?? {})) { - const s = new Sprite(loader.get(Texture, `tex_${k}`)); + await Promise.all(Object.entries(assets.demo.textures ?? {}).map(async ([k, url]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', url))); s.setAnchor(0.5); this.texSprites.set(k, s); - } + })); break; } case 'sprites': { - const imgBatch = {}; - const jsonBatch = {}; - for (const [k, entry] of Object.entries(assets.demo.sprites ?? {})) { - imgBatch[`spr_${k}`] = entry.image; - jsonBatch[`spr_${k}`] = entry.data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.sprites ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `spr_${k}`), loader.get(Json, `spr_${k}`).value); + await Promise.all(Object.entries(assets.demo.sprites ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', entry.image)), + loader.load(Asset.kind('json', entry.data)), + ]); + const ss = new Spritesheet(tex, data); this.sprSheets.set(k, ss); for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + })); break; } case 'spritesheets': { - const imgBatch = {}; - const jsonBatch = {}; - for (const [k, entry] of Object.entries(assets.demo.spritesheets ?? {})) { - imgBatch[`ssh_${k}`] = entry.image; - jsonBatch[`ssh_${k}`] = entry.data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.spritesheets ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `ssh_${k}`), loader.get(Json, `ssh_${k}`).value); + await Promise.all(Object.entries(assets.demo.spritesheets ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', entry.image)), + loader.load(Asset.kind('json', entry.data)), + ]); + const ss = new Spritesheet(tex, data); this.sshSheets.set(k, ss); for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + })); break; } case 'svg': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.svg ?? {})) { - batch[`svg_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(SvgAsset, batch); - for (const k of Object.keys(assets.demo.svg ?? {})) { - const s = new Sprite(new Texture(loader.get(SvgAsset, `svg_${k}`))); + await Promise.all(Object.entries(assets.demo.svg ?? {}).map(async ([k, url]) => { + const s = new Sprite(new Texture(await loader.load(Asset.kind('svg', url)))); s.setAnchor(0.5); this.svgSprites.set(k, s); - } + })); break; } case 'inputPrompts': { - const imgBatch = {}; - const jsonBatch = {}; - for (const [k, entry] of Object.entries(assets.demo.inputPrompts ?? {})) { - imgBatch[`inp_${k}`] = entry.image; - jsonBatch[`inp_${k}`] = entry.data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.inputPrompts ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `inp_${k}`), loader.get(Json, `inp_${k}`).value); + await Promise.all(Object.entries(assets.demo.inputPrompts ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', entry.image)), + loader.load(Asset.kind('json', entry.data)), + ]); + const ss = new Spritesheet(tex, data); this.inpSheets.set(k, ss); for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + })); break; } case 'audio': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.audio ?? {})) { - batch[`aud_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.audio ?? {})) { - this.audioMusics.set(k, loader.get(AudioStream, `aud_${k}`)); - } + await Promise.all(Object.entries(assets.demo.audio ?? {}).map(async ([k, url]) => { + this.audioMusics.set(k, await loader.load(Asset.kind('music', url))); + })); break; } case 'sound': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.sound ?? {})) { - batch[`snd_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.sound ?? {})) { - this.soundMusics.set(k, loader.get(AudioStream, `snd_${k}`)); - } + await Promise.all(Object.entries(assets.demo.sound ?? {}).map(async ([k, url]) => { + this.soundMusics.set(k, await loader.load(Asset.kind('music', url))); + })); break; } case 'music': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.music ?? {})) { - batch[`mus_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.music ?? {})) { - this.musicMusics.set(k, loader.get(AudioStream, `mus_${k}`)); - } + await Promise.all(Object.entries(assets.demo.music ?? {}).map(async ([k, url]) => { + this.musicMusics.set(k, await loader.load(Asset.kind('music', url))); + })); break; } case 'soundSprites': { - const audioBatch = {}; - const jsonBatch = {}; - for (const [k, entry] of Object.entries(assets.demo.soundSprites ?? {})) { - audioBatch[`sds_${k}`] = entry.audio; - jsonBatch[`sds_${k}`] = entry.data; - } - if (Object.keys(audioBatch).length) { - await loader.load(AudioStream, audioBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.soundSprites ?? {})) { - this.soundSpriteAudio.set(k, loader.get(AudioStream, `sds_${k}`)); - this.soundSpriteData.set(k, loader.get(Json, `sds_${k}`).value); - } + await Promise.all(Object.entries(assets.demo.soundSprites ?? {}).map(async ([k, entry]) => { + const [audio, data] = await Promise.all([ + loader.load(Asset.kind('music', entry.audio)), + loader.load(Asset.kind('json', entry.data)), + ]); + this.soundSpriteAudio.set(k, audio); + this.soundSpriteData.set(k, data); + })); break; } case 'fonts': { - for (const [k, url] of Object.entries(assets.demo.fonts ?? {})) { + await Promise.all(Object.entries(assets.demo.fonts ?? {}).map(async ([k, url]) => { // The fonts category mixes vector fonts (.ttf/.otf) with // bitmap-font sidecars (.fnt/.png) that FontFace cannot // parse. Load only the vector entries — the bitmap ones // fall back to a path readout. if (!/\.(ttf|otf|woff2?)$/i.test(url)) - continue; + return; const family = `assetbrowser_${k}`; - await loader.load(FontAsset, { [`fnt_${k}`]: url }, { family }); + await loader.load(Asset.kind('font', url, { family })); this.fontFamilies.set(k, family); - } + })); break; } case 'technical': { - const batch = {}; - for (const [subcat, items] of Object.entries(assets.technical ?? {})) { - for (const [k, u] of Object.entries(items)) { - batch[`tech_${subcat}_${k}`] = u; - } - } - if (Object.keys(batch).length) - await loader.load(Texture, batch); - for (const [subcat, items] of Object.entries(assets.technical ?? {})) { - for (const k of Object.keys(items)) { - const s = new Sprite(loader.get(Texture, `tech_${subcat}_${k}`)); - s.setAnchor(0.5); - this.techSprites.set(`${subcat}.${k}`, s); - } - } + await Promise.all(Object.entries(assets.technical ?? {}).flatMap(([subcat, items]) => Object.entries(items).map(async ([k, u]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', u))); + s.setAnchor(0.5); + this.techSprites.set(`${subcat}.${k}`, s); + }))); break; } case 'backgrounds': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.backgrounds ?? {})) { - batch[`bg_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.backgrounds ?? {})) { - const s = new Sprite(loader.get(Texture, `bg_${k}`)); + await Promise.all(Object.entries(assets.demo.backgrounds ?? {}).map(async ([k, url]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', url))); s.setAnchor(0.5); this.bgSprites.set(k, s); - } + })); break; } case 'cursors': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.cursors ?? {})) { - batch[`cur_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(SvgAsset, batch); - for (const k of Object.keys(assets.demo.cursors ?? {})) { - const s = new Sprite(new Texture(loader.get(SvgAsset, `cur_${k}`))); + await Promise.all(Object.entries(assets.demo.cursors ?? {}).map(async ([k, url]) => { + const s = new Sprite(new Texture(await loader.load(Asset.kind('svg', url)))); s.setAnchor(0.5); this.cursorSprites.set(k, s); - } + })); break; } case 'tilesets': { - const batch = {}; - for (const [k, entry] of Object.entries(assets.demo.tilesets ?? {})) { - batch[`tls_${k}`] = entry.image; - } - if (Object.keys(batch).length) - await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.tilesets ?? {})) { - const s = new Sprite(loader.get(Texture, `tls_${k}`)); + await Promise.all(Object.entries(assets.demo.tilesets ?? {}).map(async ([k, entry]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', entry.image))); s.setAnchor(0.5); this.tilesetSprites.set(k, s); - } + })); break; } case 'vendor': { - const batch = {}; - for (const [k, url] of Object.entries(assets.demo.vendor ?? {})) { - batch[`vnd_${k}`] = url; - } - if (Object.keys(batch).length) - await loader.load(Json, batch); - for (const k of Object.keys(assets.demo.vendor ?? {})) { - this.vendorData.set(k, loader.get(Json, `vnd_${k}`).value); - } + await Promise.all(Object.entries(assets.demo.vendor ?? {}).map(async ([k, url]) => { + this.vendorData.set(k, await loader.load(Asset.kind('json', url))); + })); break; } default: diff --git a/examples/debug-layer/asset-browser.ts b/examples/debug-layer/asset-browser.ts index 8400ae03f..e80d3a5c9 100644 --- a/examples/debug-layer/asset-browser.ts +++ b/examples/debug-layer/asset-browser.ts @@ -1,6 +1,7 @@ +import { Asset } from '@codexo/exojs'; import { Application, AudioStream, Color, FontAsset, Graphics, Json, type Pausable, Scene, - type Seekable, Sprite, Spritesheet, SvgAsset, Text, Texture, type Voice, + type Seekable, type SpritesheetData, Sprite, Spritesheet, SvgAsset, Text, Texture, type Voice, } from '@codexo/exojs'; // Dynamic category accessor: maps a category key to the correct sub-object @@ -171,13 +172,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)); @@ -201,215 +198,174 @@ class AssetBrowserScene extends Scene { private async loadCategory(catId: string): Promise { const loader = this.assetLoader; + // Every category below fetches its assets by real (dynamic, non-literal) + // path via `X.of(path)` + `load()` — the old alias-keyed batch-record form + // (`load(Type, { alias: path })` + `get(Type, alias)`) is gone; there is no + // alias indirection to reconstruct, so each item is loaded directly and its + // resolved handle/value is stored under the catalog's own key. switch (catId) { case 'textures': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.textures ?? {})) { - batch[`tex_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.textures ?? {})) { - const s = new Sprite(loader.get(Texture, `tex_${k}`)); - s.setAnchor(0.5); - this.texSprites.set(k, s); - } + await Promise.all( + Object.entries(assets.demo.textures ?? {}).map(async ([k, url]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', url as string))); + s.setAnchor(0.5); + this.texSprites.set(k, s); + }), + ); break; } case 'sprites': { - const imgBatch: Record = {}; - const jsonBatch: Record = {}; - for (const [k, entry] of Object.entries(assets.demo.sprites ?? {})) { - imgBatch[`spr_${k}`] = (entry as any).image; - jsonBatch[`spr_${k}`] = (entry as any).data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.sprites ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `spr_${k}`), loader.get(Json, `spr_${k}`).value); - this.sprSheets.set(k, ss); - for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + await Promise.all( + Object.entries(assets.demo.sprites ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', (entry as any).image)), + loader.load(Asset.kind('json', (entry as any).data)), + ]); + const ss = new Spritesheet(tex, data); + this.sprSheets.set(k, ss); + for (const s of ss.sprites.values()) s.setAnchor(0.5); + }), + ); break; } case 'spritesheets': { - const imgBatch: Record = {}; - const jsonBatch: Record = {}; - for (const [k, entry] of Object.entries(assets.demo.spritesheets ?? {})) { - imgBatch[`ssh_${k}`] = (entry as any).image; - jsonBatch[`ssh_${k}`] = (entry as any).data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.spritesheets ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `ssh_${k}`), loader.get(Json, `ssh_${k}`).value); - this.sshSheets.set(k, ss); - for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + await Promise.all( + Object.entries(assets.demo.spritesheets ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', (entry as any).image)), + loader.load(Asset.kind('json', (entry as any).data)), + ]); + const ss = new Spritesheet(tex, data); + this.sshSheets.set(k, ss); + for (const s of ss.sprites.values()) s.setAnchor(0.5); + }), + ); break; } case 'svg': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.svg ?? {})) { - batch[`svg_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(SvgAsset, batch); - for (const k of Object.keys(assets.demo.svg ?? {})) { - const s = new Sprite(new Texture(loader.get(SvgAsset, `svg_${k}`))); - s.setAnchor(0.5); - this.svgSprites.set(k, s); - } + await Promise.all( + Object.entries(assets.demo.svg ?? {}).map(async ([k, url]) => { + const s = new Sprite(new Texture(await loader.load(Asset.kind('svg', url as string)))); + s.setAnchor(0.5); + this.svgSprites.set(k, s); + }), + ); break; } case 'inputPrompts': { - const imgBatch: Record = {}; - const jsonBatch: Record = {}; - for (const [k, entry] of Object.entries(assets.demo.inputPrompts ?? {})) { - imgBatch[`inp_${k}`] = (entry as any).image; - jsonBatch[`inp_${k}`] = (entry as any).data; - } - if (Object.keys(imgBatch).length) { - await loader.load(Texture, imgBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.inputPrompts ?? {})) { - const ss = new Spritesheet(loader.get(Texture, `inp_${k}`), loader.get(Json, `inp_${k}`).value); - this.inpSheets.set(k, ss); - for (const s of ss.sprites.values()) s.setAnchor(0.5); - } + await Promise.all( + Object.entries(assets.demo.inputPrompts ?? {}).map(async ([k, entry]) => { + const [tex, data] = await Promise.all([ + loader.load(Asset.kind('texture', (entry as any).image)), + loader.load(Asset.kind('json', (entry as any).data)), + ]); + const ss = new Spritesheet(tex, data); + this.inpSheets.set(k, ss); + for (const s of ss.sprites.values()) s.setAnchor(0.5); + }), + ); break; } case 'audio': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.audio ?? {})) { - batch[`aud_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.audio ?? {})) { - this.audioMusics.set(k, loader.get(AudioStream, `aud_${k}`)); - } + await Promise.all( + Object.entries(assets.demo.audio ?? {}).map(async ([k, url]) => { + this.audioMusics.set(k, await loader.load(Asset.kind('music', url as string))); + }), + ); break; } case 'sound': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.sound ?? {})) { - batch[`snd_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.sound ?? {})) { - this.soundMusics.set(k, loader.get(AudioStream, `snd_${k}`)); - } + await Promise.all( + Object.entries(assets.demo.sound ?? {}).map(async ([k, url]) => { + this.soundMusics.set(k, await loader.load(Asset.kind('music', url as string))); + }), + ); break; } case 'music': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.music ?? {})) { - batch[`mus_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(AudioStream, batch); - for (const k of Object.keys(assets.demo.music ?? {})) { - this.musicMusics.set(k, loader.get(AudioStream, `mus_${k}`)); - } + await Promise.all( + Object.entries(assets.demo.music ?? {}).map(async ([k, url]) => { + this.musicMusics.set(k, await loader.load(Asset.kind('music', url as string))); + }), + ); break; } case 'soundSprites': { - const audioBatch: Record = {}; - const jsonBatch: Record = {}; - for (const [k, entry] of Object.entries(assets.demo.soundSprites ?? {})) { - audioBatch[`sds_${k}`] = (entry as any).audio; - jsonBatch[`sds_${k}`] = (entry as any).data; - } - if (Object.keys(audioBatch).length) { - await loader.load(AudioStream, audioBatch); - await loader.load(Json, jsonBatch); - } - for (const k of Object.keys(assets.demo.soundSprites ?? {})) { - this.soundSpriteAudio.set(k, loader.get(AudioStream, `sds_${k}`)); - this.soundSpriteData.set(k, loader.get(Json, `sds_${k}`).value); - } + await Promise.all( + Object.entries(assets.demo.soundSprites ?? {}).map(async ([k, entry]) => { + const [audio, data] = await Promise.all([ + loader.load(Asset.kind('music', (entry as any).audio)), + loader.load(Asset.kind('json', (entry as any).data)), + ]); + this.soundSpriteAudio.set(k, audio); + this.soundSpriteData.set(k, data); + }), + ); break; } case 'fonts': { - for (const [k, url] of Object.entries(assets.demo.fonts ?? {})) { - // The fonts category mixes vector fonts (.ttf/.otf) with - // bitmap-font sidecars (.fnt/.png) that FontFace cannot - // parse. Load only the vector entries — the bitmap ones - // fall back to a path readout. - if (!/\.(ttf|otf|woff2?)$/i.test(url as string)) continue; - const family = `assetbrowser_${k}`; - await loader.load(FontAsset, { [`fnt_${k}`]: url }, { family }); - this.fontFamilies.set(k, family); - } + await Promise.all( + Object.entries(assets.demo.fonts ?? {}).map(async ([k, url]) => { + // The fonts category mixes vector fonts (.ttf/.otf) with + // bitmap-font sidecars (.fnt/.png) that FontFace cannot + // parse. Load only the vector entries — the bitmap ones + // fall back to a path readout. + if (!/\.(ttf|otf|woff2?)$/i.test(url as string)) return; + const family = `assetbrowser_${k}`; + await loader.load(Asset.kind('font', url as string, { family })); + this.fontFamilies.set(k, family); + }), + ); break; } case 'technical': { - const batch: Record = {}; - for (const [subcat, items] of Object.entries(assets.technical ?? {})) { - for (const [k, u] of Object.entries(items as Record)) { - batch[`tech_${subcat}_${k}`] = u; - } - } - if (Object.keys(batch).length) await loader.load(Texture, batch); - for (const [subcat, items] of Object.entries(assets.technical ?? {})) { - for (const k of Object.keys(items as Record)) { - const s = new Sprite(loader.get(Texture, `tech_${subcat}_${k}`)); - s.setAnchor(0.5); - this.techSprites.set(`${subcat}.${k}`, s); - } - } + await Promise.all( + Object.entries(assets.technical ?? {}).flatMap(([subcat, items]) => + Object.entries(items as Record).map(async ([k, u]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', u))); + s.setAnchor(0.5); + this.techSprites.set(`${subcat}.${k}`, s); + }), + ), + ); break; } case 'backgrounds': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.backgrounds ?? {})) { - batch[`bg_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.backgrounds ?? {})) { - const s = new Sprite(loader.get(Texture, `bg_${k}`)); - s.setAnchor(0.5); - this.bgSprites.set(k, s); - } + await Promise.all( + Object.entries(assets.demo.backgrounds ?? {}).map(async ([k, url]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', url as string))); + s.setAnchor(0.5); + this.bgSprites.set(k, s); + }), + ); break; } case 'cursors': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.cursors ?? {})) { - batch[`cur_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(SvgAsset, batch); - for (const k of Object.keys(assets.demo.cursors ?? {})) { - const s = new Sprite(new Texture(loader.get(SvgAsset, `cur_${k}`))); - s.setAnchor(0.5); - this.cursorSprites.set(k, s); - } + await Promise.all( + Object.entries(assets.demo.cursors ?? {}).map(async ([k, url]) => { + const s = new Sprite(new Texture(await loader.load(Asset.kind('svg', url as string)))); + s.setAnchor(0.5); + this.cursorSprites.set(k, s); + }), + ); break; } case 'tilesets': { - const batch: Record = {}; - for (const [k, entry] of Object.entries(assets.demo.tilesets ?? {})) { - batch[`tls_${k}`] = (entry as any).image; - } - if (Object.keys(batch).length) await loader.load(Texture, batch); - for (const k of Object.keys(assets.demo.tilesets ?? {})) { - const s = new Sprite(loader.get(Texture, `tls_${k}`)); - s.setAnchor(0.5); - this.tilesetSprites.set(k, s); - } + await Promise.all( + Object.entries(assets.demo.tilesets ?? {}).map(async ([k, entry]) => { + const s = new Sprite(await loader.load(Asset.kind('texture', (entry as any).image))); + s.setAnchor(0.5); + this.tilesetSprites.set(k, s); + }), + ); break; } case 'vendor': { - const batch: Record = {}; - for (const [k, url] of Object.entries(assets.demo.vendor ?? {})) { - batch[`vnd_${k}`] = url as string; - } - if (Object.keys(batch).length) await loader.load(Json, batch); - for (const k of Object.keys(assets.demo.vendor ?? {})) { - this.vendorData.set(k, loader.get(Json, `vnd_${k}`).value); - } + await Promise.all( + Object.entries(assets.demo.vendor ?? {}).map(async ([k, url]) => { + this.vendorData.set(k, await loader.load(Asset.kind('json', url as string))); + }), + ); break; } default: diff --git a/examples/debug-layer/bounding-boxes.js b/examples/debug-layer/bounding-boxes.js index 6db4cd901..f54162df5 100644 --- a/examples/debug-layer/bounding-boxes.js +++ b/examples/debug-layer/bounding-boxes.js @@ -1,5 +1,5 @@ // Auto-generated from bounding-boxes.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ canvas: { @@ -18,16 +18,13 @@ debug.layers.boundingBoxes.visible = true; class BoundingBoxesScene extends Scene { sprites; time = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/bounding-boxes.ts b/examples/debug-layer/bounding-boxes.ts index c085ae8f3..495d7ee9d 100644 --- a/examples/debug-layer/bounding-boxes.ts +++ b/examples/debug-layer/bounding-boxes.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ @@ -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('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.js b/examples/debug-layer/performance-overlay.js index 7534cc113..644c8092c 100644 --- a/examples/debug-layer/performance-overlay.js +++ b/examples/debug-layer/performance-overlay.js @@ -1,5 +1,5 @@ // Auto-generated from performance-overlay.ts — edit the .ts source, not this file. -import { Application, Color, Container, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ canvas: { @@ -18,10 +18,7 @@ debug.layers.performance.visible = true; class PerformanceOverlayScene extends Scene { sprites; layer; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; // All sprites share one texture, so adding them to a single container and // rendering it once lets the renderer batch them into a single draw call. @@ -29,7 +26,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('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/performance-overlay.ts b/examples/debug-layer/performance-overlay.ts index b5b1d4157..7af23d4b4 100644 --- a/examples/debug-layer/performance-overlay.ts +++ b/examples/debug-layer/performance-overlay.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ @@ -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('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.js b/examples/debug-layer/pointer-and-hittest.js index cf7f9dea3..d534cc708 100644 --- a/examples/debug-layer/pointer-and-hittest.js +++ b/examples/debug-layer/pointer-and-hittest.js @@ -1,5 +1,5 @@ // Auto-generated from pointer-and-hittest.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ canvas: { @@ -18,14 +18,11 @@ debug.layers.hitTest.visible = true; debug.layers.pointerStack.visible = true; class PointerAndHittestScene extends Scene { sprites; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/debug-layer/pointer-and-hittest.ts b/examples/debug-layer/pointer-and-hittest.ts index ee8b41f64..df0da5674 100644 --- a/examples/debug-layer/pointer-and-hittest.ts +++ b/examples/debug-layer/pointer-and-hittest.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ @@ -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('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/filters/blur-filter.js b/examples/filters/blur-filter.js index c1b31940c..33687d65c 100644 --- a/examples/filters/blur-filter.js +++ b/examples/filters/blur-filter.js @@ -1,5 +1,5 @@ // Auto-generated from blur-filter.ts — edit the .ts source, not this file. -import { Application, BlurFilter, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Scene, Sprite } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -20,13 +20,10 @@ class BlurFilterScene extends Scene { hud; panel; slider; - async load(loader) { - await loader.load(Texture, { grid: PIXEL_GRID }); - } - init(loader) { + init() { 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(PIXEL_GRID)).setAnchor(0.5).setScale(4.5).setPosition(width / 2, height / 2); this.sprite.filters = [this.blur]; this.hud = mountControls({ title: 'Blur Filter', diff --git a/examples/filters/blur-filter.ts b/examples/filters/blur-filter.ts index cf3758708..80b0b7bf3 100644 --- a/examples/filters/blur-filter.ts +++ b/examples/filters/blur-filter.ts @@ -1,4 +1,4 @@ -import { Application, BlurFilter, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Scene, Sprite } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/chromatic-aberration.js index 4d6b4eeaa..1e1f96710 100644 --- a/examples/filters/chromatic-aberration.js +++ b/examples/filters/chromatic-aberration.js @@ -1,5 +1,5 @@ // Auto-generated from chromatic-aberration.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -37,16 +37,13 @@ class ChromaticAberrationScene extends Scene { intensity = 0.4; hud; panel; - async load(loader) { - await loader.load(Texture, { checker: CHECKER }); - } - init(loader) { + init() { 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(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(). this.hud = mountControls({ diff --git a/examples/filters/chromatic-aberration.ts b/examples/filters/chromatic-aberration.ts index d29ef1a55..f771ceeae 100644 --- a/examples/filters/chromatic-aberration.ts +++ b/examples/filters/chromatic-aberration.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/color-filter.js index f0292cfe3..dd93ea8d3 100644 --- a/examples/filters/color-filter.js +++ b/examples/filters/color-filter.js @@ -1,5 +1,5 @@ // Auto-generated from color-filter.ts — edit the .ts source, not this file. -import { Application, Color, ColorFilter, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, ColorFilter, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -49,12 +49,9 @@ class ColorFilterScene extends Scene { index = 1; // start on Desaturate - the most visually obvious preset hud; cycle; - async load(loader) { - await loader.load(Texture, { hueRamp: HUE_RAMP }); - } - init(loader) { + init() { 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(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)); this.desaturate = this.makeShader(desaturateGlsl, desaturateWgsl); diff --git a/examples/filters/color-filter.ts b/examples/filters/color-filter.ts index cf2440d56..3be20ab24 100644 --- a/examples/filters/color-filter.ts +++ b/examples/filters/color-filter.ts @@ -1,4 +1,4 @@ -import { Application, Color, ColorFilter, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, ColorFilter, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/crt-scanlines.js index e89624549..bc5d6dd13 100644 --- a/examples/filters/crt-scanlines.js +++ b/examples/filters/crt-scanlines.js @@ -1,5 +1,5 @@ // Auto-generated from crt-scanlines.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -23,16 +23,13 @@ class CrtScanlinesScene extends Scene { enabled = true; hud; panel; - async load(loader) { - await loader.load(Texture, { grid: PIXEL_GRID }); - } - init(loader) { + init() { 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(PIXEL_GRID)).setAnchor(0.5).setScale(5).setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ title: 'CRT Scanlines', diff --git a/examples/filters/crt-scanlines.ts b/examples/filters/crt-scanlines.ts index e921a96b5..26a12eee7 100644 --- a/examples/filters/crt-scanlines.ts +++ b/examples/filters/crt-scanlines.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/custom-fragment-shader.js index d0bd7d809..9f6adf71c 100644 --- a/examples/filters/custom-fragment-shader.js +++ b/examples/filters/custom-fragment-shader.js @@ -1,5 +1,5 @@ // Auto-generated from custom-fragment-shader.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -33,16 +33,13 @@ class CustomFragmentShaderScene extends Scene { filter; sprite; hud; - async load(loader) { - await loader.load(Texture, { hueRamp: HUE_RAMP }); - } - init(loader) { + init() { 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(HUE_RAMP)).setAnchor(0.5).setScale(4).setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ title: 'Custom Fragment Shader', diff --git a/examples/filters/custom-fragment-shader.ts b/examples/filters/custom-fragment-shader.ts index 978e923bb..c6c1f3f49 100644 --- a/examples/filters/custom-fragment-shader.ts +++ b/examples/filters/custom-fragment-shader.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/filter-stack.js index 30e44cb38..a1080563a 100644 --- a/examples/filters/filter-stack.js +++ b/examples/filters/filter-stack.js @@ -1,5 +1,5 @@ // Auto-generated from filter-stack.ts — edit the .ts source, not this file. -import { Application, BlurFilter, Color, ColorFilter, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, BlurFilter, Color, ColorFilter, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -24,12 +24,9 @@ class FilterStackScene extends Scene { active = { blur: true, tint: true, custom: true }; hud; panel; - async load(loader) { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - init(loader) { + init() { 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(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/filter-stack.ts b/examples/filters/filter-stack.ts index 4a377a3e0..297864c99 100644 --- a/examples/filters/filter-stack.ts +++ b/examples/filters/filter-stack.ts @@ -1,4 +1,4 @@ -import { Application, BlurFilter, Color, ColorFilter, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, BlurFilter, Color, ColorFilter, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/noise-vignette.js index 73c74df6b..692ca4f28 100644 --- a/examples/filters/noise-vignette.js +++ b/examples/filters/noise-vignette.js @@ -1,5 +1,5 @@ // Auto-generated from noise-vignette.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -36,17 +36,14 @@ class NoiseVignetteScene extends Scene { sprite; hud; panel; - async load(loader) { - await loader.load(Texture, { grid: UV_GRID }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; this.filter = app.backend.backendType === RenderBackendType.WebGpu ? new WebGpuShaderFilter({ fragmentSource: wgsl, uniforms: { uTime: 0, uIntensity: this.intensity } }) : 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(UV_GRID); this.sprite = new Sprite(texture).setAnchor(0.5).setPosition(width / 2, height / 2); this.sprite.width = width; this.sprite.height = height; diff --git a/examples/filters/noise-vignette.ts b/examples/filters/noise-vignette.ts index 70645cf46..369169022 100644 --- a/examples/filters/noise-vignette.ts +++ b/examples/filters/noise-vignette.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/filters/palette-cycling.js index 0bb104dfe..4aafe3d10 100644 --- a/examples/filters/palette-cycling.js +++ b/examples/filters/palette-cycling.js @@ -1,5 +1,5 @@ // Auto-generated from palette-cycling.ts — edit the .ts source, not this file. -import { Application, Color, LutFilter, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, LutFilter, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -38,14 +38,11 @@ class PaletteCyclingScene extends Scene { sprite; offset = 0; hud; - async load(loader) { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - init(loader) { + init() { 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(PRIMARY_RAMP)).setAnchor(0.5).setScale(4); this.sprite.setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ diff --git a/examples/filters/palette-cycling.ts b/examples/filters/palette-cycling.ts index 7e7a39f6f..963ec5e40 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(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.js b/examples/geometry-graphics/gradient.js index dc6aa7fda..1e6c9a8dd 100644 --- a/examples/geometry-graphics/gradient.js +++ b/examples/geometry-graphics/gradient.js @@ -43,7 +43,7 @@ class GradientScene extends Scene { context.render(this.background); context.render(this.orb); } - unload() { + destroy() { this.background?.texture?.destroy(); this.orb?.texture?.destroy(); this.background?.destroy(); @@ -51,9 +51,6 @@ class GradientScene extends Scene { this.backgroundGradient?.destroy(); this.orbGradient?.destroy(); } - destroy() { - this.unload(); - } } app.start(new GradientScene()).catch(() => { app.canvas.remove(); 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.js b/examples/geometry-graphics/graphics-gradient.js index 590600f7f..fef40df2b 100644 --- a/examples/geometry-graphics/graphics-gradient.js +++ b/examples/geometry-graphics/graphics-gradient.js @@ -57,11 +57,8 @@ class GraphicsGradientScene extends Scene { context.backend.clear(); context.render(this.sceneRoot); } - unload() { - this.sceneRoot?.destroy(); - } destroy() { - this.unload(); + this.sceneRoot?.destroy(); } } app.start(new GraphicsGradientScene()).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.js b/examples/geometry-graphics/graphics-primitives.js index fa69611c7..c76e6746e 100644 --- a/examples/geometry-graphics/graphics-primitives.js +++ b/examples/geometry-graphics/graphics-primitives.js @@ -43,9 +43,6 @@ class GraphicsPrimitivesScene extends Scene { context.backend.clear(); context.render(this.sceneRoot); } - unload() { - this.sceneRoot?.destroy(); - } destroy() { 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.js b/examples/geometry-graphics/immediate-mode-rendering.js index 55c92fc15..ab21c7dc7 100644 --- a/examples/geometry-graphics/immediate-mode-rendering.js +++ b/examples/geometry-graphics/immediate-mode-rendering.js @@ -174,9 +174,6 @@ class ImmediateModeScene extends Scene { const path = this.batched ? 'RenderBatch (instanced)' : 'drawGeometry per spark'; this.hud.setStatus(`${path} · ${FIELD_COUNT} sparks · ${GEAR_COUNT} gears · drawCalls: ${drawCalls}`); } - unload() { - this.dispose(); - } destroy() { this.dispose(); } 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.js b/examples/geometry-graphics/infinite-grid.js index 07282218d..f1fd9fc89 100644 --- a/examples/geometry-graphics/infinite-grid.js +++ b/examples/geometry-graphics/infinite-grid.js @@ -1,5 +1,5 @@ // Auto-generated from infinite-grid.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, RenderBackendType, Scene, Sprite, Texture, View, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, Keyboard, RenderBackendType, Scene, Sprite, View, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -58,13 +58,10 @@ class InfiniteGridScene extends Scene { move = { x: 0, y: 0, zoom: 0 }; sprite; filter; - async load(loader) { - await loader.load(Texture, { uvGrid: 'image/uv-grid-256.png' }); - } - init(loader) { + init() { 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('image/uv-grid-256.png')); this.sprite.width = width; this.sprite.height = height; this.filter = diff --git a/examples/geometry-graphics/infinite-grid.ts b/examples/geometry-graphics/infinite-grid.ts index ee6116627..ccd52f31b 100644 --- a/examples/geometry-graphics/infinite-grid.ts +++ b/examples/geometry-graphics/infinite-grid.ts @@ -1,4 +1,4 @@ -import { Application, Color, Keyboard, RenderBackendType, Scene, Sprite, Texture, View, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, Keyboard, RenderBackendType, Scene, Sprite, View, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('image/uv-grid-256.png')); this.sprite.width = width; this.sprite.height = height; this.filter = diff --git a/examples/geometry-graphics/mesh-deformed-grid.js b/examples/geometry-graphics/mesh-deformed-grid.js index 43207979c..79107f834 100644 --- a/examples/geometry-graphics/mesh-deformed-grid.js +++ b/examples/geometry-graphics/mesh-deformed-grid.js @@ -1,5 +1,5 @@ // Auto-generated from mesh-deformed-grid.ts — edit the .ts source, not this file. -import { Application, Color, Mesh, Scene, Texture } from '@codexo/exojs'; +import { Application, Color, Mesh, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -51,10 +51,7 @@ class MeshDeformedGridScene extends Scene { restVertices; mesh; time = 0; - async load(loader) { - await loader.load(Texture, { uvGrid: UV_GRID }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; const grid = buildGrid(); this.restVertices = grid.vertices.slice(); @@ -62,7 +59,7 @@ class MeshDeformedGridScene extends Scene { vertices: grid.vertices, uvs: grid.uvs, indices: grid.indices, - texture: loader.get(Texture, 'uvGrid'), + texture: this.loader.get(UV_GRID), }); this.mesh.setPosition((width / 2) | 0, (height / 2) | 0); } diff --git a/examples/geometry-graphics/mesh-deformed-grid.ts b/examples/geometry-graphics/mesh-deformed-grid.ts index a69e849b2..e018786dc 100644 --- a/examples/geometry-graphics/mesh-deformed-grid.ts +++ b/examples/geometry-graphics/mesh-deformed-grid.ts @@ -1,4 +1,4 @@ -import { Application, Color, Mesh, Scene, Texture } from '@codexo/exojs'; +import { Application, Color, Mesh, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(UV_GRID), }); this.mesh.setPosition((width / 2) | 0, (height / 2) | 0); } diff --git a/examples/geometry-graphics/mesh-textured-quad.js b/examples/geometry-graphics/mesh-textured-quad.js index 3d5d408c1..98e6a82f2 100644 --- a/examples/geometry-graphics/mesh-textured-quad.js +++ b/examples/geometry-graphics/mesh-textured-quad.js @@ -1,5 +1,5 @@ // Auto-generated from mesh-textured-quad.ts — edit the .ts source, not this file. -import { Application, Color, Mesh, Scene, Texture } from '@codexo/exojs'; +import { Application, Color, Mesh, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -13,10 +13,7 @@ const UV_GRID = assets.technical.filtering.uvGrid256; const HALF = 300; class MeshTexturedQuadScene extends Scene { quad; - async load(loader) { - await loader.load(Texture, { uvGrid: UV_GRID }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; this.quad = new Mesh({ vertices: new Float32Array([ @@ -31,7 +28,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(UV_GRID), }); this.quad.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..57af8d47f 100644 --- a/examples/geometry-graphics/mesh-textured-quad.ts +++ b/examples/geometry-graphics/mesh-textured-quad.ts @@ -1,4 +1,4 @@ -import { Application, Color, Mesh, Scene, Texture } from '@codexo/exojs'; +import { Application, Color, Mesh, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(UV_GRID), }); this.quad.setPosition((width / 2) | 0, (height / 2) | 0); diff --git a/examples/getting-started/game-loop.js b/examples/getting-started/game-loop.js index 3d75e01b1..bc6f0d6f5 100644 --- a/examples/getting-started/game-loop.js +++ b/examples/getting-started/game-loop.js @@ -1,5 +1,5 @@ // Auto-generated from game-loop.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -14,11 +14,9 @@ const app = new Application({ }); class GameLoopScene extends Scene { sprite; - async load(loader) { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } diff --git a/examples/getting-started/game-loop.ts b/examples/getting-started/game-loop.ts index 111a32ccb..61efc7199 100644 --- a/examples/getting-started/game-loop.ts +++ b/examples/getting-started/game-loop.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } diff --git a/examples/getting-started/hello-world.js b/examples/getting-started/hello-world.js index 2df2a65b3..72f5425a7 100644 --- a/examples/getting-started/hello-world.js +++ b/examples/getting-started/hello-world.js @@ -1,5 +1,5 @@ // Auto-generated from hello-world.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,11 +15,9 @@ const app = new Application({ // #region guide:first-scene class HelloWorldScene extends Scene { sprite; - async load(loader) { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { const { width, height } = this.app.canvas; + this.sprite = new Sprite(this.loader.get('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..336eab420 100644 --- a/examples/getting-started/hello-world.ts +++ b/examples/getting-started/hello-world.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/getting-started/resize-and-dpr.js index a6bb9114d..7ee80df95 100644 --- a/examples/getting-started/resize-and-dpr.js +++ b/examples/getting-started/resize-and-dpr.js @@ -1,5 +1,5 @@ // Auto-generated from resize-and-dpr.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Text } from '@codexo/exojs'; // #region guide:app-setup const app = new Application({ canvas: { @@ -28,10 +28,8 @@ app.resize(window.innerWidth, window.innerHeight); class ResizeScene extends Scene { sprite; info; - async load(loader) { - this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png')); - } init() { + this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); this.info = new Text('', { fillColor: Color.white, fontSize: 16 }); this.info.setAnchor(0.5, 0); diff --git a/examples/getting-started/resize-and-dpr.ts b/examples/getting-started/resize-and-dpr.ts index 727d35533..ee6f10c2f 100644 --- a/examples/getting-started/resize-and-dpr.ts +++ b/examples/getting-started/resize-and-dpr.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Text } from '@codexo/exojs'; // #region guide:app-setup const app = new Application({ @@ -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('image/ship-a.png')); this.sprite.setAnchor(0.5); this.info = new Text('', { fillColor: Color.white, fontSize: 16 }); diff --git a/examples/input/action-mapping.js b/examples/input/action-mapping.js index 86b826f9c..abdca5cf2 100644 --- a/examples/input/action-mapping.js +++ b/examples/input/action-mapping.js @@ -1,5 +1,5 @@ // Auto-generated from action-mapping.ts — edit the .ts source, not this file. -import { Application, Color, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -26,12 +26,9 @@ class ActionMappingScene extends Scene { lastDevice = 'keyboard'; actions = { moveX: 0, moveY: 0, jump: false }; hud; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); const pad0 = this.app.input.getGamepad(0); // --- Move action: keyboard WASD/arrows feed key axes --- this.inputs.onActive([Keyboard.A, Keyboard.Left], () => (this.keys.left = 1)); diff --git a/examples/input/action-mapping.ts b/examples/input/action-mapping.ts index b200a1cce..0c0591b13 100644 --- a/examples/input/action-mapping.ts +++ b/examples/input/action-mapping.ts @@ -1,4 +1,4 @@ -import { Application, Color, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('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.js b/examples/input/gamepad.js index 4fcf19904..2c568ddfb 100644 --- a/examples/input/gamepad.js +++ b/examples/input/gamepad.js @@ -1,5 +1,5 @@ // Auto-generated from gamepad.ts — edit the .ts source, not this file. -import { Application, Color, Container, GamepadAxis, GamepadButton, Json, lerp, Scene, Spritesheet, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Container, GamepadAxis, GamepadButton, lerp, Scene, Spritesheet, Vector } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -22,12 +22,9 @@ class GamepadScene extends Scene { padBindings = []; status; container; - async load(loader) { - await loader.load(Texture, { buttons: 'image/buttons.png' }); - await loader.load(Json, { buttons: 'json/buttons.json' }); - } - init(loader) { - this.buttons = new Spritesheet(loader.get(Texture, 'buttons'), loader.get(Json, 'buttons').value); + async init() { + const buttonsData = (await this.loader.load(Asset.kind('json', 'json/buttons.json'))); + this.buttons = new Spritesheet(this.loader.get('image/buttons.png'), buttonsData); this.status = this.createStatus(); this.container = this.createGamepad(); for (const sprite of this.mappingButtons.values()) { diff --git a/examples/input/gamepad.ts b/examples/input/gamepad.ts index cc494fdfd..738b0406a 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, Asset, Color, Container, GamepadAxis, GamepadButton, lerp, Scene, Sprite, Spritesheet, type SpritesheetData, 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(Asset.kind('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('image/buttons.png'), buttonsData); this.status = this.createStatus(); this.container = this.createGamepad(); diff --git a/examples/input/mouse-and-pointer.js b/examples/input/mouse-and-pointer.js index 04057c56d..5ea8df439 100644 --- a/examples/input/mouse-and-pointer.js +++ b/examples/input/mouse-and-pointer.js @@ -1,5 +1,5 @@ // Auto-generated from mouse-and-pointer.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -29,14 +29,11 @@ class MouseAndPointerScene extends Scene { buttons = 0; clicks = 0; hud; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/mouse-and-pointer.ts b/examples/input/mouse-and-pointer.ts index 1d92225e8..3830deb2b 100644 --- a/examples/input/mouse-and-pointer.ts +++ b/examples/input/mouse-and-pointer.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('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.js b/examples/input/multi-gamepad.js index fa26c189b..89f9dee2f 100644 --- a/examples/input/multi-gamepad.js +++ b/examples/input/multi-gamepad.js @@ -1,5 +1,5 @@ // Auto-generated from multi-gamepad.ts — edit the .ts source, not this file. -import { Application, Color, GamepadAxis, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, GamepadAxis, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -23,13 +23,10 @@ class MultiGamepadScene extends Scene { hasPad = false; connectPrompt; hud; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('image/ship-a.png')) .setAnchor(0.5) .setScale(0.6) .setPosition(width * (0.2 + index * 0.2), height / 2) diff --git a/examples/input/multi-gamepad.ts b/examples/input/multi-gamepad.ts index da0049db1..1ae0e58fc 100644 --- a/examples/input/multi-gamepad.ts +++ b/examples/input/multi-gamepad.ts @@ -1,4 +1,4 @@ -import { Application, Color, GamepadAxis, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, GamepadAxis, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('image/ship-a.png')) .setAnchor(0.5) .setScale(0.6) .setPosition(width * (0.2 + index * 0.2), height / 2) diff --git a/examples/particles/bonfire.js b/examples/particles/bonfire.js index 5cef3d5dc..73dd8a96d 100644 --- a/examples/particles/bonfire.js +++ b/examples/particles/bonfire.js @@ -1,5 +1,5 @@ // Auto-generated from bonfire.ts — edit the .ts source, not this file. -import { Application, BlendModes, Color, Scene, Texture, } from '@codexo/exojs'; +import { Application, BlendModes, Color, Scene } from '@codexo/exojs'; import { ColorGradient, ColorOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn, VectorRange, } from '@codexo/exojs-particles'; const app = new Application({ canvas: { @@ -14,12 +14,9 @@ const app = new Application({ class BonfireScene extends Scene { fireSystem; smokeSystem; - async load(loader) { - await loader.load(Texture, { flame: assets.demo.textures.particleFlame, smoke: assets.demo.textures.particleSmoke }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.fireSystem = new ParticleSystem(loader.get(Texture, 'flame')); + this.fireSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleFlame)); this.fireSystem.setPosition(width * 0.5, height * 0.75); this.fireSystem.setBlendMode(BlendModes.Additive); this.fireSystem.addSpawnModule(new RateSpawn({ @@ -32,7 +29,7 @@ class BonfireScene extends Scene { { t: 0, color: new Color(194, 64, 30, 1) }, { t: 1, color: new Color(0, 0, 0, 0) }, ]))); - this.smokeSystem = new ParticleSystem(loader.get(Texture, 'smoke')); + this.smokeSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleSmoke)); this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40); this.smokeSystem.setBlendMode(BlendModes.Normal); this.smokeSystem.addSpawnModule(new RateSpawn({ diff --git a/examples/particles/bonfire.ts b/examples/particles/bonfire.ts index 3b014ee2a..8e95c9c67 100644 --- a/examples/particles/bonfire.ts +++ b/examples/particles/bonfire.ts @@ -1,10 +1,4 @@ -import { - Application, - BlendModes, - Color, - Scene, - Texture, -} from '@codexo/exojs'; +import { Application, BlendModes, Color, Scene } from '@codexo/exojs'; import { ColorGradient, ColorOverLifetime, @@ -32,14 +26,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(assets.demo.textures.particleFlame)); this.fireSystem.setPosition(width * 0.5, height * 0.75); this.fireSystem.setBlendMode(BlendModes.Additive); @@ -61,7 +51,7 @@ class BonfireScene extends Scene { ), ); - this.smokeSystem = new ParticleSystem(loader.get(Texture, 'smoke')); + this.smokeSystem = new ParticleSystem(this.loader.get(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.js b/examples/particles/cursor-attractor-particles.js index b183a89dc..14cdd0099 100644 --- a/examples/particles/cursor-attractor-particles.js +++ b/examples/particles/cursor-attractor-particles.js @@ -1,5 +1,5 @@ // Auto-generated from cursor-attractor-particles.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, AttractToPoint, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn, RepelFromPoint, } from '@codexo/exojs-particles'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -21,12 +21,9 @@ class CursorAttractorParticlesScene extends Scene { repeller; mode = 'attract'; hud; - async load(loader) { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 32000 }); + this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 32000 }); this.system.setPosition(width / 2, height / 2); this.system.addSpawnModule(new RateSpawn({ rate: new Constant(2200), diff --git a/examples/particles/cursor-attractor-particles.ts b/examples/particles/cursor-attractor-particles.ts index 7dede8b42..b768d2709 100644 --- a/examples/particles/cursor-attractor-particles.ts +++ b/examples/particles/cursor-attractor-particles.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, AttractToPoint, @@ -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(assets.demo.textures.particleLight), { capacity: 32000 }); this.system.setPosition(width / 2, height / 2); this.system.addSpawnModule( diff --git a/examples/particles/custom-wgsl-module.js b/examples/particles/custom-wgsl-module.js index 20c642331..e4f6b84b8 100644 --- a/examples/particles/custom-wgsl-module.js +++ b/examples/particles/custom-wgsl-module.js @@ -1,5 +1,5 @@ // Auto-generated from custom-wgsl-module.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Scene, Vector } from '@codexo/exojs'; import { Constant, particlesExtension, ParticleSystem, RateSpawn, UpdateModule, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -52,12 +52,9 @@ class CustomWgslModuleScene extends Scene { system; hud; reportedMode = false; - async load(loader) { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 26000 }); + this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 26000 }); this.system.setPosition(width / 2, height - 60); this.system.addSpawnModule(new RateSpawn({ rate: new Constant(1800), diff --git a/examples/particles/custom-wgsl-module.ts b/examples/particles/custom-wgsl-module.ts index 38ce782a8..0121664c1 100644 --- a/examples/particles/custom-wgsl-module.ts +++ b/examples/particles/custom-wgsl-module.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Scene, Vector } from '@codexo/exojs'; import { Constant, particlesExtension, @@ -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(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.js b/examples/particles/emitter-basics.js index 129fc6d23..7bd304459 100644 --- a/examples/particles/emitter-basics.js +++ b/examples/particles/emitter-basics.js @@ -1,5 +1,5 @@ // Auto-generated from emitter-basics.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Texture } from '@codexo/exojs'; +import { Application, Color, Scene } from '@codexo/exojs'; import { ApplyForce, ColorGradient, ColorOverLifetime, ConeDirection, Constant, Curve, particlesExtension, ParticleSystem, Range, RateSpawn, ScaleOverLifetime, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -15,12 +15,9 @@ const app = new Application({ class EmitterBasicsScene extends Scene { system; hud; - async load(loader) { - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 4000 }); + this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 4000 }); this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that // shoots upward (-π/2) with a ±36° spread and 70–180 px/s speed. diff --git a/examples/particles/emitter-basics.ts b/examples/particles/emitter-basics.ts index eff501a86..5eb2d0573 100644 --- a/examples/particles/emitter-basics.ts +++ b/examples/particles/emitter-basics.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, Scene } from '@codexo/exojs'; import { ApplyForce, ColorGradient, @@ -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(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.js b/examples/particles/fireworks.js index 46492f46b..f67d980f4 100644 --- a/examples/particles/fireworks.js +++ b/examples/particles/fireworks.js @@ -1,5 +1,5 @@ // Auto-generated from fireworks.ts — edit the .ts source, not this file. -import { Application, BlendModes, Color, Random, Scene, Size, Sprite, Texture, Time, Timer, Vector, } from '@codexo/exojs'; +import { Application, BlendModes, Color, Random, Scene, Size, Sprite, Time, Timer, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, BurstSpawn, ConeDirection, Constant, Curve, particlesExtension, ParticleSystem, Range, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -43,13 +43,10 @@ class FireworksScene extends Scene { rockets = []; launchCount = 0; hud; - async load(loader) { - await loader.load(Texture, { star: assets.demo.textures.particleStar }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; this.canvasSize = new Size(width, height); - this.rocketTexture = loader.get(Texture, 'star'); + this.rocketTexture = this.loader.get(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. this.explosions = new ParticleSystem(this.rocketTexture, { capacity: 16384 }); diff --git a/examples/particles/fireworks.ts b/examples/particles/fireworks.ts index 690403944..7095c8111 100644 --- a/examples/particles/fireworks.ts +++ b/examples/particles/fireworks.ts @@ -1,16 +1,4 @@ -import { - Application, - BlendModes, - Color, - Random, - Scene, - Size, - Sprite, - Texture, - Time, - Timer, - Vector, -} from '@codexo/exojs'; +import { Application, BlendModes, Color, Random, Scene, Size, Sprite, Texture, Time, Timer, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, @@ -76,15 +64,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(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.js b/examples/particles/gpu-particles.js index 9504fd626..8ab5a2ad4 100644 --- a/examples/particles/gpu-particles.js +++ b/examples/particles/gpu-particles.js @@ -1,5 +1,5 @@ // Auto-generated from gpu-particles.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -25,12 +25,9 @@ const RATE = isWebGpu ? 75_000 : 3_000; class GpuParticlesScene extends Scene { system; hud; - async load(loader) { - await loader.load(Texture, { particle: 'image/particle-light.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: CAPACITY }); + this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: CAPACITY }); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule(new RateSpawn({ rate: new Constant(RATE), diff --git a/examples/particles/gpu-particles.ts b/examples/particles/gpu-particles.ts index 76c1eb60a..a5c24acb2 100644 --- a/examples/particles/gpu-particles.ts +++ b/examples/particles/gpu-particles.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Texture, Vector } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, @@ -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('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.js b/examples/performance/backend-comparison.js index 4398c8c53..3bc5a758f 100644 --- a/examples/performance/backend-comparison.js +++ b/examples/performance/backend-comparison.js @@ -1,5 +1,5 @@ // Auto-generated from backend-comparison.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const options = { canvas: { @@ -18,13 +18,10 @@ let overlay = null; let backendType = 'webgpu'; class DemoScene extends Scene { sprites; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/backend-comparison.ts b/examples/performance/backend-comparison.ts index 4dbb1d593..b64c4e0e3 100644 --- a/examples/performance/backend-comparison.ts +++ b/examples/performance/backend-comparison.ts @@ -1,4 +1,4 @@ -import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const options = { @@ -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('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.js b/examples/performance/multi-texture-stress.js index 85e4f5643..680216914 100644 --- a/examples/performance/multi-texture-stress.js +++ b/examples/performance/multi-texture-stress.js @@ -69,9 +69,6 @@ class MultiTextureStressScene extends Scene { context.backend.clear(); context.render(this.spriteLayer); } - unload() { - this.spriteLayer?.destroy(); - } destroy() { this.spriteLayer?.destroy(); } 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.js b/examples/performance/particle-stress.js index d93f31ccf..a40496a56 100644 --- a/examples/performance/particle-stress.js +++ b/examples/performance/particle-stress.js @@ -1,5 +1,5 @@ // Auto-generated from particle-stress.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Texture, } from '@codexo/exojs'; +import { Application, Color, Scene, Texture } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, Constant, Curve, particlesExtension, ParticleSystem, Range, RateSpawn, ScaleOverLifetime, UpdateModule, VectorRange, } from '@codexo/exojs-particles'; const app = new Application({ canvas: { @@ -126,9 +126,6 @@ class ParticleStressScene extends Scene { context.render(entry.instance); } } - unload() { - this.destroySystems(); - } destroy() { this.destroySystems(); } diff --git a/examples/performance/particle-stress.ts b/examples/performance/particle-stress.ts index f848815fa..08a8d6269 100644 --- a/examples/performance/particle-stress.ts +++ b/examples/performance/particle-stress.ts @@ -1,9 +1,4 @@ -import { - Application, - Color, - Scene, - Texture, -} from '@codexo/exojs'; +import { Application, Color, Scene, Texture } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, @@ -178,10 +173,6 @@ class ParticleStressScene extends Scene { } } - override unload(): void { - this.destroySystems(); - } - override destroy(): void { this.destroySystems(); } diff --git a/examples/performance/sprite-stress.js b/examples/performance/sprite-stress.js index 0a941b896..03efd62bd 100644 --- a/examples/performance/sprite-stress.js +++ b/examples/performance/sprite-stress.js @@ -68,9 +68,6 @@ class SpriteStressScene extends Scene { context.backend.clear(); context.render(this.spriteLayer); } - unload() { - this.spriteLayer?.destroy(); - } destroy() { this.spriteLayer?.destroy(); } 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(); } diff --git a/examples/physics/sprite-follows-body.js b/examples/physics/sprite-follows-body.js index 173856055..fb4932ae2 100644 --- a/examples/physics/sprite-follows-body.js +++ b/examples/physics/sprite-follows-body.js @@ -1,5 +1,5 @@ // Auto-generated from sprite-follows-body.ts — edit the .ts source, not this file. -import { Application, Color, Json, Scene, Sprite, Spritesheet, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Spritesheet, Vector } from '@codexo/exojs'; import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; import { mountControls } from '@examples/runtime'; // The minimal physics binding: `world.attach(node, { ... })` builds a body + @@ -23,25 +23,18 @@ class SpriteFollowsBodyScene extends Scene { floorY = 0; settled = 0; hud; - async load(loader) { - 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 }); - } - init(loader) { + async init() { 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); + const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); this.floorY = height - 80; // ── Static floor ────────────────────────────────────────────────── // A wide static body. `world.attach` binds it to the floor sprite, so // the sprite is positioned from the body (no manual placement needed). 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(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/sprite-follows-body.ts b/examples/physics/sprite-follows-body.ts index 31f94f609..475698d2f 100644 --- a/examples/physics/sprite-follows-body.ts +++ b/examples/physics/sprite-follows-body.ts @@ -1,4 +1,4 @@ -import { Application, Color, Json, Scene, Sprite, Spritesheet, type SpritesheetData, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Spritesheet, type SpritesheetData, Vector } from '@codexo/exojs'; import { BoxShape, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; import { mountControls } from '@examples/runtime'; @@ -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(assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Asset.kind('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(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.js b/examples/physics/tiled-map-physics-actor.js index 3ba581765..5b7ce289f 100644 --- a/examples/physics/tiled-map-physics-actor.js +++ b/examples/physics/tiled-map-physics-actor.js @@ -1,5 +1,5 @@ // Auto-generated from tiled-map-physics-actor.ts — edit the .ts source, not this file. -import { Application, Color, Json, Scene, Spritesheet, Texture, TextureRegion, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Spritesheet, TextureRegion, Vector } from '@codexo/exojs'; import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; import { ObjectKind, ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; @@ -40,19 +40,12 @@ class TiledMapPhysicsActorScene extends Scene { actorBody; debug; hud; - async load(loader) { - 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 }); - } - init(loader) { + async init() { 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(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 }), @@ -115,7 +108,7 @@ class TiledMapPhysicsActorScene extends Scene { }); } // ── Dynamic actor ───────────────────────────────────────────────── - const characters = new Spritesheet(loader.get(Texture, 'characters'), loader.get(Json, 'characters').value); + const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); this.actor = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.actorBody = this.world.attach(this.actor, { type: 'dynamic', diff --git a/examples/physics/tiled-map-physics-actor.ts b/examples/physics/tiled-map-physics-actor.ts index 007b46f0b..9e69c60c9 100644 --- a/examples/physics/tiled-map-physics-actor.ts +++ b/examples/physics/tiled-map-physics-actor.ts @@ -1,4 +1,4 @@ -import { Application, Color, Json, Scene, Sprite, Spritesheet, type SpritesheetData, Texture, TextureRegion, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Spritesheet, type SpritesheetData, TextureRegion, Vector } from '@codexo/exojs'; import { BoxShape, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; import { ObjectKind, ObjectLayer, type RectangleObject, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; @@ -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(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(assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Asset.kind('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/render-targets/bloom-lite.js b/examples/render-targets/bloom-lite.js index 558c0ba52..0ca07f545 100644 --- a/examples/render-targets/bloom-lite.js +++ b/examples/render-targets/bloom-lite.js @@ -1,5 +1,5 @@ // Auto-generated from bloom-lite.ts — edit the .ts source, not this file. -import { Application, BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -22,15 +22,12 @@ class BloomLiteScene extends Scene { blur; pipeline; time = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/bloom-lite.ts b/examples/render-targets/bloom-lite.ts index 124e191fc..33cd709ae 100644 --- a/examples/render-targets/bloom-lite.ts +++ b/examples/render-targets/bloom-lite.ts @@ -1,4 +1,4 @@ -import { Application, BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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/post-processing-chain.js b/examples/render-targets/post-processing-chain.js index 09ad10368..8230ae8d8 100644 --- a/examples/render-targets/post-processing-chain.js +++ b/examples/render-targets/post-processing-chain.js @@ -1,5 +1,5 @@ // Auto-generated from post-processing-chain.ts — edit the .ts source, not this file. -import { Application, BlurFilter, CallbackRenderPass, Color, ColorFilter, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, } from '@codexo/exojs'; +import { Application, BlurFilter, CallbackRenderPass, Color, ColorFilter, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, diff --git a/examples/render-targets/post-processing-chain.ts b/examples/render-targets/post-processing-chain.ts index 69b91fa9a..bb249032d 100644 --- a/examples/render-targets/post-processing-chain.ts +++ b/examples/render-targets/post-processing-chain.ts @@ -1,16 +1,4 @@ -import { - Application, - BlurFilter, - CallbackRenderPass, - Color, - ColorFilter, - Graphics, - RenderNodePass, - RenderPipeline, - RenderTexture, - Scene, - Sprite, -} from '@codexo/exojs'; +import { Application, BlurFilter, CallbackRenderPass, Color, ColorFilter, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { diff --git a/examples/render-targets/render-pipeline.js b/examples/render-targets/render-pipeline.js index 830633c5f..4858f8f0d 100644 --- a/examples/render-targets/render-pipeline.js +++ b/examples/render-targets/render-pipeline.js @@ -1,5 +1,5 @@ // Auto-generated from render-pipeline.ts — edit the .ts source, not this file. -import { Application, BlurFilter, CallbackRenderPass, Color, Container, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, } from '@codexo/exojs'; +import { Application, BlurFilter, CallbackRenderPass, Color, Container, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, diff --git a/examples/render-targets/render-pipeline.ts b/examples/render-targets/render-pipeline.ts index 43f27448f..e57af9281 100644 --- a/examples/render-targets/render-pipeline.ts +++ b/examples/render-targets/render-pipeline.ts @@ -1,16 +1,4 @@ -import { - Application, - BlurFilter, - CallbackRenderPass, - Color, - Container, - Graphics, - RenderNodePass, - RenderPipeline, - RenderTexture, - Scene, - Sprite, -} from '@codexo/exojs'; +import { Application, BlurFilter, CallbackRenderPass, Color, Container, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { diff --git a/examples/render-targets/render-to-texture.js b/examples/render-targets/render-to-texture.js index 90689686e..89e1fa302 100644 --- a/examples/render-targets/render-to-texture.js +++ b/examples/render-targets/render-to-texture.js @@ -1,5 +1,5 @@ // Auto-generated from render-to-texture.ts — edit the .ts source, not this file. -import { Application, Color, Container, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Container, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -16,12 +16,9 @@ class RenderToTextureScene extends Scene { container; renderTexture; renderSprite; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.container = this.createBunnyContainer(loader.get(Texture, 'bunny')); + this.container = this.createBunnyContainer(this.loader.get('image/ship-a.png')); this.renderTexture = this.createRenderTexture(this.container); this.renderSprite = new Sprite(this.renderTexture); this.renderSprite.setPosition(width, height); diff --git a/examples/render-targets/render-to-texture.ts b/examples/render-targets/render-to-texture.ts index d216b3df7..c23e619fe 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('image/ship-a.png')); this.renderTexture = this.createRenderTexture(this.container); diff --git a/examples/render-targets/trail-feedback.js b/examples/render-targets/trail-feedback.js index bed6d9374..87d6f94e0 100644 --- a/examples/render-targets/trail-feedback.js +++ b/examples/render-targets/trail-feedback.js @@ -1,5 +1,5 @@ // Auto-generated from trail-feedback.ts — edit the .ts source, not this file. -import { Application, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -24,14 +24,11 @@ class TrailFeedbackScene extends Scene { pipeBtoA; forward = true; time = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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)); const decayB = new Sprite(this.rtB).setTint(new Color(255, 255, 255, 0.93)); diff --git a/examples/render-targets/trail-feedback.ts b/examples/render-targets/trail-feedback.ts index 0e95a3cbb..94b2c5493 100644 --- a/examples/render-targets/trail-feedback.ts +++ b/examples/render-targets/trail-feedback.ts @@ -1,4 +1,4 @@ -import { Application, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/render-targets/water-mirror.js index 233852f3d..e77e0d054 100644 --- a/examples/render-targets/water-mirror.js +++ b/examples/render-targets/water-mirror.js @@ -1,5 +1,5 @@ // Auto-generated from water-mirror.ts — edit the .ts source, not this file. -import { Application, CallbackRenderPass, Color, RenderBackendType, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture, WebGl2ShaderFilter, WebGpuShaderFilter, } from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, RenderBackendType, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -31,14 +31,11 @@ class WaterMirrorScene extends Scene { filter; pipeline; time = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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 = diff --git a/examples/render-targets/water-mirror.ts b/examples/render-targets/water-mirror.ts index da0547f23..3cd56a5ca 100644 --- a/examples/render-targets/water-mirror.ts +++ b/examples/render-targets/water-mirror.ts @@ -1,17 +1,4 @@ -import { - Application, - CallbackRenderPass, - Color, - RenderBackendType, - RenderNodePass, - RenderPipeline, - RenderTexture, - Scene, - Sprite, - Texture, - WebGl2ShaderFilter, - WebGpuShaderFilter, -} from '@codexo/exojs'; +import { Application, CallbackRenderPass, Color, RenderBackendType, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -47,16 +34,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('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 = diff --git a/examples/scene-graph/containers.js b/examples/scene-graph/containers.js index 1b4edc519..291c094a6 100644 --- a/examples/scene-graph/containers.js +++ b/examples/scene-graph/containers.js @@ -1,5 +1,5 @@ // Auto-generated from containers.ts — edit the .ts source, not this file. -import { Application, Color, Container, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Container, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,21 +15,16 @@ const app = new Application({ class ContainersScene extends Scene { rainbow; bunnies; - async load(loader) { - await loader.load(Texture, { - bunny: 'image/ship-a.png', - rainbow: 'image/hue-ramp.png', - }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.rainbow = new Sprite(loader.get(Texture, 'rainbow')); + const { bunny, rainbow } = this.loader.get(Assets.from({ bunny: Asset.kind('texture', 'image/ship-a.png'), rainbow: Asset.kind('texture', 'image/hue-ramp.png') })); + 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')); - bunny.setPosition((i % 5) * (bunny.width + 15), ((i / 5) | 0) * (bunny.height + 10)); - this.bunnies.addChild(bunny); + const sprite = new Sprite(bunny); + sprite.setPosition((i % 5) * (sprite.width + 15), ((i / 5) | 0) * (sprite.height + 10)); + this.bunnies.addChild(sprite); } this.bunnies.setAnchor(0.5); } diff --git a/examples/scene-graph/containers.ts b/examples/scene-graph/containers.ts index f205cf1c8..b46c69367 100644 --- a/examples/scene-graph/containers.ts +++ b/examples/scene-graph/containers.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Container, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(Assets.from({ bunny: Asset.kind('texture', 'image/ship-a.png'), rainbow: Asset.kind('texture', '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.js b/examples/scene-graph/local-vs-global-transform.js index d4cff6139..a349b3361 100644 --- a/examples/scene-graph/local-vs-global-transform.js +++ b/examples/scene-graph/local-vs-global-transform.js @@ -1,5 +1,5 @@ // Auto-generated from local-vs-global-transform.ts — edit the .ts source, not this file. -import { Application, Color, Container, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -18,12 +18,9 @@ class LocalVsGlobalTransformScene extends Scene { globalSprite; localLabel; globalLabel; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'bunny'); + const texture = this.loader.get('image/ship-a.png'); this.parent = new Container().setPosition(width / 4, height / 2); this.localSprite = new Sprite(texture) .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..1a1595bbd 100644 --- a/examples/scene-graph/local-vs-global-transform.ts +++ b/examples/scene-graph/local-vs-global-transform.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/scene-graph/masks.js index 49bc332f6..2c7b8ff26 100644 --- a/examples/scene-graph/masks.js +++ b/examples/scene-graph/masks.js @@ -1,5 +1,5 @@ // Auto-generated from masks.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Rectangle, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Rectangle, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,12 +15,9 @@ class MasksScene extends Scene { rectMask; gfxSprite; time = 0; - async load(loader) { - await loader.load(Texture, { alphaRings: ALPHA_RINGS }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - const tex = loader.get(Texture, 'alphaRings'); + const tex = this.loader.get(ALPHA_RINGS); this.rectSprite = new Sprite(tex); this.rectSprite.setScale(1); this.rectSprite.setPosition((width / 4) | 0, (height / 2) | 0); diff --git a/examples/scene-graph/masks.ts b/examples/scene-graph/masks.ts index 7e219346b..03a0fa58e 100644 --- a/examples/scene-graph/masks.ts +++ b/examples/scene-graph/masks.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Rectangle, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Rectangle, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(ALPHA_RINGS); this.rectSprite = new Sprite(tex); this.rectSprite.setScale(1); diff --git a/examples/scene-graph/pivot-and-anchor.js b/examples/scene-graph/pivot-and-anchor.js index 2d9ebf9ed..4c27f1ce8 100644 --- a/examples/scene-graph/pivot-and-anchor.js +++ b/examples/scene-graph/pivot-and-anchor.js @@ -1,5 +1,5 @@ // Auto-generated from pivot-and-anchor.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -23,12 +23,9 @@ class PivotAndAnchorScene extends Scene { label; mode = 0; timer = 0; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/pivot-and-anchor.ts b/examples/scene-graph/pivot-and-anchor.ts index 41f2ab8e8..8e4165f25 100644 --- a/examples/scene-graph/pivot-and-anchor.ts +++ b/examples/scene-graph/pivot-and-anchor.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/scene-graph/z-ordering.js index 3dbef9a71..5821e3d62 100644 --- a/examples/scene-graph/z-ordering.js +++ b/examples/scene-graph/z-ordering.js @@ -1,5 +1,5 @@ // Auto-generated from z-ordering.ts — edit the .ts source, not this file. -import { Application, Color, Container, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -16,18 +16,16 @@ class ZOrderingScene extends Scene { group; label; sprites; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; + const texture = this.loader.get('image/ship-a.png'); this.group = new Container(); this.label = new Text('Press 1, 2, 3 — front: 3 (blue)', { fillColor: Color.white, fontSize: 18 }); this.label.setPosition(18, 18); // 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/scene-graph/z-ordering.ts b/examples/scene-graph/z-ordering.ts index 5cee41f59..60f5d099d 100644 --- a/examples/scene-graph/z-ordering.ts +++ b/examples/scene-graph/z-ordering.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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/showcase/audio-reactive-particles.js b/examples/showcase/audio-reactive-particles.js index 8f38eb699..2e1450e98 100644 --- a/examples/showcase/audio-reactive-particles.js +++ b/examples/showcase/audio-reactive-particles.js @@ -1,5 +1,5 @@ // Auto-generated from audio-reactive-particles.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Scene, Text, Texture, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text, Vector } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { AlphaFadeOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; @@ -25,19 +25,15 @@ class AudioReactiveParticlesScene extends Scene { cone; hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - await loader.load(Texture, { particle: assets.demo.textures.particleLight }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(Asset.kind('music', assets.demo.audio.musicLoop)); // Two parallel taps of the same track: the analyser gives per-band // energy (drives emission), the detector gives beats (recolours). this.analyser = new AudioAnalyser({ fftSize: 1024, source: this.app.audio.music }); 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(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 // frame from live audio energy. Starting both near zero means a silent diff --git a/examples/showcase/audio-reactive-particles.ts b/examples/showcase/audio-reactive-particles.ts index 0f5ef1b0b..a29abf0b5 100644 --- a/examples/showcase/audio-reactive-particles.ts +++ b/examples/showcase/audio-reactive-particles.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Scene, Text, Texture, Vector, type Voice } from '@codexo/exojs'; +import { Application, Asset, AudioStream, Color, Scene, Text, Vector, type Voice } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { AlphaFadeOverLifetime, @@ -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(Asset.kind('music', 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(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.js b/examples/showcase/audio-visualisation.js index bdfe6e835..1c5c6c070 100644 --- a/examples/showcase/audio-visualisation.js +++ b/examples/showcase/audio-visualisation.js @@ -1,5 +1,5 @@ // Auto-generated from audio-visualisation.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Text, Texture } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -24,12 +24,9 @@ class AudioVisualisationScene extends Scene { screen; hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { example: assets.demo.audio.musicLoop }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'example'); + this.music = this.loader.get(Asset.kind('music', 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, // without altering playback. diff --git a/examples/showcase/audio-visualisation.ts b/examples/showcase/audio-visualisation.ts index 1d28ee3c4..d65f7df1a 100644 --- a/examples/showcase/audio-visualisation.ts +++ b/examples/showcase/audio-visualisation.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, type Pausable, Scene, type Seekable, Sprite, Text, Texture, type Voice } from '@codexo/exojs'; +import { Application, Asset, AudioStream, Color, type Pausable, Scene, type Seekable, Sprite, Text, Texture, type Voice } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Asset.kind('music', 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.js b/examples/showcase/boss-intro-cinematic.js index 4af6a87fe..8b44f1278 100644 --- a/examples/showcase/boss-intro-cinematic.js +++ b/examples/showcase/boss-intro-cinematic.js @@ -1,5 +1,5 @@ // Auto-generated from boss-intro-cinematic.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Keyboard, Scene, Sprite, Text, Texture, View } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Keyboard, Scene, Sprite, Text, View } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -25,11 +25,7 @@ class BossIntroCinematicScene extends Scene { tapPrompt; width = 0; height = 0; - async load(loader) { - await loader.load(Texture, { boss: assets.demo.textures.shipA }); - await loader.load(AudioStream, { track: assets.demo.music.loopMain }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; this.width = width; this.height = height; @@ -41,12 +37,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(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(Asset.kind('music', assets.demo.music.loopMain)); this.hud = mountControls({ title: 'Boss Intro Cinematic', controls: [ diff --git a/examples/showcase/boss-intro-cinematic.ts b/examples/showcase/boss-intro-cinematic.ts index b4a7af92c..6070e7154 100644 --- a/examples/showcase/boss-intro-cinematic.ts +++ b/examples/showcase/boss-intro-cinematic.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Keyboard, type Pausable, Scene, type Seekable, Sprite, Text, Texture, View, type Voice } from '@codexo/exojs'; +import { Application, Asset, AudioStream, Color, Graphics, Keyboard, type Pausable, Scene, type Seekable, Sprite, Text, View, type Voice } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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(Asset.kind('music', assets.demo.music.loopMain)); this.hud = mountControls({ title: 'Boss Intro Cinematic', diff --git a/examples/showcase/color-grading.js b/examples/showcase/color-grading.js index 052e29be4..be7aa6973 100644 --- a/examples/showcase/color-grading.js +++ b/examples/showcase/color-grading.js @@ -1,5 +1,5 @@ // Auto-generated from color-grading.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, LutFilter, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Keyboard, LutFilter, Scene, Sprite } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -76,14 +76,11 @@ class ColorGradingScene extends Scene { sprite; hud; cycle; - async load(loader) { - await loader.load(Texture, { ramp: PRIMARY_RAMP }); - } - init(loader) { + init() { 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(PRIMARY_RAMP)).setAnchor(0.5).setScale(3.5); this.sprite.setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ diff --git a/examples/showcase/color-grading.ts b/examples/showcase/color-grading.ts index e09895c01..5c10ee736 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(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.js b/examples/showcase/damage-flash.js index ab8e3437e..309fcc3ab 100644 --- a/examples/showcase/damage-flash.js +++ b/examples/showcase/damage-flash.js @@ -1,5 +1,5 @@ // Auto-generated from damage-flash.ts — edit the .ts source, not this file. -import { Application, Color, ColorFilter, Scene, Signal, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, ColorFilter, Scene, Signal, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -20,13 +20,10 @@ class DamageFlashScene extends Scene { filter; hud; hits = 0; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/damage-flash.ts b/examples/showcase/damage-flash.ts index ed828fa5a..8cd1a7849 100644 --- a/examples/showcase/damage-flash.ts +++ b/examples/showcase/damage-flash.ts @@ -1,4 +1,4 @@ -import { Application, Color, ColorFilter, Scene, Signal, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, ColorFilter, Scene, Signal, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('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.js b/examples/showcase/dialog-system.js index 4139c9af5..385880d0b 100644 --- a/examples/showcase/dialog-system.js +++ b/examples/showcase/dialog-system.js @@ -1,5 +1,5 @@ // Auto-generated from dialog-system.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sound, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -29,15 +29,11 @@ class DialogSystemScene extends Scene { timer = 0; done = false; awaitingChoice = false; - async load(loader) { - await loader.load(Texture, { portrait: assets.demo.textures.shipA }); - await loader.load(Sound, { beep: assets.demo.sound.uiConfirm }); - } - init(loader) { + init() { 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(assets.demo.textures.shipA)).setAnchor(0.5).setScale(2.4).setPosition(width * 0.16, height * 0.62); const textX = width * 0.3; // Name plate sits just above the dialog body, like a classic VN UI. this.namePlate = new Text(lines[0].speaker, { fillColor: new Color(255, 214, 120), fontSize: 26, fontWeight: 'bold' }); @@ -46,7 +42,7 @@ class DialogSystemScene extends Scene { this.box.setPosition(textX, height * 0.56); 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', controls: [{ keys: 'Click', action: 'advance / reveal' }], diff --git a/examples/showcase/dialog-system.ts b/examples/showcase/dialog-system.ts index 102fef56a..4cd4da7b1 100644 --- a/examples/showcase/dialog-system.ts +++ b/examples/showcase/dialog-system.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sound, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sound, Sprite, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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(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.js b/examples/showcase/gamepad-spaceship.js index a6ca5f053..b03c33d9c 100644 --- a/examples/showcase/gamepad-spaceship.js +++ b/examples/showcase/gamepad-spaceship.js @@ -1,5 +1,5 @@ // Auto-generated from gamepad-spaceship.ts — edit the .ts source, not this file. -import { Application, AudioGenerator, Color, GamepadAxis, GamepadButton, Graphics, Scene, Sprite, Text, Texture, Vector } from '@codexo/exojs'; +import { Application, AudioGenerator, Color, GamepadAxis, GamepadButton, Graphics, Scene, Sprite, Text, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem, } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -28,15 +28,12 @@ class GamepadSpaceshipScene extends Scene { hasPad = false; connectPrompt; hud; - async load(loader) { - await loader.load(Texture, { ship: assets.demo.textures.shipA, particle: assets.demo.textures.particleSpark }); - } - init(loader) { + init() { 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(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(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/gamepad-spaceship.ts b/examples/showcase/gamepad-spaceship.ts index f6ade216a..3c33d1d51 100644 --- a/examples/showcase/gamepad-spaceship.ts +++ b/examples/showcase/gamepad-spaceship.ts @@ -1,4 +1,4 @@ -import { Application, AudioGenerator, Color, GamepadAxis, GamepadButton, Graphics, Scene, Sprite, Text, Texture, Vector, type Voice } from '@codexo/exojs'; +import { Application, AudioGenerator, Color, GamepadAxis, GamepadButton, Graphics, Scene, Sprite, Text, Vector, type Voice } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, @@ -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(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(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.js b/examples/showcase/loading-progress-with-shader.js index f7bb6b97a..24c3f1d45 100644 --- a/examples/showcase/loading-progress-with-shader.js +++ b/examples/showcase/loading-progress-with-shader.js @@ -1,5 +1,5 @@ // Auto-generated from loading-progress-with-shader.ts — edit the .ts source, not this file. -import { Application, Color, RenderBackendType, Scene, Sprite, Text, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, Text, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -31,15 +31,12 @@ class LoadingProgressWithShaderScene extends Scene { label; ring; filter; - async load(loader) { - await loader.load(Texture, { uvGrid: 'image/uv-grid-256.png' }); - } - init(loader) { + init() { 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('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/loading-progress-with-shader.ts b/examples/showcase/loading-progress-with-shader.ts index 99fa0b794..961aa9c1d 100644 --- a/examples/showcase/loading-progress-with-shader.ts +++ b/examples/showcase/loading-progress-with-shader.ts @@ -1,4 +1,4 @@ -import { Application, Color, RenderBackendType, Scene, Sprite, Text, Texture, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; +import { Application, Color, RenderBackendType, Scene, Sprite, Text, WebGl2ShaderFilter, WebGpuShaderFilter } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/showcase/low-band-camera-shake.js index 1715ea286..9aa219d18 100644 --- a/examples/showcase/low-band-camera-shake.js +++ b/examples/showcase/low-band-camera-shake.js @@ -1,5 +1,5 @@ // Auto-generated from low-band-camera-shake.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Scene, Sprite, Text, Texture, View } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Text, View } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -19,16 +19,12 @@ class LowBandCameraShakeScene extends Scene { sprite; hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - await loader.load(Texture, { ship: assets.demo.textures.shipA }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(Asset.kind('music', 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(assets.demo.textures.shipA)).setAnchor(0.5).setScale(3).setPosition(width / 2, height / 2); this.hud = mountControls({ title: 'Low Band Camera Shake', controls: [{ keys: 'Audio', action: 'low-band energy → shake' }], diff --git a/examples/showcase/low-band-camera-shake.ts b/examples/showcase/low-band-camera-shake.ts index 20527236b..6b414d107 100644 --- a/examples/showcase/low-band-camera-shake.ts +++ b/examples/showcase/low-band-camera-shake.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Scene, Sprite, Text, Texture, View, type Voice } from '@codexo/exojs'; +import { Application, Asset, AudioStream, Color, Scene, Sprite, Text, View, type Voice } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Asset.kind('music', 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(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.js b/examples/showcase/pause-blur.js index 307d236bd..7c865c824 100644 --- a/examples/showcase/pause-blur.js +++ b/examples/showcase/pause-blur.js @@ -1,5 +1,5 @@ // Auto-generated from pause-blur.ts — edit the .ts source, not this file. -import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -28,12 +28,9 @@ class GameScene extends Scene { pausePanel; pauseLabel; hud; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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. this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) }); diff --git a/examples/showcase/pause-blur.ts b/examples/showcase/pause-blur.ts index 34de52463..78c2f273b 100644 --- a/examples/showcase/pause-blur.ts +++ b/examples/showcase/pause-blur.ts @@ -1,4 +1,4 @@ -import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('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.js b/examples/showcase/rectangles-collision.js index 937a537cd..1594b8d33 100644 --- a/examples/showcase/rectangles-collision.js +++ b/examples/showcase/rectangles-collision.js @@ -1,5 +1,5 @@ // Auto-generated from rectangles-collision.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -20,12 +20,9 @@ class RectanglesCollisionScene extends Scene { boxB; overlap; hud; - async load(loader) { - await loader.load(Texture, { gradient: 'image/hue-ramp.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'gradient'); + const texture = this.loader.get('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 // drag offset and the bounds centred on the pointer-grabbed point. diff --git a/examples/showcase/rectangles-collision.ts b/examples/showcase/rectangles-collision.ts index d3a00419e..674dd284f 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('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.js b/examples/showcase/screen-shake-on-explosion.js index 49f2f593d..0d7fba9e1 100644 --- a/examples/showcase/screen-shake-on-explosion.js +++ b/examples/showcase/screen-shake-on-explosion.js @@ -1,5 +1,5 @@ // Auto-generated from screen-shake-on-explosion.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Texture, Vector, View } from '@codexo/exojs'; +import { Application, Color, Scene, Vector, View } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem, } from '@codexo/exojs-particles'; const app = new Application({ canvas: { @@ -19,13 +19,10 @@ class ScreenShakeOnExplosionScene extends Scene { ps; burstPos; burst; - async load(loader) { - await loader.load(Texture, { particle: 'image/particle-light.png' }); - } - init(loader) { + init() { 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('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/screen-shake-on-explosion.ts b/examples/showcase/screen-shake-on-explosion.ts index 2affa4292..aeb269819 100644 --- a/examples/showcase/screen-shake-on-explosion.ts +++ b/examples/showcase/screen-shake-on-explosion.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Texture, Vector, View } from '@codexo/exojs'; +import { Application, Color, Scene, Vector, View } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, @@ -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('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.js b/examples/showcase/typewriter-text.js index 482a5494d..75e081d44 100644 --- a/examples/showcase/typewriter-text.js +++ b/examples/showcase/typewriter-text.js @@ -1,5 +1,5 @@ // Auto-generated from typewriter-text.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, Scene, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -19,12 +19,9 @@ class TypewriterTextScene extends Scene { state; last = 0; tapPrompt; - async load(loader) { - await loader.load(Sound, { tick: 'audio/ui-click.ogg' }); - } - init(loader) { + init() { 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/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.js b/examples/showcase/vinyl-record.js index 59d596b43..1418eb4f6 100644 --- a/examples/showcase/vinyl-record.js +++ b/examples/showcase/vinyl-record.js @@ -1,5 +1,5 @@ // Auto-generated from vinyl-record.ts — edit the .ts source, not this file. -import { Application, AudioStream, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Text } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -21,12 +21,9 @@ class VinylRecordScene extends Scene { rpm = 0; hud; tapPrompt; - async load(loader) { - await loader.load(AudioStream, { track: assets.demo.audio.musicLoop }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); + this.music = this.loader.get(Asset.kind('music', assets.demo.audio.musicLoop)); this.analyser = new AudioAnalyser({ fftSize: 1024, source: this.app.audio.music }); this.disc = new Graphics(); this.bars = new Graphics(); diff --git a/examples/showcase/vinyl-record.ts b/examples/showcase/vinyl-record.ts index 81d93a48c..35a012c0f 100644 --- a/examples/showcase/vinyl-record.ts +++ b/examples/showcase/vinyl-record.ts @@ -1,4 +1,4 @@ -import { Application, AudioStream, Color, Graphics, Scene, Text, type Voice } from '@codexo/exojs'; +import { Application, Asset, AudioStream, Color, Graphics, Scene, Text, type Voice } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; @@ -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(Asset.kind('music', assets.demo.audio.musicLoop)); this.analyser = new AudioAnalyser({ fftSize: 1024, source: this.app.audio.music }); this.disc = new Graphics(); this.bars = new Graphics(); diff --git a/examples/spatial-audio/falloff-curves.js b/examples/spatial-audio/falloff-curves.js index 31da3c99f..ade0c8044 100644 --- a/examples/spatial-audio/falloff-curves.js +++ b/examples/spatial-audio/falloff-curves.js @@ -1,5 +1,5 @@ // Auto-generated from falloff-curves.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -44,10 +44,7 @@ class FalloffCurvesScene extends Scene { // Canvas-relative plot geometry computed in init(). plot = { x: 0, y: 0, w: 0, h: 0 }; hud; - async load(loader) { - await loader.load(Sound, { source: 'audio/impact-light.ogg' }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; // Sources spread across the lower half; the listener starts centred. const sourceY = height * 0.72; @@ -55,8 +52,12 @@ class FalloffCurvesScene extends Scene { this.plot = { x: width * 0.06, y: height * 0.16, w: width * 0.88, h: height * 0.18 }; 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/impact-light.ogg')); 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/falloff-curves.ts b/examples/spatial-audio/falloff-curves.ts index bcc76661d..d996f7be9 100644 --- a/examples/spatial-audio/falloff-curves.ts +++ b/examples/spatial-audio/falloff-curves.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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,12 @@ 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/impact-light.ogg')); 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.js b/examples/spatial-audio/listener-and-source.js index 824c4eb5c..26fca5ec1 100644 --- a/examples/spatial-audio/listener-and-source.js +++ b/examples/spatial-audio/listener-and-source.js @@ -1,5 +1,5 @@ // Auto-generated from listener-and-source.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -36,14 +36,14 @@ class ListenerAndSourceScene extends Scene { label; tapPrompt; hud; - async load(loader) { - // 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' }); - } - init(loader) { + async init() { 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/demo-loop-main.ogg')); + this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', 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..fa043a682 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import type { Spatializable, Voice } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; @@ -40,16 +40,15 @@ 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/demo-loop-main.ogg')); + this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, diff --git a/examples/spatial-audio/moving-source.js b/examples/spatial-audio/moving-source.js index 538b52f61..871fd425e 100644 --- a/examples/spatial-audio/moving-source.js +++ b/examples/spatial-audio/moving-source.js @@ -1,5 +1,5 @@ // Auto-generated from moving-source.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -34,14 +34,14 @@ class MovingSourceScene extends Scene { label; tapPrompt; hud; - async load(loader) { - // 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' }); - } - init(loader) { + async init() { 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/demo-loop-main.ogg')); + 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..660cda8d2 100644 --- a/examples/spatial-audio/moving-source.ts +++ b/examples/spatial-audio/moving-source.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs'; import type { Spatializable, Voice } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; @@ -38,16 +38,15 @@ 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). + const source = await this.loader.load(Asset.kind('sound', 'audio/demo-loop-main.ogg')); + this.sound = new Sound(source.audioBuffer, { distanceModel: 'linear', refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, diff --git a/examples/sprites-textures/blendmodes.js b/examples/sprites-textures/blendmodes.js index 3ff90dddd..5533df7a6 100644 --- a/examples/sprites-textures/blendmodes.js +++ b/examples/sprites-textures/blendmodes.js @@ -1,5 +1,5 @@ // Auto-generated from blendmodes.ts — edit the .ts source, not this file. -import { Application, BlendModes, Color, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Asset, BlendModes, Color, ScaleModes, Scene, Sprite } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -43,28 +43,32 @@ class BlendmodesScene extends Scene { ticker = 0; hud; cycle; - async load(loader) { - await loader.load(Texture, { - background: ALPHA_RINGS, - ship: assets.demo.textures.shipA, - }, { - scaleMode: ScaleModes.Nearest, - }); - } - init(loader) { + // Note: passing `options` as a 3rd argument to `loader.get(…)` or + // `loader.load(Asset.kind('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. + async init() { const { width, height } = this.app.canvas; - this.background = new Sprite(loader.get(Texture, 'background')); + const samplerOptions = { scaleMode: ScaleModes.Nearest }; + await this.loader.load(Asset.kind('texture', ALPHA_RINGS, { samplerOptions })); + await this.loader.load(Asset.kind('texture', assets.demo.textures.shipA, { samplerOptions })); + const backgroundTexture = this.loader.get(ALPHA_RINGS); + const shipTexture = this.loader.get(assets.demo.textures.shipA); + 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); this.background.setTint(new Color(120, 130, 150)); // 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/blendmodes.ts b/examples/sprites-textures/blendmodes.ts index 8bd6c739e..82e1043c1 100644 --- a/examples/sprites-textures/blendmodes.ts +++ b/examples/sprites-textures/blendmodes.ts @@ -1,4 +1,4 @@ -import { Application, BlendModes, Color, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Asset, BlendModes, Color, ScaleModes, Scene, Sprite } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -47,23 +47,24 @@ class BlendmodesScene extends Scene { private hud!: ReturnType; private cycle!: { set(value: number): void }; - override async load(loader): Promise { - await loader.load( - Texture, - { - background: ALPHA_RINGS, - ship: assets.demo.textures.shipA, - }, - { - scaleMode: ScaleModes.Nearest, - }, - ); - } - - override init(loader): void { + // Note: passing `options` as a 3rd argument to `loader.get(…)` or + // `loader.load(Asset.kind('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; - this.background = new Sprite(loader.get(Texture, 'background')); + const samplerOptions = { scaleMode: ScaleModes.Nearest }; + await this.loader.load(Asset.kind('texture', ALPHA_RINGS, { samplerOptions })); + await this.loader.load(Asset.kind('texture', assets.demo.textures.shipA, { samplerOptions })); + + const backgroundTexture = this.loader.get(ALPHA_RINGS); + const shipTexture = this.loader.get(assets.demo.textures.shipA); + + 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 +72,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.js b/examples/sprites-textures/sprite-basics.js index c9c6b6f92..07e8e8126 100644 --- a/examples/sprites-textures/sprite-basics.js +++ b/examples/sprites-textures/sprite-basics.js @@ -1,5 +1,5 @@ // Auto-generated from sprite-basics.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -19,12 +19,9 @@ class SpriteBasicsScene extends Scene { tint = new Color(120, 200, 255, 1); elapsed = 0; hud; - async load(loader) { - await loader.load(Texture, { ship: 'image/ship-a.png' }); - } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.ship = new Sprite(loader.get(Texture, 'ship')); + this.ship = new Sprite(this.loader.get('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/sprite-basics.ts b/examples/sprites-textures/sprite-basics.ts index 41196cbc2..9e1af6512 100644 --- a/examples/sprites-textures/sprite-basics.ts +++ b/examples/sprites-textures/sprite-basics.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('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.js b/examples/sprites-textures/spritesheet-frames.js index 5ec0dc76d..42071ce96 100644 --- a/examples/sprites-textures/spritesheet-frames.js +++ b/examples/sprites-textures/spritesheet-frames.js @@ -1,5 +1,5 @@ // Auto-generated from spritesheet-frames.ts — edit the .ts source, not this file. -import { Application, Color, Json, Scene, Spritesheet, Texture } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Spritesheet } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -22,14 +22,10 @@ class SpritesheetFramesScene extends Scene { elapsed = 0; playing = true; hud; - async load(loader) { - await loader.load(Texture, { characters: 'image/platformer-characters.png' }); - await loader.load(Json, { characters: 'json/platformer-characters.json' }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'characters'); - const data = loader.get(Json, 'characters').value; + const texture = this.loader.get('image/platformer-characters.png'); + const data = (await this.loader.load(Asset.kind('json', 'json/platformer-characters.json'))); this.spritesheet = new Spritesheet(texture, data); // The spritesheet caches one Sprite per named frame; configure them all // once so any frame we draw is centred and scaled up for visibility. diff --git a/examples/sprites-textures/spritesheet-frames.ts b/examples/sprites-textures/spritesheet-frames.ts index 5f6a7b685..a4de5ca23 100644 --- a/examples/sprites-textures/spritesheet-frames.ts +++ b/examples/sprites-textures/spritesheet-frames.ts @@ -1,4 +1,4 @@ -import { Application, Color, Json, Scene, Spritesheet, type SpritesheetData, Texture } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Spritesheet, type SpritesheetData } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const app = new Application({ @@ -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('image/platformer-characters.png'); + const data = (await this.loader.load(Asset.kind('json', 'json/platformer-characters.json'))) as SpritesheetData; this.spritesheet = new Spritesheet(texture, data); diff --git a/examples/sprites-textures/svg-drawable.js b/examples/sprites-textures/svg-drawable.js index 5371d53cb..fe54af65d 100644 --- a/examples/sprites-textures/svg-drawable.js +++ b/examples/sprites-textures/svg-drawable.js @@ -1,5 +1,5 @@ // Auto-generated from svg-drawable.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, SvgAsset, Texture } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, Texture } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,15 +15,22 @@ const app = new Application({ class SvgDrawableScene extends Scene { texture; sprite; - async load(loader) { + async init() { + 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 }); - } - init(loader) { - 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(Asset.kind('svg', 'svg/exo-wordmark.svg', { width: 850, height: 324 }))); + 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/svg-drawable.ts b/examples/sprites-textures/svg-drawable.ts index c9805a69e..6872295b8 100644 --- a/examples/sprites-textures/svg-drawable.ts +++ b/examples/sprites-textures/svg-drawable.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, SvgAsset, Texture } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Sprite, SvgAsset, Texture } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(Asset.kind('svg', '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.js b/examples/sprites-textures/texture-loader.js index 1475fef8c..148741e35 100644 --- a/examples/sprites-textures/texture-loader.js +++ b/examples/sprites-textures/texture-loader.js @@ -1,5 +1,5 @@ // Auto-generated from texture-loader.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Graphics, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -14,31 +14,23 @@ const app = new Application({ }); class TextureLoaderScene extends Scene { sprites; + textures; bar; label; barX = 0; barY = 0; barWidth = 0; progress = { loaded: 0, total: 3 }; - async load(loader) { - 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; - } - init(loader) { + init() { 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('image/ship-a.png'), this.loader.get('image/hue-ramp.png'), this.loader.get('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; }); // Centered progress bar in the upper third. @@ -50,6 +42,9 @@ class TextureLoaderScene extends Scene { this.label.setAnchor(0.5, 0); this.label.setPosition(width / 2, this.barY + 40); } + update() { + this.progress.loaded = this.textures.filter(texture => texture.loadState === 'ready').length; + } draw(context) { context.backend.clear(); const { loaded, total } = this.progress; diff --git a/examples/sprites-textures/texture-loader.ts b/examples/sprites-textures/texture-loader.ts index 5f4aaa7ad..3769fd60d 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('image/ship-a.png'), this.loader.get('image/hue-ramp.png'), this.loader.get('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.js b/examples/sprites-textures/video-drawable.js index f06067c84..b4eb28824 100644 --- a/examples/sprites-textures/video-drawable.js +++ b/examples/sprites-textures/video-drawable.js @@ -1,5 +1,5 @@ // Auto-generated from video-drawable.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, Scene, Sprite, Texture, Video } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; // Every video in the asset catalog, switchable at runtime with the number // keys. Only the first entry is fetched up front — the others lazy-load on @@ -24,23 +24,21 @@ class VideoDrawableScene extends Scene { overlay; elapsed = 0; hud; - assetLoader = null; videoIdx = 0; loadedVideos = new Set(); switching = false; - async load(loader) { - 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 }); - } - init(loader) { + async init() { 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()`. + const loaded = await this.loader.load(Assets.from({ [VIDEOS[0].name]: Asset.kind('video', VIDEOS[0].url) })); + this.loadedVideos.add(VIDEOS[0].name); + this.video = loaded[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(assets.demo.textures.shipA)); this.overlay.setAnchor(0.5); this.overlay.setScale(3); this.overlay.setPosition(width / 2, height / 2); @@ -80,13 +78,11 @@ class VideoDrawableScene extends Scene { this.switching = true; this.hud.setStatus(`Loading — ${entry.label}…`); try { - if (!this.loadedVideos.has(entry.name)) { - await this.assetLoader.load(Video, { [entry.name]: entry.url }); - this.loadedVideos.add(entry.name); - } + const loaded = await this.loader.load(Assets.from({ [entry.name]: Asset.kind('video', entry.url) })); + this.loadedVideos.add(entry.name); this.video.pause(); this.videoIdx = idx; - this.video = this.assetLoader.get(Video, entry.name); + this.video = loaded[entry.name]; this.configureVideo(); this.video.play(); this.hud.setStatus(`Playing — ${entry.label}`); diff --git a/examples/sprites-textures/video-drawable.ts b/examples/sprites-textures/video-drawable.ts index 5ca69e08c..b7e9c2a52 100644 --- a/examples/sprites-textures/video-drawable.ts +++ b/examples/sprites-textures/video-drawable.ts @@ -1,4 +1,4 @@ -import { Application, Color, Keyboard, Scene, Sprite, Texture, Video } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, Keyboard, Scene, Sprite, Texture, Video } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; // Every video in the asset catalog, switchable at runtime with the number @@ -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()`. + const loaded = await this.loader.load(Assets.from({ [VIDEOS[0].name]: Asset.kind('video', VIDEOS[0].url) })); + this.loadedVideos.add(VIDEOS[0].name); + + this.video = loaded[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(assets.demo.textures.shipA)); this.overlay.setAnchor(0.5); this.overlay.setScale(3); this.overlay.setPosition(width / 2, height / 2); @@ -90,13 +88,11 @@ class VideoDrawableScene extends Scene { this.switching = true; this.hud.setStatus(`Loading — ${entry.label}…`); try { - if (!this.loadedVideos.has(entry.name)) { - await this.assetLoader.load(Video, { [entry.name]: entry.url }); - this.loadedVideos.add(entry.name); - } + const loaded = await this.loader.load(Assets.from({ [entry.name]: Asset.kind('video', entry.url) })); + this.loadedVideos.add(entry.name); this.video.pause(); this.videoIdx = idx; - this.video = this.assetLoader.get(Video, entry.name); + this.video = loaded[entry.name]; this.configureVideo(); this.video.play(); this.hud.setStatus(`Playing — ${entry.label}`); diff --git a/examples/text-fonts/basic-text.js b/examples/text-fonts/basic-text.js index 59981861b..cd84ecefb 100644 --- a/examples/text-fonts/basic-text.js +++ b/examples/text-fonts/basic-text.js @@ -1,5 +1,5 @@ // Auto-generated from basic-text.ts — edit the .ts source, not this file. -import { Application, Color, FontAsset, Scene, Text, Time } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text, Time } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,10 +15,8 @@ const app = new Application({ class BasicTextScene extends Scene { time; text; - async load(loader) { - await loader.load(FontAsset, { example: 'font/Kenney Future.ttf' }, { family: 'Kenney Future' }); - } - init() { + async init() { + await this.loader.load(Asset.kind('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); const { width, height } = this.app.canvas; this.time = new Time(); this.text = new Text('Hello World!', { diff --git a/examples/text-fonts/basic-text.ts b/examples/text-fonts/basic-text.ts index ff2fe0d17..7f208b2bf 100644 --- a/examples/text-fonts/basic-text.ts +++ b/examples/text-fonts/basic-text.ts @@ -1,4 +1,4 @@ -import { Application, Color, FontAsset, Scene, Text, Time } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text, Time } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(Asset.kind('font', '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.js b/examples/text-fonts/bitmap-text-basic.js index 0fa348f70..23c9a54b9 100644 --- a/examples/text-fonts/bitmap-text-basic.js +++ b/examples/text-fonts/bitmap-text-basic.js @@ -1,5 +1,5 @@ // Auto-generated from bitmap-text-basic.ts — edit the .ts source, not this file. -import { Application, BitmapText, BmFont, Color, Scene } from '@codexo/exojs'; +import { Application, Asset, BitmapText, Color, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -16,10 +16,8 @@ class BitmapTextBasicScene extends Scene { wrapped; counter; frame = 0; - async load(loader) { - this.font = await loader.load(BmFont, assets.demo.fonts.kenneyBlocksFnt); - } - init() { + async init() { + this.font = await this.loader.load(Asset.kind('bmFont', assets.demo.fonts.kenneyBlocksFnt)); const font = this.font; const { width, height } = this.app.canvas; const marginX = width * 0.08; diff --git a/examples/text-fonts/bitmap-text-basic.ts b/examples/text-fonts/bitmap-text-basic.ts index 6be814b34..feb34f9f1 100644 --- a/examples/text-fonts/bitmap-text-basic.ts +++ b/examples/text-fonts/bitmap-text-basic.ts @@ -1,4 +1,4 @@ -import { Application, BitmapText, BmFont, Color, Scene } from '@codexo/exojs'; +import { Application, Asset, BitmapText, BmFont, Color, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(Asset.kind('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.js b/examples/text-fonts/web-fonts.js index b196e7956..6e924397d 100644 --- a/examples/text-fonts/web-fonts.js +++ b/examples/text-fonts/web-fonts.js @@ -1,5 +1,5 @@ // Auto-generated from web-fonts.ts — edit the .ts source, not this file. -import { Application, Color, FontAsset, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,10 +15,8 @@ const app = new Application({ class WebFontsScene extends Scene { default; loaded; - async load(loader) { - await loader.load(FontAsset, { andy: 'font/Kenney Future.ttf' }, { family: 'Kenney Future' }); - } - init() { + async init() { + await this.loader.load(Asset.kind('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); const { width, height } = this.app.canvas; this.default = new Text('Default Font', { fillColor: Color.white, fontSize: 52, align: 'center' }); this.default.setAnchor(0.5, 0.5); diff --git a/examples/text-fonts/web-fonts.ts b/examples/text-fonts/web-fonts.ts index 881673a96..4fb1822f0 100644 --- a/examples/text-fonts/web-fonts.ts +++ b/examples/text-fonts/web-fonts.ts @@ -1,4 +1,4 @@ -import { Application, Color, FontAsset, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Color, Scene, Text } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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(Asset.kind('font', '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' }); diff --git a/examples/tweens-animation/frame-animation.js b/examples/tweens-animation/frame-animation.js index 268d4ef1b..3f280b14c 100644 --- a/examples/tweens-animation/frame-animation.js +++ b/examples/tweens-animation/frame-animation.js @@ -1,5 +1,5 @@ // Auto-generated from frame-animation.ts — edit the .ts source, not this file. -import { AnimatedSprite, Application, Color, Json, Scene, Spritesheet, Texture } from '@codexo/exojs'; +import { AnimatedSprite, Application, Asset, Color, Scene, Spritesheet } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -18,14 +18,10 @@ class FrameAnimationScene extends Scene { sprite; frameCount = 0; hud; - async load(loader) { - await loader.load(Texture, { characters: 'image/platformer-characters.png' }); - await loader.load(Json, { characters: 'json/platformer-characters.json' }); - } - init(loader) { + async init() { const { width, height } = this.app.canvas; - const texture = loader.get(Texture, 'characters'); - const data = loader.get(Json, 'characters').value; + const texture = this.loader.get('image/platformer-characters.png'); + const data = (await this.loader.load(Asset.kind('json', 'json/platformer-characters.json'))); const sheet = new Spritesheet(texture, data); const walkFrames = ['character_beige_walk_a', 'character_beige_walk_b'].map(name => sheet.getFrame(name)); this.frameCount = walkFrames.length; diff --git a/examples/tweens-animation/frame-animation.ts b/examples/tweens-animation/frame-animation.ts index ebf049667..4750497f9 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, Asset, Color, Scene, Spritesheet, type SpritesheetData } 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('image/platformer-characters.png'); + const data = (await this.loader.load(Asset.kind('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.js b/examples/tweens-animation/interrupt-and-replace.js index c41272a34..23a8f9f82 100644 --- a/examples/tweens-animation/interrupt-and-replace.js +++ b/examples/tweens-animation/interrupt-and-replace.js @@ -1,5 +1,5 @@ // Auto-generated from interrupt-and-replace.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -15,12 +15,9 @@ const app = new Application({ class InterruptAndReplaceScene extends Scene { sprite; moveTween = null; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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/interrupt-and-replace.ts b/examples/tweens-animation/interrupt-and-replace.ts index dabadcb39..3b69036bd 100644 --- a/examples/tweens-animation/interrupt-and-replace.ts +++ b/examples/tweens-animation/interrupt-and-replace.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture, Tween } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Tween } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/tweens-animation/tween-basics.js index 49835bce0..5331503a0 100644 --- a/examples/tweens-animation/tween-basics.js +++ b/examples/tweens-animation/tween-basics.js @@ -1,5 +1,5 @@ // Auto-generated from tween-basics.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Text } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -17,14 +17,11 @@ class TweenBasicsScene extends Scene { text; forward; backward; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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-basics.ts b/examples/tweens-animation/tween-basics.ts index f960230ef..765f029d2 100644 --- a/examples/tweens-animation/tween-basics.ts +++ b/examples/tweens-animation/tween-basics.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Text, Texture, Tween } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite, Text, Tween } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/tweens-animation/tween-chains.js index 501641ec6..ce4c4c46f 100644 --- a/examples/tweens-animation/tween-chains.js +++ b/examples/tweens-animation/tween-chains.js @@ -1,5 +1,5 @@ // Auto-generated from tween-chains.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -14,17 +14,14 @@ const app = new Application({ }); class TweenChainsScene extends Scene { sprite; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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; const right = width / 2 + width * 0.28; 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('image/ship-a.png')).setAnchor(0.5).setPosition(left, top); const a = this.app.tweens .create(this.sprite.position) .to({ x: right, y: top }, 0.6) diff --git a/examples/tweens-animation/tween-chains.ts b/examples/tweens-animation/tween-chains.ts index 02eb33a1d..979989a94 100644 --- a/examples/tweens-animation/tween-chains.ts +++ b/examples/tweens-animation/tween-chains.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/tweens-animation/tween-from-array.js index c110a18ea..86ef93243 100644 --- a/examples/tweens-animation/tween-from-array.js +++ b/examples/tweens-animation/tween-from-array.js @@ -1,5 +1,5 @@ // Auto-generated from tween-from-array.ts — edit the .ts source, not this file. -import { Application, Color, Ease, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Ease, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -27,13 +27,10 @@ const waypointFractions = [ class TweenFromArrayScene extends Scene { sprite; waypoints = []; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('image/ship-a.png')).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); this.buildPath(); } buildPath() { diff --git a/examples/tweens-animation/tween-from-array.ts b/examples/tweens-animation/tween-from-array.ts index 1855dfca4..697943752 100644 --- a/examples/tweens-animation/tween-from-array.ts +++ b/examples/tweens-animation/tween-from-array.ts @@ -1,4 +1,4 @@ -import { Application, Color, Ease, Scene, Sprite, Texture, Tween } from '@codexo/exojs'; +import { Application, Color, Ease, Scene, Sprite, Tween } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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.js b/examples/tweens-animation/tween-with-yoyo.js index 502d055da..9b5cf82a4 100644 --- a/examples/tweens-animation/tween-with-yoyo.js +++ b/examples/tweens-animation/tween-with-yoyo.js @@ -1,5 +1,5 @@ // Auto-generated from tween-with-yoyo.ts — edit the .ts source, not this file. -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -14,12 +14,9 @@ const app = new Application({ }); class TweenWithYoyoScene extends Scene { sprite; - async load(loader) { - await loader.load(Texture, { bunny: 'image/ship-a.png' }); - } - init(loader) { + init() { 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('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(); } diff --git a/examples/tweens-animation/tween-with-yoyo.ts b/examples/tweens-animation/tween-with-yoyo.ts index e5c232683..4b53a9dc4 100644 --- a/examples/tweens-animation/tween-with-yoyo.ts +++ b/examples/tweens-animation/tween-with-yoyo.ts @@ -1,4 +1,4 @@ -import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Scene, Sprite } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -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('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(); } diff --git a/package.json b/package.json index ed2936561..fbb25af5e 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", "create:package": "tsx scripts/create-package.ts", "verify:release": "pnpm verify:lockstep && pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm lint:strict && pnpm format:check && pnpm test && pnpm verify:package && pnpm verify:create-exo-app && pnpm site:build", - "verify:quick": "pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm typecheck:packages && pnpm lint:all && pnpm format:check && pnpm docs:api:check", + "verify:quick": "pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm typecheck:type-tests && pnpm typecheck:packages && pnpm lint:all && pnpm format:check && pnpm docs:api:check", "verify:ci": "pnpm verify:quick && pnpm test", "release": "tsx ./scripts/release.ts", "release:cut": "tsx ./scripts/release/cut.ts", @@ -94,6 +94,7 @@ "docs:api:check": "tsx scripts/check-api-docs-sync.ts", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", + "typecheck:type-tests": "tsc --noEmit -p tsconfig.type-tests.json", "typecheck:guides": "tsx scripts/extract-guide-snippets.ts && tsc --noEmit -p tsconfig.guides.json", "typecheck:packages": "pnpm --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.js\" \"site/src/**/*.{ts,tsx}\"", diff --git a/packages/exojs-aseprite/src/AsepriteSheet.ts b/packages/exojs-aseprite/src/AsepriteSheet.ts index 2d71b389e..a9812ae82 100644 --- a/packages/exojs-aseprite/src/AsepriteSheet.ts +++ b/packages/exojs-aseprite/src/AsepriteSheet.ts @@ -95,13 +95,14 @@ function avgFps(frameArray: AsepriteFrameData[], indices: number[]): number { * * @example * ```ts - * const sheet = await loader.load(AsepriteSheet, 'hero.aseprite.json'); + * const sheet = await loader.load(Asset.kind('asepriteSheet', 'hero.aseprite.json')); * const sprite = sheet.createAnimatedSprite(); * sprite.play('run'); * scene.addChild(sprite); * ``` */ export class AsepriteSheet { + /** The underlying {@link Spritesheet} whose frames are keyed by index string. */ public readonly spritesheet: Spritesheet; diff --git a/packages/exojs-aseprite/src/asepriteBinding.ts b/packages/exojs-aseprite/src/asepriteBinding.ts index beb5fb4d6..b2799a732 100644 --- a/packages/exojs-aseprite/src/asepriteBinding.ts +++ b/packages/exojs-aseprite/src/asepriteBinding.ts @@ -1,10 +1,10 @@ +import { Asset } from '@codexo/exojs'; // Relative-path resolution for Aseprite image references (JSON → PNG). // Mirrors the approach used in @codexo/exojs-tiled: Aseprite stores the image // path relative to the JSON file; asset sources are often themselves relative, // so plain `new URL(ref, base)` cannot be used when `base` has no scheme. - -import { Texture } from '@codexo/exojs'; -import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; +import { defineAsset, Texture } from '@codexo/exojs'; +import type { AssetHandler } from '@codexo/exojs/extensions'; import type { AsepriteData } from './AsepriteData'; import { AsepriteSheet } from './AsepriteSheet'; @@ -103,27 +103,27 @@ function validateAsepriteData(raw: unknown, source: string): AsepriteData { /** * Declarative asset binding for {@link AsepriteSheet}. * - * `loader.load(AsepriteSheet, 'hero.aseprite.json')` fetches and validates the + * `loader.load(Asset.kind('asepriteSheet', 'hero.aseprite.json'))` fetches and validates the * Aseprite JSON export, resolves the packed image URL from `meta.image` * (relative to the JSON source), loads the {@link Texture} via the Loader's * sub-load deduplication, and constructs a fully-parsed {@link AsepriteSheet}. * * The `aseprite` type name enables the asset-config shorthand: - * `{ type: 'aseprite', source: 'hero.aseprite.json' }`. + * `{ kind: 'aseprite', source: 'hero.aseprite.json' }`. */ -export const asepriteBinding = { +export const asepriteBinding = defineAsset({ type: AsepriteSheet, - typeNames: ['asepriteSheet'], + kind: 'asepriteSheet', create() { return { async load(req, ctx): Promise { const raw = await ctx.fetchJson(req.source); const data = validateAsepriteData(raw, req.source); const imageUrl = resolveAsepriteUrl(data.meta.image, req.source); - const texture = (await ctx.loader.load(Texture, imageUrl)) as Texture; + const texture = await ctx.loader.load(Asset.kind('texture', imageUrl)); return AsepriteSheet.parse(data, texture); }, } satisfies AssetHandler; }, -} satisfies AssetBinding; +}); diff --git a/packages/exojs-aseprite/src/asepriteExtension.ts b/packages/exojs-aseprite/src/asepriteExtension.ts index 81c6e8b10..a11703bd9 100644 --- a/packages/exojs-aseprite/src/asepriteExtension.ts +++ b/packages/exojs-aseprite/src/asepriteExtension.ts @@ -6,7 +6,7 @@ import { asepriteBinding } from './asepriteBinding'; * Default immutable Aseprite extension descriptor. * * Registers one asset binding: - * - {@link asepriteBinding} — `loader.load(AsepriteSheet, 'hero.aseprite.json')` → + * - {@link asepriteBinding} — `loader.load(Asset.kind('asepriteSheet', 'hero.aseprite.json'))` → * fetches the Aseprite JSON, resolves and loads the packed texture, and * returns a fully-parsed {@link AsepriteSheet} with all frame-tag clips. * diff --git a/packages/exojs-aseprite/test/aseprite-binding.test.ts b/packages/exojs-aseprite/test/aseprite-binding.test.ts index 44866e6e5..12e8831ca 100644 --- a/packages/exojs-aseprite/test/aseprite-binding.test.ts +++ b/packages/exojs-aseprite/test/aseprite-binding.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { type AssetLoaderContext, Texture } from '@codexo/exojs'; +import { Asset, type AssetLoaderContext, Texture } from '@codexo/exojs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { asepriteBinding,AsepriteFormatError } from '../src/asepriteBinding'; @@ -33,7 +33,7 @@ function makeContext(fixtures: Record) { }; loaderLoad.mockImplementation(async (token: unknown): Promise => { - if (token === Texture) { + if ((token as { kind?: unknown } | null)?.kind === 'texture') { const tex = new Texture(); tex.width = 48; tex.height = 16; @@ -91,7 +91,7 @@ describe('asepriteBinding.load — array fixture', () => { const { context, loaderLoad } = makeContext(fixtures); const handler = asepriteBinding.create(); await handler.load({ source: 'sprites/hero.json' }, context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'sprites/hero.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'sprites/hero.png')); }); it('passes absolute image references through unchanged', async () => { @@ -100,7 +100,7 @@ describe('asepriteBinding.load — array fixture', () => { const { context, loaderLoad } = makeContext({ 'sprites/hero.json': doc }); const handler = asepriteBinding.create(); await handler.load({ source: 'sprites/hero.json' }, context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://cdn.example.com/hero.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'https://cdn.example.com/hero.png')); }); it('loads the hash-form fixture identically', async () => { @@ -114,14 +114,14 @@ describe('asepriteBinding.load — array fixture', () => { const { context, loaderLoad } = makeContext({ 'https://cdn.example.com/sprites/hero.json': loadFixture('hero.array.json') }); const handler = asepriteBinding.create(); await handler.load({ source: 'https://cdn.example.com/sprites/hero.json' }, context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://cdn.example.com/sprites/hero.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'https://cdn.example.com/sprites/hero.png')); }); it('resolves a relative image ref against a root-relative source, preserving the leading slash', async () => { const { context, loaderLoad } = makeContext({ '/assets/sprites/hero.json': loadFixture('hero.array.json') }); const handler = asepriteBinding.create(); await handler.load({ source: '/assets/sprites/hero.json' }, context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, '/assets/sprites/hero.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', '/assets/sprites/hero.png')); }); }); diff --git a/packages/exojs-audio-fx/src/effects/ConvolutionEffect.ts b/packages/exojs-audio-fx/src/effects/ConvolutionEffect.ts index d4af6d8c0..7dfc76119 100644 --- a/packages/exojs-audio-fx/src/effects/ConvolutionEffect.ts +++ b/packages/exojs-audio-fx/src/effects/ConvolutionEffect.ts @@ -62,8 +62,8 @@ interface ConvolutionEffectSetup { * * @example * ```ts - * await loader.load(Sound, { hall: 'hall.wav' }); - * const reverb = new ConvolutionEffect({ impulse: loader.get(Sound, 'hall'), wet: 0.6 }); + * const hall = await loader.load(Asset.kind('sound', 'hall.wav')); + * const reverb = new ConvolutionEffect({ impulse: hall, wet: 0.6 }); * bus.addEffect(reverb); * ``` */ diff --git a/packages/exojs-ldtk/src/LdtkMap.ts b/packages/exojs-ldtk/src/LdtkMap.ts index 674d9ba69..64c63409b 100644 --- a/packages/exojs-ldtk/src/LdtkMap.ts +++ b/packages/exojs-ldtk/src/LdtkMap.ts @@ -16,6 +16,7 @@ import { getLdtkLevelEntries } from './ldtkLevelEntries'; * **not** own tileset textures; those remain in the Loader cache. */ export class LdtkMap { + /** Resolved URL this map was loaded from. */ public readonly source: string; /** The raw parsed LDtk document. */ diff --git a/packages/exojs-ldtk/src/ldtkBinding.ts b/packages/exojs-ldtk/src/ldtkBinding.ts index f228b790a..de5047139 100644 --- a/packages/exojs-ldtk/src/ldtkBinding.ts +++ b/packages/exojs-ldtk/src/ldtkBinding.ts @@ -1,4 +1,5 @@ -import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; +import { defineAsset } from '@codexo/exojs'; +import type { AssetHandler } from '@codexo/exojs/extensions'; import { LdtkMap } from './LdtkMap'; import { loadLdtkMap } from './loadLdtkMap'; @@ -16,9 +17,9 @@ import { loadLdtkMap } from './loadLdtkMap'; * Each loaded level's TileMap is accessible via {@link LdtkMap.levels} or * {@link LdtkMap.getLevelByName}. */ -export const ldtkMapBinding = { +export const ldtkMapBinding = defineAsset({ type: LdtkMap, - typeNames: ['ldtkMap'], + kind: 'ldtkMap', extensions: ['ldtk'], create() { return { @@ -27,4 +28,4 @@ export const ldtkMapBinding = { }, } satisfies AssetHandler; }, -} satisfies AssetBinding; +}); diff --git a/packages/exojs-ldtk/src/loadLdtkMap.ts b/packages/exojs-ldtk/src/loadLdtkMap.ts index 2494e1dca..b7c19bb40 100644 --- a/packages/exojs-ldtk/src/loadLdtkMap.ts +++ b/packages/exojs-ldtk/src/loadLdtkMap.ts @@ -1,4 +1,5 @@ -import { type AssetLoaderContext, Texture, TextureRegion } from '@codexo/exojs'; +import { Asset } from '@codexo/exojs'; +import { type AssetLoaderContext, TextureRegion } from '@codexo/exojs'; import { TileSet } from '@codexo/exojs-tilemap'; import type { LdtkData, LdtkLevel, LdtkTilesetDef } from './LdtkData'; @@ -31,7 +32,7 @@ async function loadLdtkTileset( if (!def.relPath) return null; const imageUrl = resolveLdtkUrl(def.relPath, ldtkSource); - const texture = (await context.loader.load(Texture, imageUrl)) as Texture; + const texture = await context.loader.load(Asset.kind('texture', imageUrl)); const tileSize = def.tileGridSize; const spacing = def.spacing ?? 0; @@ -87,9 +88,10 @@ async function loadExternalLevel( if (level.layerInstances !== null || !level.externalRelPath) return level; const externalUrl = resolveLdtkUrl(level.externalRelPath, ldtkSource); - // Cast without deep validation, matching the root document's fetch below — - // structural errors surface as runtime exceptions during conversion. - const external = (await context.fetchJson(externalUrl)) as LdtkLevel; + // Typed without deep validation (fetchJson is an unvalidated assertion, + // matching the root document's fetch below) — structural errors surface as + // runtime exceptions during conversion. + const external = await context.fetchJson(externalUrl); const fieldInstances = external.fieldInstances ?? level.fieldInstances; return { diff --git a/packages/exojs-ldtk/test/load-ldtk-map.test.ts b/packages/exojs-ldtk/test/load-ldtk-map.test.ts index a2e12da73..5af392d62 100644 --- a/packages/exojs-ldtk/test/load-ldtk-map.test.ts +++ b/packages/exojs-ldtk/test/load-ldtk-map.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { type AssetLoaderContext, Texture } from '@codexo/exojs'; +import { Asset, type AssetLoaderContext, type Texture } from '@codexo/exojs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { LdtkData } from '../src/LdtkData'; @@ -86,7 +86,7 @@ describe('loadLdtkMap — happy path (absolute source)', () => { const { context: ctx, loaderLoad } = context(); await loadLdtkMap(ABS_SOURCE, ctx); // resolveLdtkUrl('tiles.png', 'https://example.com/maps/world.ldtk') - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://example.com/maps/tiles.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'https://example.com/maps/tiles.png')); }); it('populates the Tiles layer with the gridTiles once the tileset is available', async () => { @@ -529,7 +529,7 @@ describe('loadLdtkMap — atlas too small for any tile', () => { const map = await loadLdtkMap(ABS_SOURCE, context); // The texture load happens before the column check, so it IS requested. - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://example.com/maps/tiny.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'https://example.com/maps/tiny.png')); expect(map.levels[0]!.layers[0]!.countNonEmptyTiles()).toBe(0); }); }); diff --git a/packages/exojs-particles/README.md b/packages/exojs-particles/README.md index c84c8300e..4c0217559 100644 --- a/packages/exojs-particles/README.md +++ b/packages/exojs-particles/README.md @@ -78,7 +78,7 @@ import { ParticleSystem, particlesExtension } from '@codexo/exojs-particles/regi ## Minimal working example ```ts -import { Application, Scene, Texture } from '@codexo/exojs'; +import { Application, Scene } from '@codexo/exojs'; import { ParticleSystem, particlesExtension, @@ -93,11 +93,11 @@ class DemoScene extends Scene { system!: ParticleSystem; override async load(loader) { - await loader.load(Texture, { particle: '/particle.png' }); + await loader.load('/particle.png'); } override create(loader) { - this.system = new ParticleSystem(loader.get(Texture, 'particle'), { + this.system = new ParticleSystem(loader.get('/particle.png'), { capacity: 1024, }); this.system.addSpawnModule( diff --git a/packages/exojs-particles/src/ParticleSystem.ts b/packages/exojs-particles/src/ParticleSystem.ts index fd1d9999c..fcc45b463 100644 --- a/packages/exojs-particles/src/ParticleSystem.ts +++ b/packages/exojs-particles/src/ParticleSystem.ts @@ -110,7 +110,7 @@ export interface ParticleSystemOptions { * * @example * // Backend-agnostic — runs CPU on WebGL2, GPU on WebGPU automatically. - * const system = new ParticleSystem(loader.get(Texture, 'spark'), { + * const system = new ParticleSystem(loader.get('spark.png'), { * capacity: 8192, * }); * diff --git a/packages/exojs-tiled/README.md b/packages/exojs-tiled/README.md index c1bec30f2..ba16d89e0 100644 --- a/packages/exojs-tiled/README.md +++ b/packages/exojs-tiled/README.md @@ -20,10 +20,10 @@ npm install @codexo/exojs @codexo/exojs-tilemap ## What this package provides -- `TileMap` (re-exported from `@codexo/exojs-tilemap`) — generic runtime tilemap; the common-case result of `loader.load(TileMap, url)` +- `TileMap` (re-exported from `@codexo/exojs-tilemap`) — generic runtime tilemap; the common-case result of `loader.load(Asset.kind('tileMap', url))` - `TileMapNode` / `TileLayerNode` (re-exported from `@codexo/exojs-tilemap`) — scene nodes that render a loaded `TileMap` on WebGL2/WebGPU - `TileMapView` / `TileMapBand` (re-exported from `@codexo/exojs-tilemap`) — group a map's layers into independently placeable bands for interleaving actors between tile layers; same class identity, so `instanceof` holds across both import paths (the canonical view/band docs live in the [`@codexo/exojs-tilemap` README](https://www.npmjs.com/package/@codexo/exojs-tilemap)) -- `TiledMap` — parsed Tiled source model; advanced/diagnostic use via `loader.load(TiledMap, url)` +- `TiledMap` — parsed Tiled source model; advanced/diagnostic use via `loader.load(Asset.kind('tiledMap', url))` - `TiledTileset` — parsed tileset (atlas-image or collection-of-images); holds resolved textures - `TiledLayer` hierarchy — `TiledTileLayer`, `TiledObjectLayer`, `TiledImageLayer`, `TiledGroupLayer` - `TiledObject` — parsed object (point, ellipse, polygon, polyline, text, tile-ref, rectangle) @@ -43,7 +43,7 @@ import { TileMap, TileMapNode, tiledExtension } from '@codexo/exojs-tiled'; const app = new Application({ extensions: [tiledExtension] }); -const map = await app.loader.load(TileMap, 'maps/world.tmj'); +const map = await app.loader.load(Asset.kind('tileMap', 'maps/world.tmj')); // map is a @codexo/exojs-tilemap TileMap app.scene.root.addChild(new TileMapNode(map)); @@ -60,7 +60,7 @@ Load the fully resolved Tiled source model and convert it manually: ```ts import { TiledMap } from '@codexo/exojs-tiled'; -const source = await app.loader.load(TiledMap, 'maps/world.tmj'); +const source = await app.loader.load(Asset.kind('tiledMap', 'maps/world.tmj')); const map = source.toTileMap(); ``` @@ -89,12 +89,12 @@ traverse the dependency graph automatically. ## Asset loading -`loader.load(TileMap, url)` (common path) and `loader.load(TiledMap, url)` (advanced path) both: +`loader.load(Asset.kind('tileMap', url))` (common path) and `loader.load(Asset.kind('tiledMap', url))` (advanced path) both: 1. Fetch and validate the `.tmj` file. 2. Resolve each tileset entry (fetches external `.tsj` files via the Loader cache). 3. Load atlas images (`tileset.image`) and per-tile images (collection-of-images tilesets) - via `loader.load(Texture, …)` — the Loader deduplicates identical URLs. + via `loader.load(imageUrl)` — the Loader deduplicates identical URLs. 4. Validate GID ranges (no duplicates, no overlaps, all layer GIDs covered) — throws `TiledFormatError` on any inconsistency. @@ -105,7 +105,7 @@ The runtime binding additionally calls `TiledMap.toTileMap()` to produce the gen ```ts // `.tmj`/`.tsj` are recognised by extension; a format hint is only needed for // Tiled data served from a generic `.json` path: -await loader.load(TileMap, 'maps/world.json', { format: 'tiled' }); +await loader.load(Asset.kind('tileMap', 'maps/world.json', { format: 'tiled' })); ``` | Option | Type | Default | Description | diff --git a/packages/exojs-tiled/src/TiledMap.ts b/packages/exojs-tiled/src/TiledMap.ts index 568cdbca7..bc24d2166 100644 --- a/packages/exojs-tiled/src/TiledMap.ts +++ b/packages/exojs-tiled/src/TiledMap.ts @@ -29,6 +29,7 @@ import { TiledFormatError } from './validate'; * {@link TiledFormatError} on failure. */ export class TiledMap { + /** Resolved URL this map was loaded from. */ public readonly source: string; /** The validated raw map data this instance was built from. */ diff --git a/packages/exojs-tiled/src/loadTiledMap.ts b/packages/exojs-tiled/src/loadTiledMap.ts index 5aea04b36..21a5a1968 100644 --- a/packages/exojs-tiled/src/loadTiledMap.ts +++ b/packages/exojs-tiled/src/loadTiledMap.ts @@ -1,4 +1,5 @@ -import { type AssetLoaderContext,Texture } from '@codexo/exojs'; +import { Asset } from '@codexo/exojs'; +import { type AssetLoaderContext,type Texture } from '@codexo/exojs'; import type { TiledLayerData, TiledTilesetData, TiledTilesetRefData } from './data'; import { decodeTiledLayerData } from './decodeLayerData'; @@ -23,7 +24,7 @@ async function loadTiledTilesetResources(data: TiledTilesetData, baseUrl: string if (data.image !== undefined) { imageUrl = resolveTiledUrl(data.image, baseUrl); - texture = await context.loader.load(Texture, imageUrl); + texture = await context.loader.load(Asset.kind('texture', imageUrl)); } let tileTextures: Map | undefined; @@ -35,7 +36,7 @@ async function loadTiledTilesetResources(data: TiledTilesetData, baseUrl: string } const tileImageUrl = resolveTiledUrl(tile.image, baseUrl); - const tileTexture: Texture = await context.loader.load(Texture, tileImageUrl); + const tileTexture: Texture = await context.loader.load(Asset.kind('texture', tileImageUrl)); tileTextures ??= new Map(); tileTextures.set(tile.id, tileTexture); @@ -81,7 +82,7 @@ async function loadImageLayerTextures( for (const layer of layers) { if (layer.type === 'imagelayer' && layer.image) { const imageUrl = resolveTiledUrl(layer.image, mapSource); - const texture: Texture = await context.loader.load(Texture, imageUrl); + const texture: Texture = await context.loader.load(Asset.kind('texture', imageUrl)); result.set(layer.id, texture); } else if (layer.type === 'group') { const nested = await loadImageLayerTextures(layer.layers, mapSource, context); diff --git a/packages/exojs-tiled/src/public.ts b/packages/exojs-tiled/src/public.ts index 85de314da..b273ab25e 100644 --- a/packages/exojs-tiled/src/public.ts +++ b/packages/exojs-tiled/src/public.ts @@ -116,10 +116,6 @@ declare module '@codexo/exojs' { /** `.tmj` path-only loads resolve to the generic runtime {@link TileMap}. */ tmj: TileMap; } - interface ExtensionTokenTypeMap { - /** The advanced parsed-source token is also accepted for `.tmj`: `load(TiledMap, 'world.tmj')`. */ - tmj: TiledMap; - } interface AssetDefinitions { tileMap: { resource: TileMap; diff --git a/packages/exojs-tiled/src/tiledExtension.ts b/packages/exojs-tiled/src/tiledExtension.ts index 1fcff6a5c..c27159cb3 100644 --- a/packages/exojs-tiled/src/tiledExtension.ts +++ b/packages/exojs-tiled/src/tiledExtension.ts @@ -8,9 +8,9 @@ import { tiledRuntimeMapBinding } from './tiledRuntimeMapBinding'; * Default immutable Tiled extension descriptor. * * Registers two asset bindings: - * - {@link tiledRuntimeMapBinding} — `loader.load(TileMap, 'world.tmj')` → + * - {@link tiledRuntimeMapBinding} — `loader.load(Asset.kind('tileMap', 'world.tmj'))` → * returns a format-independent runtime {@link TileMap} (common case). - * - {@link tiledMapBinding} — `loader.load(TiledMap, 'world.tmj')` → + * - {@link tiledMapBinding} — `loader.load(Asset.kind('tiledMap', 'world.tmj'))` → * returns the raw parsed {@link TiledMap} source model (advanced/diagnostic). * * Depends on {@link tilemapExtension} so that snapshot construction always diff --git a/packages/exojs-tiled/src/tiledMapBinding.ts b/packages/exojs-tiled/src/tiledMapBinding.ts index 09e732da3..ebcac7177 100644 --- a/packages/exojs-tiled/src/tiledMapBinding.ts +++ b/packages/exojs-tiled/src/tiledMapBinding.ts @@ -1,4 +1,5 @@ -import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; +import { defineAsset } from '@codexo/exojs'; +import type { AssetHandler } from '@codexo/exojs/extensions'; import { loadTiledMap } from './loadTiledMap'; import { TiledMap } from './TiledMap'; @@ -7,15 +8,15 @@ import { resolveTiledOptions,type TiledLoadOptions } from './tiledOptions'; /** * Declarative asset binding for {@link TiledMap}. * - * Token-only: `loader.load(TiledMap, 'world.tmj')` resolves through this + * `loader.load(Asset.kind('tiledMap', 'world.tmj'))` resolves through this * binding, but no `extensions` are claimed, so a plain * `loader.load('world.tmj')` does not resolve to `TiledMap`. The `.tmj` * extension (and generic `.json` Tiled loading) is reserved for the * format-independent `TileMap` runtime asset binding. */ -export const tiledMapBinding = { +export const tiledMapBinding = defineAsset({ type: TiledMap, - typeNames: ['tiledMap'], + kind: 'tiledMap', create() { return { getIdentityKey(req) { @@ -27,4 +28,4 @@ export const tiledMapBinding = { }, } satisfies AssetHandler; }, -} satisfies AssetBinding; +}); diff --git a/packages/exojs-tiled/src/tiledOptions.ts b/packages/exojs-tiled/src/tiledOptions.ts index 4749c759e..49d967979 100644 --- a/packages/exojs-tiled/src/tiledOptions.ts +++ b/packages/exojs-tiled/src/tiledOptions.ts @@ -9,7 +9,7 @@ export interface TiledLoadOptions { * * `.tmj`/`.tsj` paths are recognised by extension and need no hint. Provide * `format: 'tiled'` when loading Tiled map data from a generic `.json` path: - * `loader.load(TileMap, 'world.json', { format: 'tiled' })`. `'tiled'` is the + * `loader.load(Asset.kind('tileMap', 'world.json', { format: 'tiled' }))`. `'tiled'` is the * only accepted value, so a foreign format (e.g. `'ldtk'`) is a compile error * rather than a silent runtime fall-through. * diff --git a/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts b/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts index 5ec3abc44..a0bc0a940 100644 --- a/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts +++ b/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts @@ -1,4 +1,6 @@ -import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; +import { Asset } from '@codexo/exojs'; +import { defineAsset } from '@codexo/exojs'; +import type { AssetHandler } from '@codexo/exojs/extensions'; import { TileMap } from '@codexo/exojs-tilemap'; import { TiledMap } from './TiledMap'; @@ -8,9 +10,9 @@ import { resolveTiledOptions,type TiledLoadOptions } from './tiledOptions'; * Declarative asset binding for the runtime {@link TileMap} produced from a * `.tmj` Tiled map file. * - * This is the common-case binding: `loader.load(TileMap, 'world.tmj')` fetches + * This is the common-case binding: `loader.load(Asset.kind('tileMap', 'world.tmj'))` fetches * and validates the TMJ, resolves external `.tsj` tilesets, loads tileset - * textures via the sub-load `loader.load(TiledMap, source)`, and synchronously + * textures via the sub-load `loader.load(Asset.kind('tiledMap', source))`, and synchronously * converts the parsed {@link TiledMap} source model into a format-independent * runtime {@link TileMap} via {@link TiledMap.toTileMap}. * @@ -22,9 +24,9 @@ import { resolveTiledOptions,type TiledLoadOptions } from './tiledOptions'; * `loader.load('world.tmj')` — the {@link ExtensionTypeMap} augmentation in * this package maps `'tmj' → TileMap`. */ -export const tiledRuntimeMapBinding = { +export const tiledRuntimeMapBinding = defineAsset({ type: TileMap, - typeNames: ['tileMap'], + kind: 'tileMap', extensions: ['tmj'], create() { return { @@ -33,9 +35,9 @@ export const tiledRuntimeMapBinding = { return `${req.source}|${o.format}`; }, async load(req, ctx) { - const tiledMap = await ctx.loader.load(TiledMap, req.source, req.options); + const tiledMap = await ctx.loader.load(Asset.kind('tiledMap', req.source, req.options)); return tiledMap.toTileMap(); }, } satisfies AssetHandler; }, -} satisfies AssetBinding; +}); diff --git a/packages/exojs-tiled/test/loadTiledMap.test.ts b/packages/exojs-tiled/test/loadTiledMap.test.ts index 4bb290df2..d77bad971 100644 --- a/packages/exojs-tiled/test/loadTiledMap.test.ts +++ b/packages/exojs-tiled/test/loadTiledMap.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; +import { Asset } from '@codexo/exojs'; import { type AssetLoaderContext,Texture } from '@codexo/exojs'; import { beforeEach, describe, expect, it,vi } from 'vitest'; @@ -75,9 +76,9 @@ describe('loadTiledMap — embedded tileset with atlas image', () => { 'with-tileset-image.tmj': loadFixture('with-tileset-image.tmj'), }); - it('calls loader.load(Texture, imageUrl) for the atlas image', async () => { + it('calls loader.load(Asset.kind(texture, imageUrl)) for the atlas image', async () => { await loadTiledMap('with-tileset-image.tmj', context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'tiles.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'tiles.png')); }); it('stores the loaded texture on the tileset', async () => { @@ -106,7 +107,7 @@ describe('loadTiledMap — external .tsj tileset', () => { it('loads the tileset image relative to the .tsj location', async () => { await loadTiledMap('external-tileset.tmj', context); // resolveTiledUrl('external-tileset.png', 'external-tileset.tsj') → 'external-tileset.png' - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'external-tileset.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'external-tileset.png')); }); it('stores the tsj source URL on the tileset', async () => { @@ -130,8 +131,8 @@ describe('loadTiledMap — collection-of-images tileset', () => { it('calls loader.load for each per-tile image', async () => { await loadTiledMap('collection-tileset.tmj', context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'tile0.png'); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'tile1.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'tile0.png')); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'tile1.png')); }); it('does NOT call loader.load for the atlas image — exactly 2 per-tile calls', async () => { @@ -168,7 +169,7 @@ describe('loadTiledMap — image layer nested inside a group layer', () => { it('loads the texture for an image layer nested inside a group layer', async () => { await loadTiledMap('nested-image.tmj', context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'bg.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'bg.png')); }); it('attaches the preloaded texture to the nested image layer via toTileMap()', async () => { diff --git a/packages/exojs-tiled/test/tiledMapBinding.test.ts b/packages/exojs-tiled/test/tiledMapBinding.test.ts index 703f02373..5950fc881 100644 --- a/packages/exojs-tiled/test/tiledMapBinding.test.ts +++ b/packages/exojs-tiled/test/tiledMapBinding.test.ts @@ -37,15 +37,18 @@ function makeContext(fixtures: Record) { }; // Handles both Texture and TiledMap sub-loads (for the runtime binding below). - loaderLoad.mockImplementation(async (token: unknown, url: string, _opts?: unknown): Promise => { - if (token === Texture) { + // Both now arrive as `Asset.kind(kind, src)` descriptors (asset form) rather than a + // `(constructor, url, opts)` token call. + loaderLoad.mockImplementation(async (token: unknown): Promise => { + const asset = token as { kind?: unknown; source?: unknown } | null; + if (asset?.kind === 'texture') { const tex = new Texture(); tex.width = 32; tex.height = 32; return tex; } - if (token === TiledMap) { - return loadTiledMap(url, context); + if (asset?.kind === 'tiledMap') { + return loadTiledMap(asset.source as string, context); } throw new Error(`tiledMapBinding.test: unexpected loader.load token: ${String(token)}`); }); @@ -104,7 +107,7 @@ describe('tiledMapBinding.load — minimal map', () => { // ── G-TILED-DIRECT-EQUIVALENCE ──────────────────────────────────────────────── // -// load(TileMap, url) must be semantically equivalent to load(TiledMap, url).toTileMap(): +// load(Asset.kind('tileMap', url)) must be semantically equivalent to load(Asset.kind('tiledMap', url)).toTileMap(): // same dimensions, layer count, tileset count, and tile data. describe('G-TILED-DIRECT-EQUIVALENCE — load(TileMap) ≡ load(TiledMap).toTileMap()', () => { diff --git a/packages/exojs-tiled/test/tiledRuntimeMapBinding.test.ts b/packages/exojs-tiled/test/tiledRuntimeMapBinding.test.ts index 6cdef078a..acd4b7fca 100644 --- a/packages/exojs-tiled/test/tiledRuntimeMapBinding.test.ts +++ b/packages/exojs-tiled/test/tiledRuntimeMapBinding.test.ts @@ -1,12 +1,12 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; +import { Asset } from '@codexo/exojs'; import { type AssetLoaderContext,Texture } from '@codexo/exojs'; import { TileMap } from '@codexo/exojs-tilemap'; import { beforeEach, describe, expect, it,vi } from 'vitest'; import { loadTiledMap } from '../src/loadTiledMap'; -import { TiledMap } from '../src/TiledMap'; import { tiledRuntimeMapBinding } from '../src/tiledRuntimeMapBinding'; // ── Fixture loading ────────────────────────────────────────────────────────── @@ -22,9 +22,10 @@ function loadFixture(name: string): unknown { // ── Mock context factory ───────────────────────────────────────────────────── // -// The runtime binding's handler calls ctx.loader.load(TiledMap, source) as a -// sub-load to share the Loader cache with the source binding. The mock below -// handles both Texture and TiledMap sub-loads. +// The runtime binding's handler calls ctx.loader.load(Asset.kind('tiledMap', source, opts)) +// as a sub-load to share the Loader cache with the source binding. The mock +// below handles both Texture and TiledMap sub-loads, both arriving as `.of(...)` +// asset descriptors (single-argument form). function makeContext(fixtures: Record) { const loaderLoad = vi.fn(); @@ -41,15 +42,18 @@ function makeContext(fixtures: Record) { }; // Configure loaderLoad after context is defined so the closure captures it. - loaderLoad.mockImplementation(async (token: unknown, url: string, _opts?: unknown): Promise => { - if (token === Texture) { + loaderLoad.mockImplementation(async (token: unknown): Promise => { + // Both Texture and TiledMap sub-loads now arrive as `Asset.kind(kind, src)` descriptors + // (asset form) rather than a `(constructor, url, opts)` token call. + const asset = token as { kind?: unknown; source?: unknown } | null; + if (asset?.kind === 'texture') { const tex = new Texture(); tex.width = 32; tex.height = 32; return tex; } - if (token === TiledMap) { - return loadTiledMap(url, context); + if (asset?.kind === 'tiledMap') { + return loadTiledMap(asset.source as string, context); } throw new Error(`tiledRuntimeMapBinding.test: unexpected loader.load token: ${String(token)}`); }); @@ -125,10 +129,10 @@ describe('tiledRuntimeMapBinding.load — minimal map', () => { expect(result.tileHeight).toBe(16); }); - it('delegates to ctx.loader.load(TiledMap, source) internally', async () => { + it('delegates to ctx.loader.load(Asset.kind(tiledMap, source)) internally', async () => { const handler = tiledRuntimeMapBinding.create(); await handler.load({ source: 'minimal.tmj' }, context); - expect(context.loader.load).toHaveBeenCalledWith(TiledMap, 'minimal.tmj', undefined); + expect(context.loader.load).toHaveBeenCalledWith(Asset.kind('tiledMap', 'minimal.tmj')); }); }); @@ -150,7 +154,7 @@ describe('tiledRuntimeMapBinding.load — with atlas tileset image', () => { const result = await handler.load({ source: 'with-tileset-image.tmj' }, context); expect(result.tilesets).toHaveLength(1); // Texture is loaded transitively via the TiledMap sub-load - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'tiles.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'tiles.png')); }); }); @@ -174,7 +178,7 @@ describe('tiledRuntimeMapBinding.load — external tileset (.tsj)', () => { it('loads the external tileset texture', async () => { const handler = tiledRuntimeMapBinding.create(); await handler.load({ source: 'external-tileset.tmj' }, context); - expect(loaderLoad).toHaveBeenCalledWith(Texture, 'external-tileset.png'); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('texture', 'external-tileset.png')); }); }); @@ -189,6 +193,6 @@ describe('tiledRuntimeMapBinding.load — options passthrough', () => { const handler = tiledRuntimeMapBinding.create(); const opts = { format: 'tiled' as const }; await handler.load({ source: 'world.tmj', options: opts }, context); - expect(loaderLoad).toHaveBeenCalledWith(TiledMap, 'world.tmj', opts); + expect(loaderLoad).toHaveBeenCalledWith(Asset.kind('tiledMap', 'world.tmj', opts)); }); }); diff --git a/packages/exojs-tiled/test/toTileMap.test.ts b/packages/exojs-tiled/test/toTileMap.test.ts index abb29499e..817d3c21a 100644 --- a/packages/exojs-tiled/test/toTileMap.test.ts +++ b/packages/exojs-tiled/test/toTileMap.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it,vi } from 'vitest'; import { loadTiledMap } from '../src/loadTiledMap'; import { TiledGroupLayer, TiledImageLayer, TiledObjectLayer, TiledTileLayer } from '../src/TiledLayer'; -import { TiledMap } from '../src/TiledMap'; import { tiledRuntimeMapBinding } from '../src/tiledRuntimeMapBinding'; import { TiledFormatError } from '../src/validate'; @@ -45,16 +44,20 @@ function makeContext(fixtures: Record) { }), }; - loaderLoad.mockImplementation(async (token: unknown, url: string): Promise => { - if (token === Texture) { + loaderLoad.mockImplementation(async (token: unknown): Promise => { + // Both Texture and TiledMap sub-loads now arrive as `Asset.kind(kind, src)` descriptors + // (asset form); read the source from the descriptor. + const asset = token as { kind?: unknown; source?: unknown } | null; + if (asset?.kind === 'texture') { + const src = asset.source as string; const tex = new Texture(); - const size = TEXTURE_SIZES[url] ?? { w: 256, h: 256 }; + const size = TEXTURE_SIZES[src] ?? { w: 256, h: 256 }; tex.width = size.w; tex.height = size.h; return tex; } - if (token === TiledMap) { - return loadTiledMap(url, context); + if (asset?.kind === 'tiledMap') { + return loadTiledMap(asset.source as string, context); } throw new Error(`toTileMap.test: unexpected loader.load token: ${String(token)}`); }); diff --git a/packages/exojs-tilemap/README.md b/packages/exojs-tilemap/README.md index 1268e311c..ea1f79f46 100644 --- a/packages/exojs-tilemap/README.md +++ b/packages/exojs-tilemap/README.md @@ -39,7 +39,7 @@ custom-format maps. ## Usage — procedural map ```ts -import { Application, Texture, TextureRegion } from '@codexo/exojs'; +import { Application, TextureRegion } from '@codexo/exojs'; import { TileLayer, TileMap, @@ -52,7 +52,7 @@ import { const app = new Application({ extensions: [tilemapExtension] /* canvas, … */ }); // Tileset over a Loader-owned atlas texture (the runtime never destroys it). -const atlas = await app.loader.load(Texture, 'tiles.png'); +const atlas = await app.loader.load('tiles.png'); const terrain = new TileSet({ name: 'terrain', texture: new TextureRegion(atlas, { x: 0, y: 0, width: atlas.width, height: atlas.height }), diff --git a/packages/exojs-tilemap/src/TileMap.ts b/packages/exojs-tilemap/src/TileMap.ts index de17baa0e..989a839b1 100644 --- a/packages/exojs-tilemap/src/TileMap.ts +++ b/packages/exojs-tilemap/src/TileMap.ts @@ -1,3 +1,4 @@ + import { type ImageLayer } from './ImageLayer'; import type { ObjectLayer, ObjectSchema } from './ObjectLayer'; import { type TileLayer } from './TileLayer'; @@ -64,6 +65,7 @@ export interface TileMapOptions { * @advanced */ export class TileMap { + /** Map name (debug). */ public readonly name: string; diff --git a/packages/exojs-tilemap/src/tilemapSerializers.ts b/packages/exojs-tilemap/src/tilemapSerializers.ts index 985d3561a..d32352113 100644 --- a/packages/exojs-tilemap/src/tilemapSerializers.ts +++ b/packages/exojs-tilemap/src/tilemapSerializers.ts @@ -11,7 +11,7 @@ import { TileMapNode } from './TileMapNode'; * Captures the **map reference** (its Loader source key) plus the render-only * `pixelSnapMode`; the per-layer / per-chunk nodes are derived from the map and * rebuilt on construction, so they are never written. The referenced `TileMap` - * must be pre-loaded into the target Loader (e.g. `loader.load(TileMap, 'world.tmj')`) + * must be pre-loaded into the target Loader (e.g. `loader.load(Asset.kind('tileMap', 'world.tmj'))`) * before deserialize — procedurally-built maps have no source key and cannot be * referenced. * diff --git a/packages/exojs-tilemap/test/serialization.test.ts b/packages/exojs-tilemap/test/serialization.test.ts index ee03a4cd7..32c943cbf 100644 --- a/packages/exojs-tilemap/test/serialization.test.ts +++ b/packages/exojs-tilemap/test/serialization.test.ts @@ -11,7 +11,7 @@ import { tileMapNodeSerializer } from '../src/tilemapSerializers'; function fakeLoader(map: TileMap, source: string): Loader { return { keyFor: (resource: object) => (resource === map ? { type: TileMap, source } : null), - peek: (type: Loadable, alias: string) => (type === TileMap && alias === source ? map : null), + _peekResource: (type: Loadable, source_: string) => (type === TileMap && source_ === source ? map : null), } as unknown as Loader; } @@ -50,13 +50,13 @@ describe('tilemap serialization', () => { }); it('throws when the referenced map is not pre-loaded', () => { - const emptyLoader = { keyFor: () => null, peek: () => null } as unknown as Loader; + const emptyLoader = { keyFor: () => null, _peekResource: () => null } as unknown as Loader; expect(() => Prefab.fromJSON({ type: 'TileMapNode', map: 'missing.tmj' }).instantiate(emptyLoader)).toThrow(/pre-loaded/); }); it('throws when no map field is present at all (procedural map, never given a source key)', () => { - const emptyLoader = { keyFor: () => null, peek: () => null } as unknown as Loader; + const emptyLoader = { keyFor: () => null, _peekResource: () => null } as unknown as Loader; expect(() => Prefab.fromJSON({ type: 'TileMapNode' }).instantiate(emptyLoader)).toThrow(/pre-loaded/); }); @@ -64,7 +64,7 @@ describe('tilemap serialization', () => { it('omits the map/pixelSnapMode keys entirely for a procedural map with default pixelSnapMode', () => { const map = new TileMap({ name: 'procedural', width: 4, height: 4, tileWidth: 32, tileHeight: 32 }); const node = new TileMapNode(map); // pixelSnapMode stays 'none' - const loaderWithoutSourceKey = { keyFor: () => null, peek: () => null } as unknown as Loader; + const loaderWithoutSourceKey = { keyFor: () => null, _peekResource: () => null } as unknown as Loader; const data = Prefab.from(node, loaderWithoutSourceKey).toJSON(); diff --git a/site/src/components/pages/HomePage.astro b/site/src/components/pages/HomePage.astro index 2dc369645..61fab7615 100644 --- a/site/src/components/pages/HomePage.astro +++ b/site/src/components/pages/HomePage.astro @@ -28,13 +28,13 @@ const heroExample = getExamplesForChapter('particles').find(entry => entry.slug const heroExampleTitle = heroExample?.title ?? 'Bonfire'; const heroExampleSource = getExampleExecutionSource('particles', 'bonfire'); -const heroSnippet = `import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; +const heroSnippet = `import { Application, Color, Scene, Sprite } from '@codexo/exojs'; class HeroScene extends Scene { private ship!: Sprite; async load(loader) { - this.ship = new Sprite(await loader.load(Texture, 'ship.png')); + this.ship = new Sprite(await loader.load('ship.png')); } init() { diff --git a/site/src/content/api/aseprite-binding.json b/site/src/content/api/aseprite-binding.json deleted file mode 100644 index 5bc315649..000000000 --- a/site/src/content/api/aseprite-binding.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "title": "asepriteBinding", - "description": "Declarative asset binding for AsepriteSheet. `loader.load(AsepriteSheet, 'hero.aseprite.json')` fetches and validates the Aseprite JSON export, resolves the packed image URL from `meta.image` (relative to the JSON source), loads the Texture via the Loader's sub-load deduplication, and constructs a fully-parsed AsepriteSheet. The `aseprite` type name enables the asset-config shorthand: `{ type: 'aseprite', source: 'hero.aseprite.json' }`.", - "symbol": "asepriteBinding", - "kind": "namespace", - "subsystem": "aseprite", - "importPath": "@codexo/exojs-aseprite", - "tier": "stable", - "memberCount": 3, - "counts": { - "constructors": 0, - "methods": 1, - "properties": 2, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Declarative asset binding for AsepriteSheet.", - "`loader.load(AsepriteSheet, 'hero.aseprite.json')` fetches and validates the Aseprite JSON export, resolves the packed image URL from `meta.image` (relative to the JSON source), loads the Texture via the Loader's sub-load deduplication, and constructs a fully-parsed AsepriteSheet.", - "The `aseprite` type name enables the asset-config shorthand: `{ type: 'aseprite', source: 'hero.aseprite.json' }`." - ], - "importLine": "import { asepriteBinding } from '@codexo/exojs-aseprite'", - "sourceLink": null - }, - { - "id": "methods", - "title": "Methods", - "members": [ - { - "name": "create", - "signature": "create(): { load: unknown }", - "signatureTokens": [ - { - "text": "create", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "load", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" - } - ], - "params": [], - "returnType": "{ load: unknown }", - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "type", - "signature": "type: typeof AsepriteSheet", - "signatureTokens": [ - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "AsepriteSheet", - "kind": "type" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "typeNames", - "signature": "typeNames: string[]", - "signatureTokens": [ - { - "text": "typeNames", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "packages/exojs-aseprite/src/asepriteBinding.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-aseprite/src/asepriteBinding.ts" - } - } - ], - "sourcePath": "packages/exojs-aseprite/src/asepriteBinding.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-aseprite/src/asepriteBinding.ts" -} diff --git a/site/src/content/api/aseprite-functions.json b/site/src/content/api/aseprite-functions.json index 0fda1527e..4a1f6be6e 100644 --- a/site/src/content/api/aseprite-functions.json +++ b/site/src/content/api/aseprite-functions.json @@ -6,11 +6,11 @@ "subsystem": "aseprite", "importPath": "@codexo/exojs-aseprite", "tier": "stable", - "memberCount": 2, + "memberCount": 3, "counts": { "constructors": 0, "methods": 1, - "properties": 1, + "properties": 2, "events": 0 }, "sections": [ @@ -84,6 +84,47 @@ "id": "constants", "title": "Constants", "members": [ + { + "name": "asepriteBinding", + "signature": "asepriteBinding: AssetBinding", + "signatureTokens": [ + { + "text": "asepriteBinding", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetBinding", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "AsepriteSheet", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "undefined", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Declarative asset binding for AsepriteSheet. loader.load(Asset.kind('asepriteSheet', 'hero.aseprite.json')) fetches and validates the Aseprite JSON export, resolves the packed image URL from meta.image (relative to the JSON source), loads the Texture via the Loader's sub-load deduplication, and constructs a fully-parsed AsepriteSheet. The aseprite type name enables the asset-config shorthand: { kind: 'aseprite', source: 'hero.aseprite.json' }." + }, { "name": "asepriteExtension", "signature": "asepriteExtension: Extension", @@ -103,7 +144,7 @@ ], "params": [], "returnType": null, - "description": "Default immutable Aseprite extension descriptor. Registers one asset binding: - asepriteBinding — loader.load(AsepriteSheet, 'hero.aseprite.json') → fetches the Aseprite JSON, resolves and loads the packed texture, and returns a fully-parsed AsepriteSheet with all frame-tag clips. Use with ApplicationOptions.extensions or call import '@codexo/exojs-aseprite/register' for global auto-registration." + "description": "Default immutable Aseprite extension descriptor. Registers one asset binding: - asepriteBinding — loader.load(Asset.kind('asepriteSheet', 'hero.aseprite.json')) → fetches the Aseprite JSON, resolves and loads the packed texture, and returns a fully-parsed AsepriteSheet with all frame-tag clips. Use with ApplicationOptions.extensions or call import '@codexo/exojs-aseprite/register' for global auto-registration." } ], "paragraphs": [], diff --git a/site/src/content/api/asset-definitions.json b/site/src/content/api/asset-definitions.json index f89d26303..1d1a6e341 100644 --- a/site/src/content/api/asset-definitions.json +++ b/site/src/content/api/asset-definitions.json @@ -1131,7 +1131,7 @@ }, { "name": "texture", - "signature": "texture: { config: { mimeType?: string; samplerOptions?: SamplerOptions; source: string }; resource: Texture }", + "signature": "texture: { config: { mimeType?: string; samplerOptions?: Partial; source: string }; resource: Texture }", "signatureTokens": [ { "text": "texture", @@ -1189,10 +1189,22 @@ "text": ": ", "kind": "punctuation" }, + { + "text": "Partial", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, { "text": "SamplerOptions", "kind": "type" }, + { + "text": ">", + "kind": "punctuation" + }, { "text": "; ", "kind": "punctuation" diff --git a/site/src/content/api/asset-loader-context.json b/site/src/content/api/asset-loader-context.json index 54f171eb0..928a4a4a8 100644 --- a/site/src/content/api/asset-loader-context.json +++ b/site/src/content/api/asset-loader-context.json @@ -1,6 +1,6 @@ { "title": "AssetLoaderContext", - "description": "Context object passed to custom asset-type load handlers registered via Loader.registerAssetType. The `fetch*` helpers route through the loader's configured cache strategy and IDB stores, giving custom handlers the same caching behaviour as built-in asset types.", + "description": "Context object passed to custom asset-type load handlers bound via `bindAsset` / `defineAsset`. The `fetch*` helpers route through the loader's configured cache strategy and IDB stores, giving custom handlers the same caching behaviour as built-in asset types.", "symbol": "AssetLoaderContext", "kind": "interface", "subsystem": "resources", @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "Context object passed to custom asset-type load handlers registered via Loader.registerAssetType.", + "Context object passed to custom asset-type load handlers bound via `bindAsset` / `defineAsset`.", "The `fetch*` helpers route through the loader's configured cache strategy and IDB stores, giving custom handlers the same caching behaviour as built-in asset types." ], "importLine": "import { AssetLoaderContext } from '@codexo/exojs'", diff --git a/site/src/content/api/asset-manifest.json b/site/src/content/api/asset-manifest.json deleted file mode 100644 index 33d0b8b1a..000000000 --- a/site/src/content/api/asset-manifest.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "title": "AssetManifest", - "description": "Static description of all asset bundles in an application. Pass to Loader.registerManifest and then load individual bundles on demand with Loader.loadBundle. Use defineAssetManifest to construct a validated, type-safe manifest at authoring time.", - "symbol": "AssetManifest", - "kind": "interface", - "subsystem": "resources", - "importPath": "@codexo/exojs", - "tier": "stable", - "memberCount": 1, - "counts": { - "constructors": 0, - "methods": 0, - "properties": 1, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Static description of all asset bundles in an application.", - "Pass to Loader.registerManifest and then load individual bundles on demand with Loader.loadBundle. Use defineAssetManifest to construct a validated, type-safe manifest at authoring time." - ], - "importLine": "import { AssetManifest } from '@codexo/exojs'", - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "bundles", - "signature": "bundles: Readonly>", - "signatureTokens": [ - { - "text": "bundles", - "kind": "name" - }, - { - "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": "readonly", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "AssetEntry", - "kind": "type" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "src/resources/AssetManifest.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" - } - } - ], - "sourcePath": "src/resources/AssetManifest.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" -} diff --git a/site/src/content/api/asset-ref.json b/site/src/content/api/asset-ref.json index 8ba67dbbb..e720783c5 100644 --- a/site/src/content/api/asset-ref.json +++ b/site/src/content/api/asset-ref.json @@ -1,16 +1,16 @@ { "title": "AssetRef", - "description": "Deferred handle for value assets (parsed JSON, text, CSV rows, …), returned by `loader.get(Json, …)` and friends. Values cannot heal in place the way resource handles do, so the REF is the stable identity: value throws until `'ready'`, loaded resolves with the value itself, and a failed ref retries (healing in place) on the next `get`.", + "description": "Deferred handle for value assets (parsed JSON, text, CSV rows, …), returned by `loader.get(Asset.kind('json', src))` / a bare value path and friends. Values cannot heal in place the way resource handles do, so the REF is the stable identity: value throws until `'ready'`, loaded resolves with the value itself, and a failed ref retries (healing in place) on the next `get`.", "symbol": "AssetRef", "kind": "class", "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 4, + "memberCount": 7, "counts": { "constructors": 1, "methods": 0, - "properties": 3, + "properties": 6, "events": 0 }, "sections": [ @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "Deferred handle for value assets (parsed JSON, text, CSV rows, …), returned by `loader.get(Json, …)` and friends. Values cannot heal in place the way resource handles do, so the REF is the stable identity: value throws until `'ready'`, loaded resolves with the value itself, and a failed ref retries (healing in place) on the next `get`." + "Deferred handle for value assets (parsed JSON, text, CSV rows, …), returned by `loader.get(Asset.kind('json', src))` / a bare value path and friends. Values cannot heal in place the way resource handles do, so the REF is the stable identity: value throws until `'ready'`, loaded resolves with the value itself, and a failed ref retries (healing in place) on the next `get`." ], "importLine": "import { AssetRef } from '@codexo/exojs'", "sourceLink": null @@ -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", @@ -130,7 +159,49 @@ ], "params": [], "returnType": null, - "description": "Load lifecycle of this ref: 'loading' | 'ready' | 'failed'." + "description": "Load lifecycle of this ref: 'idle' | '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: 'idle' | 'loading' | 'ready' | 'failed' (asset-system v2 §6)." }, { "name": "value", diff --git a/site/src/content/api/asset-entry.json b/site/src/content/api/asset-status.json similarity index 50% rename from site/src/content/api/asset-entry.json rename to site/src/content/api/asset-status.json index a0d565ee2..432858311 100644 --- a/site/src/content/api/asset-entry.json +++ b/site/src/content/api/asset-status.json @@ -1,16 +1,16 @@ { - "title": "AssetEntry", - "description": "A single asset declaration inside an AssetManifest bundle. `type` is the loadable class token (e.g. `Texture`, `Sound`), `alias` is the key used to retrieve the asset from the Loader, and `path` is the URL or relative path used to fetch it.", - "symbol": "AssetEntry", + "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": 4, + "memberCount": 3, "counts": { "constructors": 0, "methods": 0, - "properties": 4, + "properties": 3, "events": 0 }, "sections": [ @@ -19,10 +19,9 @@ "title": "Import", "members": [], "paragraphs": [ - "A single asset declaration inside an AssetManifest bundle.", - "`type` is the loadable class token (e.g. `Texture`, `Sound`), `alias` is the key used to retrieve the asset from the Loader, and `path` is the URL or relative path used to fetch it." + "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 { AssetEntry } from '@codexo/exojs'", + "importLine": "import { AssetStatus } from '@codexo/exojs'", "sourceLink": null }, { @@ -30,11 +29,11 @@ "title": "Properties", "members": [ { - "name": "alias", - "signature": "alias: string", + "name": "error", + "signature": "error: Error | null", "signatureTokens": [ { - "text": "alias", + "text": "error", "kind": "name" }, { @@ -42,45 +41,28 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "options", - "signature": "options?: unknown", - "signatureTokens": [ - { - "text": "options", - "kind": "name" - }, - { - "text": "?", - "kind": "punctuation" + "text": "Error", + "kind": "type" }, { - "text": ": ", + "text": " | ", "kind": "punctuation" }, { - "text": "unknown", + "text": "null", "kind": "keyword" } ], "params": [], "returnType": null, - "description": "" + "description": "The error the last load failed with, or null outside 'failed'." }, { - "name": "path", - "signature": "path: string", + "name": "ready", + "signature": "ready: boolean", "signatureTokens": [ { - "text": "path", + "text": "ready", "kind": "name" }, { @@ -88,20 +70,20 @@ "kind": "punctuation" }, { - "text": "string", + "text": "boolean", "kind": "keyword" } ], "params": [], "returnType": null, - "description": "" + "description": "true exactly when state is 'ready'." }, { - "name": "type", - "signature": "type: T", + "name": "state", + "signature": "state: LoadStateValue", "signatureTokens": [ { - "text": "type", + "text": "state", "kind": "name" }, { @@ -109,13 +91,13 @@ "kind": "punctuation" }, { - "text": "T", + "text": "LoadStateValue", "kind": "type" } ], "params": [], "returnType": null, - "description": "" + "description": "Current load lifecycle: 'idle' | 'loading' | 'ready' | 'failed'." } ], "paragraphs": [], @@ -129,11 +111,11 @@ "paragraphs": [], "importLine": null, "sourceLink": { - "label": "src/resources/AssetManifest.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" + "label": "src/resources/AssetStatus.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetStatus.ts" } } ], - "sourcePath": "src/resources/AssetManifest.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.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/asset.json b/site/src/content/api/asset.json index 687315e28..8c1d8a0a9 100644 --- a/site/src/content/api/asset.json +++ b/site/src/content/api/asset.json @@ -29,11 +29,11 @@ "title": "Properties", "members": [ { - "name": "source", - "signature": "source: string", + "name": "kind", + "signature": "kind: keyof AssetDefinitions", "signatureTokens": [ { - "text": "source", + "text": "kind", "kind": "name" }, { @@ -41,8 +41,16 @@ "kind": "punctuation" }, { - "text": "string", + "text": "keyof", "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AssetDefinitions", + "kind": "type" } ], "params": [], @@ -50,11 +58,11 @@ "description": "" }, { - "name": "type", - "signature": "type: keyof AssetDefinitions", + "name": "source", + "signature": "source: string", "signatureTokens": [ { - "text": "type", + "text": "source", "kind": "name" }, { @@ -62,16 +70,8 @@ "kind": "punctuation" }, { - "text": "keyof", + "text": "string", "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "AssetDefinitions", - "kind": "type" } ], "params": [], diff --git a/site/src/content/api/assets.json b/site/src/content/api/assets.json index 65a6994c3..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. 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/binary-asset.json b/site/src/content/api/binary-asset.json index 672c498db..0fa18dcb9 100644 --- a/site/src/content/api/binary-asset.json +++ b/site/src/content/api/binary-asset.json @@ -1,6 +1,6 @@ { "title": "BinaryAsset", - "description": "Dispatch token for binary data loading. `loader.load(BinaryAsset, 'data.bin')` returns `Promise`.", + "description": "Dispatch token for binary data loading. `loader.load(Asset.kind('binary', 'data.bin'))` returns `Promise`.", "symbol": "BinaryAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for binary data loading.", - "`loader.load(BinaryAsset, 'data.bin')` returns `Promise`." + "`loader.load(Asset.kind('binary', 'data.bin'))` returns `Promise`." ], "importLine": "import { BinaryAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/bm-font.json b/site/src/content/api/bm-font.json index 078631dde..5db849ada 100644 --- a/site/src/content/api/bm-font.json +++ b/site/src/content/api/bm-font.json @@ -1,6 +1,6 @@ { "title": "BmFont", - "description": "A loaded BMFont asset: the parsed descriptor plus all page textures. Loaded by the built-in BMFont factory — no extra setup required. Pass directly to BitmapText. ```ts const font = await loader.load('fonts/ui.fnt'); // BmFont via extension const font = await loader.load(BmFont, 'fonts/ui.fnt'); // explicit token const label = new BitmapText('Score: 0', font); ```", + "description": "A loaded BMFont asset: the parsed descriptor plus all page textures. Loaded by the built-in BMFont factory — no extra setup required. Pass directly to BitmapText. ```ts const font = await loader.load('fonts/ui.fnt'); // BmFont via extension const font = await loader.load(Asset.kind('bmFont', 'fonts/ui.fnt')); // explicit descriptor const label = new BitmapText('Score: 0', font); ```", "symbol": "BmFont", "kind": "class", "subsystem": "rendering", @@ -21,7 +21,7 @@ "paragraphs": [ "A loaded BMFont asset: the parsed descriptor plus all page textures.", "Loaded by the built-in BMFont factory — no extra setup required. Pass directly to BitmapText.", - "```ts const font = await loader.load('fonts/ui.fnt'); // BmFont via extension const font = await loader.load(BmFont, 'fonts/ui.fnt'); // explicit token const label = new BitmapText('Score: 0', font); ```" + "```ts const font = await loader.load('fonts/ui.fnt'); // BmFont via extension const font = await loader.load(Asset.kind('bmFont', 'fonts/ui.fnt')); // explicit descriptor const label = new BitmapText('Score: 0', font); ```" ], "importLine": "import { BmFont } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/bundle-load-error.json b/site/src/content/api/bundle-load-error.json deleted file mode 100644 index 7809cf18a..000000000 --- a/site/src/content/api/bundle-load-error.json +++ /dev/null @@ -1,366 +0,0 @@ -{ - "title": "BundleLoadError", - "description": "Thrown by Loader.loadBundle when one or more assets in the bundle fail to load. The `failures` array contains every entry that errored, letting callers distinguish individual per-asset failures from a wholesale network outage.", - "symbol": "BundleLoadError", - "kind": "class", - "subsystem": "resources", - "importPath": "@codexo/exojs", - "tier": "stable", - "memberCount": 7, - "counts": { - "constructors": 1, - "methods": 0, - "properties": 6, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Thrown by Loader.loadBundle when one or more assets in the bundle fail to load.", - "The `failures` array contains every entry that errored, letting callers distinguish individual per-asset failures from a wholesale network outage." - ], - "importLine": "import { BundleLoadError } from '@codexo/exojs'", - "sourceLink": null - }, - { - "id": "constructors", - "title": "Constructors", - "members": [ - { - "name": "new", - "signature": "new(bundle: string, failures: { alias: string; error: Error; type: Loadable }[]): BundleLoadError", - "signatureTokens": [ - { - "text": "new", - "kind": "keyword" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "bundle", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "failures", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "alias", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "error", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Error", - "kind": "type" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Loadable", - "kind": "type" - }, - { - "text": " }", - "kind": "punctuation" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "BundleLoadError", - "kind": "type" - } - ], - "params": [ - { - "name": "bundle", - "type": "string", - "optional": false - }, - { - "name": "failures", - "type": "{ alias: string; error: Error; type: Loadable }[]", - "optional": false - } - ], - "returnType": "BundleLoadError", - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "bundle", - "signature": "bundle: string", - "signatureTokens": [ - { - "text": "bundle", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "cause", - "signature": "cause?: unknown", - "signatureTokens": [ - { - "text": "cause", - "kind": "name" - }, - { - "text": "?", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "failures", - "signature": "failures: { alias: string; error: Error; type: Loadable }[]", - "signatureTokens": [ - { - "text": "failures", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "alias", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "error", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Error", - "kind": "type" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Loadable", - "kind": "type" - }, - { - "text": " }", - "kind": "punctuation" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "message", - "signature": "message: string", - "signatureTokens": [ - { - "text": "message", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "name", - "signature": "name: string", - "signatureTokens": [ - { - "text": "name", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "stack", - "signature": "stack?: string", - "signatureTokens": [ - { - "text": "stack", - "kind": "name" - }, - { - "text": "?", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "src/resources/AssetManifest.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" - } - } - ], - "sourcePath": "src/resources/AssetManifest.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" -} diff --git a/site/src/content/api/constrained-loadable.json b/site/src/content/api/constrained-loadable.json deleted file mode 100644 index bffd531fc..000000000 --- a/site/src/content/api/constrained-loadable.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "title": "ConstrainedLoadable", - "description": "Constrains a Loadable token against the types registered for a given path's extension. When the extension is in ExtensionTypeMap, `T` must produce a value assignable to the registered union (including any extra token types from ExtensionTokenTypeMap) — otherwise resolves to `never`, triggering a compile-time error. For paths with an unregistered extension — including non-literal `string` paths and extension-less paths, where no extension can be derived — the constraint is skipped and any `T` is accepted (runtime behaviour is unchanged). ```ts // ExtensionTypeMap: { ogg: Sound | Video } loader.load(Sound, 'effect.ogg') // ✓ Sound ∈ Sound | Video loader.load(BitmapText, 'effect.ogg') // ✗ BitmapText ∉ Sound | Video loader.load(Sound, 'theme.custom') // ✓ .custom not in map → unconstrained loader.load(Sound, dynamicPath) // ✓ string path → unconstrained ```", - "symbol": "ConstrainedLoadable", - "kind": "type", - "subsystem": "resources", - "importPath": "@codexo/exojs", - "tier": "stable", - "memberCount": 0, - "counts": { - "constructors": 0, - "methods": 0, - "properties": 0, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Constrains a Loadable token against the types registered for a given path's extension. When the extension is in ExtensionTypeMap, `T` must produce a value assignable to the registered union (including any extra token types from ExtensionTokenTypeMap) — otherwise resolves to `never`, triggering a compile-time error.", - "For paths with an unregistered extension — including non-literal `string` paths and extension-less paths, where no extension can be derived — the constraint is skipped and any `T` is accepted (runtime behaviour is unchanged).", - "```ts // ExtensionTypeMap: { ogg: Sound | Video } loader.load(Sound, 'effect.ogg') // ✓ Sound ∈ Sound | Video loader.load(BitmapText, 'effect.ogg') // ✗ BitmapText ∉ Sound | Video loader.load(Sound, 'theme.custom') // ✓ .custom not in map → unconstrained loader.load(Sound, dynamicPath) // ✓ string path → unconstrained ```" - ], - "importLine": "import { ConstrainedLoadable } from '@codexo/exojs'", - "sourceLink": null - }, - { - "id": "definition", - "title": "Definition", - "members": [ - { - "name": "ConstrainedLoadable", - "signature": "[PathExtension] extends [never] ? T : PathExtension extends keyof ExtensionTypeMap ? LoadReturn extends ExtensionTypeMap[PathExtension] | TokenTypesFor> ? T : never : T", - "signatureTokens": [ - { - "text": "[", - "kind": "punctuation" - }, - { - "text": "PathExtension", - "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": "T", - "kind": "type" - }, - { - "text": " : ", - "kind": "punctuation" - }, - { - "text": "PathExtension", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "extends", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "keyof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "ExtensionTypeMap", - "kind": "type" - }, - { - "text": " ? ", - "kind": "punctuation" - }, - { - "text": "LoadReturn", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "T", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "extends", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "ExtensionTypeMap", - "kind": "type" - }, - { - "text": "[", - "kind": "punctuation" - }, - { - "text": "PathExtension", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": "]", - "kind": "punctuation" - }, - { - "text": " | ", - "kind": "punctuation" - }, - { - "text": "TokenTypesFor", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "PathExtension", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": " ? ", - "kind": "punctuation" - }, - { - "text": "T", - "kind": "type" - }, - { - "text": " : ", - "kind": "punctuation" - }, - { - "text": "never", - "kind": "keyword" - }, - { - "text": " : ", - "kind": "punctuation" - }, - { - "text": "T", - "kind": "type" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "src/resources/Loader.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" - } - } - ], - "sourcePath": "src/resources/Loader.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" -} diff --git a/site/src/content/api/csv-asset.json b/site/src/content/api/csv-asset.json index d03397cb2..d07396033 100644 --- a/site/src/content/api/csv-asset.json +++ b/site/src/content/api/csv-asset.json @@ -1,6 +1,6 @@ { "title": "CsvAsset", - "description": "Dispatch token for CSV loading. `loader.load(CsvAsset, 'table.csv')` returns `Promise`. Each inner array is one row; values are raw strings (no type coercion).", + "description": "Dispatch token for CSV loading. `loader.load(Asset.kind('csv', 'table.csv'))` returns `Promise`. Each inner array is one row; values are raw strings (no type coercion).", "symbol": "CsvAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for CSV loading.", - "`loader.load(CsvAsset, 'table.csv')` returns `Promise`. Each inner array is one row; values are raw strings (no type coercion)." + "`loader.load(Asset.kind('csv', 'table.csv'))` returns `Promise`. Each inner array is one row; values are raw strings (no type coercion)." ], "importLine": "import { CsvAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/data-texture.json b/site/src/content/api/data-texture.json index 32ab2de1c..73e3e0560 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", @@ -989,7 +1018,7 @@ ], "params": [], "returnType": null, - "description": "Load lifecycle of this texture. Directly constructed textures are 'ready'; deferred handles returned by loader.get(Texture, …) start 'loading' and become 'ready' once the payload fills in, or 'failed' (showing the Texture.missing checker) when the load errors." + "description": "Load lifecycle of this texture. Directly constructed textures are 'ready'; deferred handles returned by loader.get('hero.png') / loader.get(Asset.kind('texture', src)) start 'loading' and become 'ready' once the payload fills in, or 'failed' (showing the Texture.missing checker) when the load errors." }, { "name": "powerOfTwo", @@ -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: 'idle' | 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "version", "signature": "version: number", diff --git a/site/src/content/api/define-asset-descriptor.json b/site/src/content/api/define-asset-descriptor.json new file mode 100644 index 000000000..6bafd5d70 --- /dev/null +++ b/site/src/content/api/define-asset-descriptor.json @@ -0,0 +1,272 @@ +{ + "title": "DefineAssetDescriptor", + "description": "One descriptor for a built-in asset type. Feeds three consumers from a single source: the per-Loader AssetBinding returned for `materializeAssetBindings`, the global kind→placeholder strategy, and the global suffix→kind inference.", + "symbol": "DefineAssetDescriptor", + "kind": "interface", + "subsystem": "resources", + "importPath": "@codexo/exojs", + "tier": "advanced", + "memberCount": 6, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 6, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "One descriptor for a built-in asset type. Feeds three consumers from a single source: the per-Loader AssetBinding returned for `materializeAssetBindings`, the global kind→placeholder strategy, and the global suffix→kind inference." + ], + "importLine": "import { DefineAssetDescriptor } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "create", + "signature": "create: (loader: Loader) => AssetHandler", + "signatureTokens": [ + { + "text": "create", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "loader", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Loader", + "kind": "type" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "AssetHandler", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Result", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "Options", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Loader-local handler factory, called once per Loader by materializeAssetBindings." + }, + { + "name": "extensions", + "signature": "extensions?: readonly string[]", + "signatureTokens": [ + { + "text": "extensions", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "File suffixes that map to this type. Feeds the per-loader map and — for a leaf-capable kind — global bare-path inference." + }, + { + "name": "isValue", + "signature": "isValue?: boolean", + "signatureTokens": [ + { + "text": "isValue", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Whether the catalog leaf is a deferred AssetRef (value kind). Defaults to seamless === undefined." + }, + { + "name": "kind", + "signature": "kind: keyof AssetDefinitions", + "signatureTokens": [ + { + "text": "kind", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "keyof", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "AssetDefinitions", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "The AssetDefinitions key this type registers under." + }, + { + "name": "seamless", + "signature": "seamless?: SeamlessAdapter", + "signatureTokens": [ + { + "text": "seamless", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "SeamlessAdapter", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Result", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Seamless placeholder adapter for a resource kind that heals in place." + }, + { + "name": "type", + "signature": "type: AssetConstructor", + "signatureTokens": [ + { + "text": "type", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetConstructor", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Result", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "The runtime constructor the produced asset is an instance of." + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/resources/defineAsset.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/defineAsset.ts" + } + } + ], + "sourcePath": "src/resources/defineAsset.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/defineAsset.ts" +} diff --git a/site/src/content/api/extension-token-type-map.json b/site/src/content/api/extension-token-type-map.json deleted file mode 100644 index 11362f7f0..000000000 --- a/site/src/content/api/extension-token-type-map.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "title": "ExtensionTokenTypeMap", - "description": "Additional asset types accepted by the **token form** of Loader.load (`load(MyType, 'file.ext')`) for a given file extension, beyond the path-only type registered in ExtensionTypeMap. Augment via declaration merging when a format package ships an advanced source-model token that shares a file extension with its common-case runtime type. The path-only form (`load('file.ext')`) is unaffected and keeps resolving to the ExtensionTypeMap entry alone: ```ts declare module '@codexo/exojs' { interface ExtensionTypeMap { tmj: TileMap } // load('world.tmj') → TileMap interface ExtensionTokenTypeMap { tmj: TiledMap } // load(TiledMap, 'world.tmj') also allowed } ```", - "symbol": "ExtensionTokenTypeMap", - "kind": "interface", - "subsystem": "resources", - "importPath": "@codexo/exojs", - "tier": "stable", - "memberCount": 0, - "counts": { - "constructors": 0, - "methods": 0, - "properties": 0, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Additional asset types accepted by the **token form** of Loader.load (`load(MyType, 'file.ext')`) for a given file extension, beyond the path-only type registered in ExtensionTypeMap.", - "Augment via declaration merging when a format package ships an advanced source-model token that shares a file extension with its common-case runtime type. The path-only form (`load('file.ext')`) is unaffected and keeps resolving to the ExtensionTypeMap entry alone: ```ts declare module '@codexo/exojs' { interface ExtensionTypeMap { tmj: TileMap } // load('world.tmj') → TileMap interface ExtensionTokenTypeMap { tmj: TiledMap } // load(TiledMap, 'world.tmj') also allowed } ```" - ], - "importLine": "import { ExtensionTokenTypeMap } from '@codexo/exojs'", - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "src/resources/Loader.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" - } - } - ], - "sourcePath": "src/resources/Loader.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" -} diff --git a/site/src/content/api/font-asset.json b/site/src/content/api/font-asset.json index 114de0357..724beb8c0 100644 --- a/site/src/content/api/font-asset.json +++ b/site/src/content/api/font-asset.json @@ -1,6 +1,6 @@ { "title": "FontAsset", - "description": "Dispatch token for font loading. `loader.load(FontAsset, 'font.woff2', { family: 'MyFont' })` returns `Promise`.", + "description": "Dispatch token for font loading. `loader.load(Asset.kind('font', 'font.woff2', { family: 'MyFont' }))` returns `Promise`.", "symbol": "FontAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for font loading.", - "`loader.load(FontAsset, 'font.woff2', { family: 'MyFont' })` returns `Promise`." + "`loader.load(Asset.kind('font', 'font.woff2', { family: 'MyFont' }))` returns `Promise`." ], "importLine": "import { FontAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/functions.json b/site/src/content/api/functions.json index fbb9db5d4..094a220bc 100644 --- a/site/src/content/api/functions.json +++ b/site/src/content/api/functions.json @@ -328,11 +328,11 @@ "description": "Decode raw audio bytes into an AudioBuffer using the shared OfflineAudioContext. The context's sample rate is derived from the live AudioContext, ensuring decoded buffers are compatible with playback nodes. Note: on some older mobile WebKit versions decodeAudioData requires a running (live) context — decoding may fail with a browser-level error rather than an ExoJS-shaped error in those environments." }, { - "name": "defineAssetManifest", - "signature": "defineAssetManifest(manifest: M): M", + "name": "defineAsset", + "signature": "defineAsset(descriptor: DefineAssetDescriptor): AssetBinding", "signatureTokens": [ { - "text": "defineAssetManifest", + "text": "defineAsset", "kind": "name" }, { @@ -340,7 +340,7 @@ "kind": "punctuation" }, { - "text": "manifest", + "text": "descriptor", "kind": "param" }, { @@ -348,9 +348,29 @@ "kind": "punctuation" }, { - "text": "M", + "text": "DefineAssetDescriptor", "kind": "type" }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Result", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "Options", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, { "text": ")", "kind": "punctuation" @@ -360,19 +380,39 @@ "kind": "punctuation" }, { - "text": "M", + "text": "AssetBinding", "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "Result", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "Options", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "manifest", - "type": "M", + "name": "descriptor", + "type": "DefineAssetDescriptor", "optional": false } ], - "returnType": "M", - "description": "Validates and returns a strongly-typed AssetManifest. Validates the manifest shape at runtime (non-empty bundle names, valid entry fields, no duplicate aliases per type/bundle) and preserves the literal types of the input for downstream type inference. Throws a descriptive Error on any validation failure." + "returnType": "AssetBinding", + "description": "Declare a built-in asset type in one place. For a **leaf-capable** kind — one with a SeamlessAdapter (resource) or isValue: true (value) — this registers its placeholder strategy and suffix→kind inference GLOBALLY at import time, so a loader-free Assets.from resolves it before any Application exists. A **non-leaf** resource kind (isValue: false and no adapter — e.g. bmFont, font) has no placeholder strategy, so it is deliberately NOT registered globally: its bare path cannot be inferred and must be declared via Asset.kind(...) or an explicit config. Its extensions still travel on the returned binding for the per-Loader map. The returned AssetBinding flows through the unchanged materializeAssetBindings path exactly like any extension package's binding — defineAsset adds no second per-loader registration channel." }, { "name": "getAudioContext", diff --git a/site/src/content/api/htmltext.json b/site/src/content/api/htmltext.json index d34c555f1..2dcca9d1c 100644 --- a/site/src/content/api/htmltext.json +++ b/site/src/content/api/htmltext.json @@ -1,6 +1,6 @@ { "title": "HTMLText", - "description": "Text node that renders arbitrary HTML+CSS into a canvas texture via an SVG `` pass and displays the result as a textured quad. Full CSS typography and rich markup are supported. External resources (``, `background-image: url(...)`) are **blocked** by browsers when loading SVG blob-URIs — inline those as base-64 data URIs. Custom web fonts must be registered with addFont before use; pass the raw font bytes (from BinaryAsset or a plain `fetch`): ```ts const bytes = await loader.load(BinaryAsset, 'roboto.woff2'); htmlText.addFont('Roboto', bytes, 'woff2'); ``` The HTML must be valid XHTML (tags closed, `&` for `&`, etc.) because it is embedded inside an XML document.", + "description": "Text node that renders arbitrary HTML+CSS into a canvas texture via an SVG `` pass and displays the result as a textured quad. Full CSS typography and rich markup are supported. External resources (``, `background-image: url(...)`) are **blocked** by browsers when loading SVG blob-URIs — inline those as base-64 data URIs. Custom web fonts must be registered with addFont before use; pass the raw font bytes (from BinaryAsset or a plain `fetch`): ```ts const bytes = await loader.load(Asset.kind('binary', 'roboto.woff2')); htmlText.addFont('Roboto', bytes, 'woff2'); ``` The HTML must be valid XHTML (tags closed, `&` for `&`, etc.) because it is embedded inside an XML document.", "symbol": "HTMLText", "kind": "class", "subsystem": "rendering", @@ -22,7 +22,7 @@ "Text node that renders arbitrary HTML+CSS into a canvas texture via an SVG `` pass and displays the result as a textured quad.", "Full CSS typography and rich markup are supported. External resources (``, `background-image: url(...)`) are **blocked** by browsers when loading SVG blob-URIs — inline those as base-64 data URIs.", "Custom web fonts must be registered with addFont before use; pass the raw font bytes (from BinaryAsset or a plain `fetch`):", - "```ts const bytes = await loader.load(BinaryAsset, 'roboto.woff2'); htmlText.addFont('Roboto', bytes, 'woff2'); ```", + "```ts const bytes = await loader.load(Asset.kind('binary', 'roboto.woff2')); htmlText.addFont('Roboto', bytes, 'woff2'); ```", "The HTML must be valid XHTML (tags closed, `&` for `&`, etc.) because it is embedded inside an XML document." ], "importLine": "import { HTMLText } from '@codexo/exojs'", @@ -478,7 +478,7 @@ } ], "returnType": "this", - "description": "Register a font by its raw bytes for use inside this node's SVG render. The family name must match the font-family value used in your CSS. Registering the same family twice replaces the previous entry. ts const bytes = await loader.load(BinaryAsset, 'roboto.woff2'); label.addFont('Roboto', bytes, 'woff2'); " + "description": "Register a font by its raw bytes for use inside this node's SVG render. The family name must match the font-family value used in your CSS. Registering the same family twice replaces the previous entry. ts const bytes = await loader.load(Asset.kind('binary', 'roboto.woff2')); label.addFont('Roboto', bytes, 'woff2'); " }, { "name": "blur", diff --git a/site/src/content/api/image-asset.json b/site/src/content/api/image-asset.json index 784ad812f..a6d88a374 100644 --- a/site/src/content/api/image-asset.json +++ b/site/src/content/api/image-asset.json @@ -1,6 +1,6 @@ { "title": "ImageAsset", - "description": "Dispatch token for image loading. `loader.load(ImageAsset, 'img.png')` returns `Promise`.", + "description": "Dispatch token for image loading. `loader.load(Asset.kind('image', 'img.png'))` returns `Promise`.", "symbol": "ImageAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for image loading.", - "`loader.load(ImageAsset, 'img.png')` returns `Promise`." + "`loader.load(Asset.kind('image', 'img.png'))` returns `Promise`." ], "importLine": "import { ImageAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/infer-asset-resource.json b/site/src/content/api/infer-asset-resource.json index 974c97088..bc61324b5 100644 --- a/site/src/content/api/infer-asset-resource.json +++ b/site/src/content/api/infer-asset-resource.json @@ -28,7 +28,7 @@ "members": [ { "name": "InferAssetResource", - "signature": "I extends Asset ? T : I extends { type: K } ? AssetDefinitions[K][\"resource\"] : never", + "signature": "I extends Asset ? T : I extends { parse: (raw: never) => R } ? R : I extends { kind: K } ? AssetDefinitions[K][\"resource\"] : never", "signatureTokens": [ { "text": "I", @@ -95,7 +95,75 @@ "kind": "punctuation" }, { - "text": "type", + "text": "parse", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "raw", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "never", + "kind": "keyword" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "R", + "kind": "type" + }, + { + "text": " }", + "kind": "punctuation" + }, + { + "text": " ? ", + "kind": "punctuation" + }, + { + "text": "R", + "kind": "type" + }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "I", + "kind": "type" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "extends", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "{ ", + "kind": "punctuation" + }, + { + "text": "kind", "kind": "name" }, { diff --git a/site/src/content/api/json.json b/site/src/content/api/json.json index f35c98b34..39a570114 100644 --- a/site/src/content/api/json.json +++ b/site/src/content/api/json.json @@ -1,6 +1,6 @@ { "title": "Json", - "description": "Dispatch token for generic JSON loading. `loader.load(Json, 'config.json')` returns `Promise`. Narrow via generic: `loader.load(Json, 'config.json')`. Handles all JSON shapes — objects, arrays, scalars.", + "description": "Dispatch token for generic JSON loading. `loader.load(Asset.kind('json', 'config.json'))` returns `Promise`. Narrow via generic: `loader.load(Asset.kind('json', 'config.json'))`. Handles all JSON shapes — objects, arrays, scalars.", "symbol": "Json", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for generic JSON loading.", - "`loader.load(Json, 'config.json')` returns `Promise`. Narrow via generic: `loader.load(Json, 'config.json')`. Handles all JSON shapes — objects, arrays, scalars." + "`loader.load(Asset.kind('json', 'config.json'))` returns `Promise`. Narrow via generic: `loader.load(Asset.kind('json', 'config.json'))`. Handles all JSON shapes — objects, arrays, scalars." ], "importLine": "import { Json } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/ldtk-functions.json b/site/src/content/api/ldtk-functions.json index ce096b1bc..8f36a42eb 100644 --- a/site/src/content/api/ldtk-functions.json +++ b/site/src/content/api/ldtk-functions.json @@ -6,11 +6,11 @@ "subsystem": "ldtk", "importPath": "@codexo/exojs-ldtk", "tier": "stable", - "memberCount": 9, + "memberCount": 10, "counts": { "constructors": 0, "methods": 2, - "properties": 7, + "properties": 8, "events": 0 }, "sections": [ @@ -352,6 +352,47 @@ "params": [], "returnType": null, "description": "Reserved TileLayer.properties key holding the JSON-encoded LdtkIntGridValueDef array for a TileLayer converted from an LDtk IntGrid layer instance — the raw-int → named/coloured mapping declared on the owning layer definition (data.defs.layers[].intGridValues). Prefer getLdtkIntGridValueAt over reading this directly." + }, + { + "name": "ldtkMapBinding", + "signature": "ldtkMapBinding: AssetBinding", + "signatureTokens": [ + { + "text": "ldtkMapBinding", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetBinding", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "LdtkMap", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "undefined", + "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Declarative asset binding for LdtkMap. Claims the ldtk file extension so that: - loader.load(LdtkMap, 'world.ldtk') — returns the parsed LdtkMap with all levels pre-converted to runtime import('@codexo/exojs-tilemap').TileMaps. - loader.load('world.ldtk') — auto-routed to LdtkMap via the ExtensionTypeMap augmentation in public.ts. Each loaded level's TileMap is accessible via LdtkMap.levels or LdtkMap.getLevelByName." } ], "paragraphs": [], diff --git a/site/src/content/api/ldtk-map-binding.json b/site/src/content/api/ldtk-map-binding.json deleted file mode 100644 index 13e5b9019..000000000 --- a/site/src/content/api/ldtk-map-binding.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "title": "ldtkMapBinding", - "description": "Declarative asset binding for LdtkMap. Claims the `ldtk` file extension so that: - `loader.load(LdtkMap, 'world.ldtk')` — returns the parsed LdtkMap with all levels pre-converted to runtime import('@codexo/exojs-tilemap').TileMaps. - `loader.load('world.ldtk')` — auto-routed to `LdtkMap` via the `ExtensionTypeMap` augmentation in `public.ts`. Each loaded level's TileMap is accessible via LdtkMap.levels or LdtkMap.getLevelByName.", - "symbol": "ldtkMapBinding", - "kind": "namespace", - "subsystem": "ldtk", - "importPath": "@codexo/exojs-ldtk", - "tier": "stable", - "memberCount": 4, - "counts": { - "constructors": 0, - "methods": 1, - "properties": 3, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Declarative asset binding for LdtkMap.", - "Claims the `ldtk` file extension so that: - `loader.load(LdtkMap, 'world.ldtk')` — returns the parsed LdtkMap with all levels pre-converted to runtime import('@codexo/exojs-tilemap').TileMaps. - `loader.load('world.ldtk')` — auto-routed to `LdtkMap` via the `ExtensionTypeMap` augmentation in `public.ts`.", - "Each loaded level's TileMap is accessible via LdtkMap.levels or LdtkMap.getLevelByName." - ], - "importLine": "import { ldtkMapBinding } from '@codexo/exojs-ldtk'", - "sourceLink": null - }, - { - "id": "methods", - "title": "Methods", - "members": [ - { - "name": "create", - "signature": "create(): { load: unknown }", - "signatureTokens": [ - { - "text": "create", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "load", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" - } - ], - "params": [], - "returnType": "{ load: unknown }", - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "extensions", - "signature": "extensions: string[]", - "signatureTokens": [ - { - "text": "extensions", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "type", - "signature": "type: typeof LdtkMap", - "signatureTokens": [ - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "LdtkMap", - "kind": "type" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "typeNames", - "signature": "typeNames: string[]", - "signatureTokens": [ - { - "text": "typeNames", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "packages/exojs-ldtk/src/ldtkBinding.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-ldtk/src/ldtkBinding.ts" - } - } - ], - "sourcePath": "packages/exojs-ldtk/src/ldtkBinding.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-ldtk/src/ldtkBinding.ts" -} diff --git a/site/src/content/api/load-bundle-options.json b/site/src/content/api/load-bundle-options.json deleted file mode 100644 index 80e91f9fb..000000000 --- a/site/src/content/api/load-bundle-options.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "title": "LoadBundleOptions", - "description": "Options controlling how a bundle is loaded by Loader.loadBundle. Set `background` to `true` to load the bundle through the low-priority background queue, and supply `onProgress` for per-bundle progress updates.", - "symbol": "LoadBundleOptions", - "kind": "interface", - "subsystem": "resources", - "importPath": "@codexo/exojs", - "tier": "stable", - "memberCount": 2, - "counts": { - "constructors": 0, - "methods": 0, - "properties": 2, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Options controlling how a bundle is loaded by Loader.loadBundle.", - "Set `background` to `true` to load the bundle through the low-priority background queue, and supply `onProgress` for per-bundle progress updates." - ], - "importLine": "import { LoadBundleOptions } from '@codexo/exojs'", - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "background", - "signature": "background?: boolean", - "signatureTokens": [ - { - "text": "background", - "kind": "name" - }, - { - "text": "?", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "boolean", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "onProgress", - "signature": "onProgress?: (loaded: number, total: number) => void", - "signatureTokens": [ - { - "text": "onProgress", - "kind": "name" - }, - { - "text": "?", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "loaded", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "total", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "number", - "kind": "keyword" - }, - { - "text": ") => ", - "kind": "punctuation" - }, - { - "text": "void", - "kind": "keyword" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "src/resources/AssetManifest.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" - } - } - ], - "sourcePath": "src/resources/AssetManifest.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/AssetManifest.ts" -} diff --git a/site/src/content/api/load-by-path.json b/site/src/content/api/load-by-path.json index f837e86fc..11fc77f1d 100644 --- a/site/src/content/api/load-by-path.json +++ b/site/src/content/api/load-by-path.json @@ -1,6 +1,6 @@ { "title": "LoadByPath", - "description": "Resolves the return type for Loader.load when called with a plain path string. Returns `unknown` when the extension is not in ExtensionTypeMap — the string-path overload rejects such paths at compile time; use the token form (`load(MyType, path)`) instead. The `[PathExtension] extends [never]` guard is load-bearing: indexing ExtensionTypeMap with `never` would silently produce `never` rather than the intended `unknown` fallback.", + "description": "Resolves the return type for Loader.load when called with a plain path string. Returns `unknown` when the extension is not in ExtensionTypeMap — the string-path overload rejects such paths at compile time; use the descriptor form (`load(Asset.kind(kind, path))`) instead. The `[PathExtension] extends [never]` guard is load-bearing: indexing ExtensionTypeMap with `never` would silently produce `never` rather than the intended `unknown` fallback.", "symbol": "LoadByPath", "kind": "type", "subsystem": "resources", @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "Resolves the return type for Loader.load when called with a plain path string. Returns `unknown` when the extension is not in ExtensionTypeMap — the string-path overload rejects such paths at compile time; use the token form (`load(MyType, path)`) instead.", + "Resolves the return type for Loader.load when called with a plain path string. Returns `unknown` when the extension is not in ExtensionTypeMap — the string-path overload rejects such paths at compile time; use the descriptor form (`load(Asset.kind(kind, path))`) instead.", "The `[PathExtension] extends [never]` guard is load-bearing: indexing ExtensionTypeMap with `never` would silently produce `never` rather than the intended `unknown` fallback." ], "importLine": "import { LoadByPath } from '@codexo/exojs'", diff --git a/site/src/content/api/load-options.json b/site/src/content/api/load-options.json new file mode 100644 index 000000000..edf610252 --- /dev/null +++ b/site/src/content/api/load-options.json @@ -0,0 +1,76 @@ +{ + "title": "LoadOptions", + "description": "Options for the catalog/asset/leaf Loader.load forms. With `background: true` every adopted leaf is still claimed and registered (so it heals in place and a later Loader.get returns the same instance), but its fetch is routed through the low-priority background queue instead of started immediately: it streams concurrency-capped, drops from the queue if released at refcount 0, and is boosted to fetch now on a direct `get()`. Foreground loading (no options) is unaffected.", + "symbol": "LoadOptions", + "kind": "interface", + "subsystem": "resources", + "importPath": "@codexo/exojs", + "tier": "stable", + "memberCount": 1, + "counts": { + "constructors": 0, + "methods": 0, + "properties": 1, + "events": 0 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "Options for the catalog/asset/leaf Loader.load forms.", + "With `background: true` every adopted leaf is still claimed and registered (so it heals in place and a later Loader.get returns the same instance), but its fetch is routed through the low-priority background queue instead of started immediately: it streams concurrency-capped, drops from the queue if released at refcount 0, and is boosted to fetch now on a direct `get()`. Foreground loading (no options) is unaffected." + ], + "importLine": "import { LoadOptions } from '@codexo/exojs'", + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "background", + "signature": "background?: boolean", + "signatureTokens": [ + { + "text": "background", + "kind": "name" + }, + { + "text": "?", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "src/resources/Loader.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" + } + } + ], + "sourcePath": "src/resources/Loader.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" +} diff --git a/site/src/content/api/load-state-value.json b/site/src/content/api/load-state-value.json index 0294e7f08..486ca3b8d 100644 --- a/site/src/content/api/load-state-value.json +++ b/site/src/content/api/load-state-value.json @@ -1,6 +1,6 @@ { "title": "LoadStateValue", - "description": "Load lifecycle of a seamless asset handle. Directly constructed assets are `'ready'`.", + "description": "Load lifecycle of a seamless asset handle. Directly constructed assets are `'ready'`; a catalog leaf awaiting adoption is `'idle'` (asset-system v2 §7).", "symbol": "LoadStateValue", "kind": "type", "subsystem": "core", @@ -19,7 +19,7 @@ "title": "Import", "members": [], "paragraphs": [ - "Load lifecycle of a seamless asset handle. Directly constructed assets are `'ready'`." + "Load lifecycle of a seamless asset handle. Directly constructed assets are `'ready'`; a catalog leaf awaiting adoption is `'idle'` (asset-system v2 §7)." ], "importLine": "import { LoadStateValue } from '@codexo/exojs'", "sourceLink": null @@ -30,8 +30,16 @@ "members": [ { "name": "LoadStateValue", - "signature": "\"loading\" | \"ready\" | \"failed\"", + "signature": "\"idle\" | \"loading\" | \"ready\" | \"failed\"", "signatureTokens": [ + { + "text": "\"idle\"", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, { "text": "\"loading\"", "kind": "keyword" diff --git a/site/src/content/api/loader.json b/site/src/content/api/loader.json index 803eda927..cd2b9776e 100644 --- a/site/src/content/api/loader.json +++ b/site/src/content/api/loader.json @@ -1,17 +1,17 @@ { "title": "Loader", - "description": "Central asset management hub for ExoJS applications. The `Loader` orchestrates fetching, processing, caching, and retrieval of all engine asset types. It ships with built-in factories for every first-party type (Texture, Sound, AudioStream, Video, FontFace, HTMLImageElement, Json, text, SVG, VTT, binary, and WASM) and allows registering custom factories via register. Assets can be loaded in three ways: - **Direct** — `loader.load(Texture, 'hero.png')` fetches immediately and resolves to the finished asset. - **Bundle** — declare assets in a manifest with registerManifest, then call loadBundle to load groups on demand. - **Background** — call backgroundLoad or loadAll to pre-warm everything registered in the manifest at low priority. Once loaded, assets are stored in memory and returned from cache on subsequent `load` or get calls without re-fetching.", + "description": "Central asset management hub for ExoJS applications. The `Loader` orchestrates fetching, processing, caching, and retrieval of all engine asset types. It ships with built-in factories for every first-party type (Texture, Sound, AudioStream, Video, FontFace, HTMLImageElement, Json, text, SVG, VTT, binary, and WASM) and allows registering custom factories via register. Assets can be loaded in two ways: - **Direct** — `loader.load(Assets.from({ hero: 'hero.png' }))` fetches immediately and resolves to the finished assets. - **Background** — pass `{ background: true }` to `load(...)` to pre-warm assets at low priority; awaitBackground resolves once the queue drains. Once loaded, assets are stored in memory and returned from cache on subsequent `load` or get calls without re-fetching.", "symbol": "Loader", "kind": "class", "subsystem": "resources", "importPath": "@codexo/exojs", "tier": "stable", - "memberCount": 62, + "memberCount": 40, "counts": { "constructors": 1, - "methods": 51, + "methods": 30, "properties": 2, - "events": 8 + "events": 7 }, "sections": [ { @@ -21,7 +21,7 @@ "paragraphs": [ "Central asset management hub for ExoJS applications.", "The `Loader` orchestrates fetching, processing, caching, and retrieval of all engine asset types. It ships with built-in factories for every first-party type (Texture, Sound, AudioStream, Video, FontFace, HTMLImageElement, Json, text, SVG, VTT, binary, and WASM) and allows registering custom factories via register.", - "Assets can be loaded in three ways: - **Direct** — `loader.load(Texture, 'hero.png')` fetches immediately and resolves to the finished asset. - **Bundle** — declare assets in a manifest with registerManifest, then call loadBundle to load groups on demand. - **Background** — call backgroundLoad or loadAll to pre-warm everything registered in the manifest at low priority.", + "Assets can be loaded in two ways: - **Direct** — `loader.load(Assets.from({ hero: 'hero.png' }))` fetches immediately and resolves to the finished assets. - **Background** — pass `{ background: true }` to `load(...)` to pre-warm assets at low priority; awaitBackground resolves once the queue drains.", "Once loaded, assets are stored in memory and returned from cache on subsequent `load` or get calls without re-fetching." ], "importLine": "import { Loader } from '@codexo/exojs'", @@ -88,11 +88,11 @@ "title": "Methods", "members": [ { - "name": "backgroundLoad", - "signature": "backgroundLoad(): void", + "name": "awaitBackground", + "signature": "awaitBackground(): Promise", "signatureTokens": [ { - "text": "backgroundLoad", + "text": "awaitBackground", "kind": "name" }, { @@ -107,21 +107,33 @@ "text": ": ", "kind": "punctuation" }, + { + "text": "Promise", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, { "text": "void", "kind": "keyword" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [], - "returnType": "void", - "description": "Declares sources and enqueues them for low-priority background fetching in one step (replaces the removed add(); PixiJS model). Progress is reported via onProgress; already-loaded, in-flight, and already-queued sources are skipped. get()/load() on a queued source boosts it to the front. The zero-argument form enqueues everything registered in the manifest (bundles/manifest die with the asset graph)." + "returnType": "Promise", + "description": "Resolves when the low-priority background queue has fully drained — every leaf enqueued via load(target, { background: true }) has finished loading (successfully or not). Kicks the queue first, so a concurrency change that left pending entries unstarted still makes progress. Individual asset errors are reported via onError but do not reject the returned promise." }, { - "name": "backgroundLoad", - "signature": "backgroundLoad(type: Loadable, source: string, options?: unknown): void", + "name": "destroy", + "signature": "destroy(): void", "signatureTokens": [ { - "text": "backgroundLoad", + "text": "destroy", "kind": "name" }, { @@ -129,138 +141,98 @@ "kind": "punctuation" }, { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Loadable", - "kind": "type" - }, - { - "text": ", ", + "text": ")", "kind": "punctuation" }, - { - "text": "source", - "kind": "param" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "string", + "text": "void", "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Tears down the loader and all resources it owns. Destroys the factory registry (releasing object URLs), destroys every cache store, clears all in-memory assets and in-flight tracking, and disconnects all signals. Also calls destroy?.() on every handler registered via bindAsset." + }, + { + "name": "get", + "signature": "get(path: LoadByPath extends Sound | Texture ? S : never, options?: unknown): LoadByPath", + "signatureTokens": [ + { + "text": "get", + "kind": "name" }, { - "text": ", ", + "text": "(", "kind": "punctuation" }, { - "text": "options", + "text": "path", "kind": "param" }, - { - "text": "?", - "kind": "punctuation" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" + "text": "LoadByPath", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" - } - ], - "params": [ - { - "name": "type", - "type": "Loadable", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false + "text": "S", + "kind": "type" }, { - "name": "options", - "type": "unknown", - "optional": true - } - ], - "returnType": "void", - "description": "Declares sources and enqueues them for low-priority background fetching in one step (replaces the removed add(); PixiJS model). Progress is reported via onProgress; already-loaded, in-flight, and already-queued sources are skipped. get()/load() on a queued source boosts it to the front. The zero-argument form enqueues everything registered in the manifest (bundles/manifest die with the asset graph)." - }, - { - "name": "backgroundLoad", - "signature": "backgroundLoad(type: Loadable, sources: readonly string[], options?: unknown): void", - "signatureTokens": [ - { - "text": "backgroundLoad", - "kind": "name" + "text": ">", + "kind": "punctuation" }, { - "text": "(", + "text": " ", "kind": "punctuation" }, { - "text": "type", - "kind": "param" + "text": "extends", + "kind": "keyword" }, { - "text": ": ", + "text": " ", "kind": "punctuation" }, { - "text": "Loadable", + "text": "Sound", "kind": "type" }, { - "text": ", ", + "text": " | ", "kind": "punctuation" }, { - "text": "sources", - "kind": "param" + "text": "Texture", + "kind": "type" }, { - "text": ": ", + "text": " ? ", "kind": "punctuation" }, { - "text": "readonly", - "kind": "keyword" + "text": "S", + "kind": "type" }, { - "text": " ", + "text": " : ", "kind": "punctuation" }, { - "text": "string", + "text": "never", "kind": "keyword" }, - { - "text": "[]", - "kind": "punctuation" - }, { "text": ", ", "kind": "punctuation" @@ -290,19 +262,26 @@ "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" + "text": "LoadByPath", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "type", - "type": "Loadable", - "optional": false - }, - { - "name": "sources", - "type": "readonly string[]", + "name": "path", + "type": "LoadByPath extends Sound | Texture ? S : never", "optional": false }, { @@ -311,15 +290,15 @@ "optional": true } ], - "returnType": "void", - "description": "Declares sources and enqueues them for low-priority background fetching in one step (replaces the removed add(); PixiJS model). Progress is reported via onProgress; already-loaded, in-flight, and already-queued sources are skipped. get()/load() on a queued source boosts it to the front. The zero-argument form enqueues everything registered in the manifest (bundles/manifest die with the asset graph)." + "returnType": "LoadByPath", + "description": "Seamless deferred access by path (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored resource; 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 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. 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": "destroy", - "signature": "destroy(): void", + "name": "get", + "signature": "get(path: [KindByPath] extends [never] ? never : S, options?: unknown): LeafForPath", "signatureTokens": [ { - "text": "destroy", + "text": "get", "kind": "name" }, { @@ -327,70 +306,77 @@ "kind": "punctuation" }, { - "text": ")", - "kind": "punctuation" + "text": "path", + "kind": "param" }, { "text": ": ", "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" - } - ], - "params": [], - "returnType": "void", - "description": "Tears down the loader and all resources it owns. Destroys the factory registry (releasing object URLs), destroys every cache store, clears all in-memory assets, manifest entries, bundle definitions, and in-flight tracking, and disconnects all signals. Also calls destroy?.() on every handler registered via bindAsset." - }, - { - "name": "get", - "signature": "get(type: typeof Texture, source: string, options?: TextureFactoryOptions & PreSizeOptions): Texture", - "signatureTokens": [ + "text": "[", + "kind": "punctuation" + }, { - "text": "get", - "kind": "name" + "text": "KindByPath", + "kind": "type" }, { - "text": "(", + "text": "<", "kind": "punctuation" }, { - "text": "type", - "kind": "param" + "text": "S", + "kind": "type" }, { - "text": ": ", + "text": ">", "kind": "punctuation" }, { - "text": "typeof", - "kind": "keyword" + "text": "]", + "kind": "punctuation" }, { "text": " ", "kind": "punctuation" }, { - "text": "Texture", - "kind": "type" + "text": "extends", + "kind": "keyword" }, { - "text": ", ", + "text": " ", "kind": "punctuation" }, { - "text": "source", - "kind": "param" + "text": "[", + "kind": "punctuation" }, { - "text": ": ", + "text": "never", + "kind": "keyword" + }, + { + "text": "]", "kind": "punctuation" }, { - "text": "string", + "text": " ? ", + "kind": "punctuation" + }, + { + "text": "never", "kind": "keyword" }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, { "text": ", ", "kind": "punctuation" @@ -408,53 +394,52 @@ "kind": "punctuation" }, { - "text": "TextureFactoryOptions", - "kind": "type" + "text": "unknown", + "kind": "keyword" }, { - "text": " & ", + "text": ")", "kind": "punctuation" }, { - "text": "PreSizeOptions", - "kind": "type" + "text": ": ", + "kind": "punctuation" }, { - "text": ")", - "kind": "punctuation" + "text": "LeafForPath", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "Texture", + "text": "S", "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "type", - "type": "typeof Texture", - "optional": false - }, - { - "name": "source", - "type": "string", + "name": "path", + "type": "[KindByPath] extends [never] ? never : S", "optional": false }, { "name": "options", - "type": "TextureFactoryOptions & PreSizeOptions", + "type": "unknown", "optional": true } ], - "returnType": "Texture", - "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": "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 Texture, sources: readonly string[], options?: TextureFactoryOptions & PreSizeOptions): Texture[]", + "signature": "get(type: T, alias: string): LoadReturn", "signatureTokens": [ { "text": "get", @@ -473,15 +458,7 @@ "kind": "punctuation" }, { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "Texture", + "text": "T", "kind": "type" }, { @@ -489,7 +466,7 @@ "kind": "punctuation" }, { - "text": "sources", + "text": "alias", "kind": "param" }, { @@ -497,31 +474,11 @@ "kind": "punctuation" }, { - "text": "readonly", + "text": "string", "kind": "keyword" }, { - "text": " ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "options", - "kind": "param" - }, - { - "text": "?", + "text": ")", "kind": "punctuation" }, { @@ -529,57 +486,40 @@ "kind": "punctuation" }, { - "text": "TextureFactoryOptions", - "kind": "type" - }, - { - "text": " & ", - "kind": "punctuation" - }, - { - "text": "PreSizeOptions", + "text": "LoadReturn", "kind": "type" }, { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "Texture", + "text": "T", "kind": "type" }, { - "text": "[]", + "text": ">", "kind": "punctuation" } ], "params": [ { "name": "type", - "type": "typeof Texture", + "type": "T", "optional": false }, { - "name": "sources", - "type": "readonly string[]", + "name": "alias", + "type": "string", "optional": false - }, - { - "name": "options", - "type": "TextureFactoryOptions & PreSizeOptions", - "optional": true } ], - "returnType": "Texture[]", - "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": "LoadReturn", + "description": "Legacy in-memory lookup: retrieves a previously loaded asset by type + alias (for non-seamless types and loadContainer-loaded assets). This is a cache lookup, NOT a fetch — the token *fetch* forms get(Type, src) were removed. Prefer bare-path get('x.png') or get(Asset.kind(...))." }, { "name": "get", - "signature": "get(type: typeof Texture, items: Readonly>, options?: TextureFactoryOptions & PreSizeOptions): Record", + "signature": "get(catalog: Assets): InferAssetsProperties", "signatureTokens": [ { "text": "get", @@ -590,7 +530,7 @@ "kind": "punctuation" }, { - "text": "type", + "text": "catalog", "kind": "param" }, { @@ -598,39 +538,31 @@ "kind": "punctuation" }, { - "text": "typeof", - "kind": "keyword" + "text": "Assets", + "kind": "type" }, { - "text": " ", + "text": "<", "kind": "punctuation" }, { - "text": "Texture", + "text": "M", "kind": "type" }, { - "text": ", ", + "text": ">", "kind": "punctuation" }, { - "text": "items", - "kind": "param" - }, - { - "text": ": ", + "text": ")", "kind": "punctuation" }, { - "text": "Readonly", - "kind": "type" - }, - { - "text": "<", + "text": ": ", "kind": "punctuation" }, { - "text": "Record", + "text": "InferAssetsProperties", "kind": "type" }, { @@ -638,79 +570,78 @@ "kind": "punctuation" }, { - "text": "K", + "text": "M", "kind": "type" }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, { "text": ">", "kind": "punctuation" - }, + } + ], + "params": [ { - "text": ">", - "kind": "punctuation" + "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(asset: ValueAsset): AssetRef", + "signatureTokens": [ + { + "text": "get", + "kind": "name" }, { - "text": ", ", + "text": "(", "kind": "punctuation" }, { - "text": "options", + "text": "asset", "kind": "param" }, - { - "text": "?", - "kind": "punctuation" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "TextureFactoryOptions", + "text": "ValueAsset", "kind": "type" }, { - "text": " & ", + "text": "<", "kind": "punctuation" }, { - "text": "PreSizeOptions", + "text": "T", "kind": "type" }, { - "text": ")", + "text": ">", "kind": "punctuation" }, { - "text": ": ", + "text": ")", "kind": "punctuation" }, { - "text": "Record", - "kind": "type" - }, - { - "text": "<", + "text": ": ", "kind": "punctuation" }, { - "text": "K", + "text": "AssetRef", "kind": "type" }, { - "text": ", ", + "text": "<", "kind": "punctuation" }, { - "text": "Texture", + "text": "T", "kind": "type" }, { @@ -720,27 +651,17 @@ ], "params": [ { - "name": "type", - "type": "typeof Texture", - "optional": false - }, - { - "name": "items", - "type": "Readonly>", + "name": "asset", + "type": "ValueAsset", "optional": false - }, - { - "name": "options", - "type": "TextureFactoryOptions & PreSizeOptions", - "optional": true } ], - "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." + "returnType": "AssetRef", + "description": "Seamless/value access from an Asset.kind(...) descriptor (asset-system v2 §4.2) — the replacement for the removed get(Type, dynamicSource) form. Builds and adopts the descriptor's handle-hybrid leaf: a resource kind yields its heal-in-place handle, a value kind a stable AssetRef. A kind with neither a seamless adapter nor a value channel throws with guidance to use load(Asset.kind(...)). The return type follows the ValueAsset brand (as InferCatalogLeaf does): a value-kind descriptor (Asset.kind('json', …)) returns AssetRef — even for an object payload — while a resource-kind descriptor returns the resource itself, so the type always matches the runtime value. Unlike bare-path get('x.png'), this form is **not instance-deduped by source**: each call builds a fresh leaf, so repeated get(Asset.kind(kind, sameSrc)) accumulates distinct handles (all healing to the same deduped backend payload). It is the dynamic-source escape hatch — capture the handle once." }, { "name": "get", - "signature": "get(path: LoadByPath extends Sound | Texture ? S : never, options?: unknown): LoadByPath", + "signature": "get(asset: Asset): T", "signatureTokens": [ { "text": "get", @@ -751,7 +672,7 @@ "kind": "punctuation" }, { - "text": "path", + "text": "asset", "kind": "param" }, { @@ -759,7 +680,7 @@ "kind": "punctuation" }, { - "text": "LoadByPath", + "text": "Asset", "kind": "type" }, { @@ -767,7 +688,7 @@ "kind": "punctuation" }, { - "text": "S", + "text": "T", "kind": "type" }, { @@ -775,64 +696,51 @@ "kind": "punctuation" }, { - "text": " ", - "kind": "punctuation" - }, - { - "text": "extends", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "Sound", - "kind": "type" - }, - { - "text": " | ", + "text": ")", "kind": "punctuation" }, { - "text": "Texture", - "kind": "type" - }, - { - "text": " ? ", + "text": ": ", "kind": "punctuation" }, { - "text": "S", + "text": "T", "kind": "type" - }, + } + ], + "params": [ { - "text": " : ", - "kind": "punctuation" - }, + "name": "asset", + "type": "Asset", + "optional": false + } + ], + "returnType": "T", + "description": "Seamless deferred access by path (asset-system v2). Returns SYNCHRONOUSLY and never throws: an already-loaded source returns the stored resource; 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 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. 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(leaf: T): T", + "signatureTokens": [ { - "text": "never", - "kind": "keyword" + "text": "get", + "kind": "name" }, { - "text": ", ", + "text": "(", "kind": "punctuation" }, { - "text": "options", + "text": "leaf", "kind": "param" }, - { - "text": "?", - "kind": "punctuation" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "unknown", - "kind": "keyword" + "text": "T", + "kind": "type" }, { "text": ")", @@ -843,43 +751,26 @@ "kind": "punctuation" }, { - "text": "LoadByPath", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", + "text": "T", "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" } ], "params": [ { - "name": "path", - "type": "LoadByPath extends Sound | Texture ? S : never", + "name": "leaf", + "type": "T", "optional": false - }, - { - "name": "options", - "type": "unknown", - "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": "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": "get", - "signature": "get(type: typeof Json, source: string, options?: unknown): AssetRef", + "name": "hasAssetType", + "signature": "hasAssetType(typeName: string): boolean", "signatureTokens": [ { - "text": "get", + "text": "hasAssetType", "kind": "name" }, { @@ -887,7 +778,7 @@ "kind": "punctuation" }, { - "text": "type", + "text": "typeName", "kind": "param" }, { @@ -895,2284 +786,85 @@ "kind": "punctuation" }, { - "text": "typeof", + "text": "string", "kind": "keyword" }, { - "text": " ", - "kind": "punctuation" - }, - { - "text": "Json", - "kind": "type" - }, - { - "text": ", ", + "text": ")", "kind": "punctuation" }, - { - "text": "source", - "kind": "param" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "string", + "text": "boolean", "kind": "keyword" - }, + } + ], + "params": [ { - "text": ", ", - "kind": "punctuation" - }, + "name": "typeName", + "type": "string", + "optional": false + } + ], + "returnType": "boolean", + "description": "Returns true if a type-name mapping is already registered." + }, + { + "name": "hasExtension", + "signature": "hasExtension(ext: string): boolean", + "signatureTokens": [ { - "text": "options", - "kind": "param" + "text": "hasExtension", + "kind": "name" }, { - "text": "?", + "text": "(", "kind": "punctuation" }, { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "T", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof Json", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof TextAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "TextAsset", - "kind": "type" - }, - { - "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": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof TextAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof CsvAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "CsvAsset", - "kind": "type" - }, - { - "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": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof CsvAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof XmlAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "XmlAsset", - "kind": "type" - }, - { - "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": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "Document", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof XmlAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof SubtitleAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "SubtitleAsset", - "kind": "type" - }, - { - "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": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "VTTCue", - "kind": "type" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof SubtitleAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "BinaryAsset", - "kind": "type" - }, - { - "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": "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": "typeof BinaryAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "WasmAsset", - "kind": "type" - }, - { - "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": "unknown", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetRef", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "Module", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof WasmAsset", - "optional": false - }, - { - "name": "source", - "type": "string", - "optional": false - }, - { - "name": "options", - "type": "unknown", - "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." - }, - { - "name": "get", - "signature": "get(type: T, alias: string): LoadReturn", - "signatureTokens": [ - { - "text": "get", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "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": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "boolean", - "kind": "keyword" - } - ], - "params": [ - { - "name": "ext", - "type": "string", - "optional": false - } - ], - "returnType": "boolean", - "description": "Returns true if a file extension is already mapped to an asset type. Extension is normalised (leading dot stripped, lower-cased)." - }, - { - "name": "hasLoadable", - "signature": "hasLoadable(type: AssetConstructor): boolean", - "signatureTokens": [ - { - "text": "hasLoadable", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetConstructor", - "kind": "type" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "boolean", - "kind": "keyword" - } - ], - "params": [ - { - "name": "type", - "type": "AssetConstructor", - "optional": false - } - ], - "returnType": "boolean", - "description": "Returns true if a handler or factory is already registered for the given constructor." - }, - { - "name": "keyFor", - "signature": "keyFor(resource: object): { source: string; type: AssetConstructor } | null", - "signatureTokens": [ - { - "text": "keyFor", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "resource", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "object", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "source", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "AssetConstructor", - "kind": "type" - }, - { - "text": " }", - "kind": "punctuation" - }, - { - "text": " | ", - "kind": "punctuation" - }, - { - "text": "null", - "kind": "keyword" - } - ], - "params": [ - { - "name": "resource", - "type": "object", - "optional": false - } - ], - "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 Json, 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": "Json", - "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": "T", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof Json", - "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 Json, 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": "Json", - "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": "T", - "kind": "type" - }, - { - "text": "[]", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof Json", - "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 Json, 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": "Json", - "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": "T", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "typeof Json", - "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(asset: Asset): LoadingQueue", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "asset", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Asset", - "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": "asset", - "type": "Asset", - "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(assets: Assets): LoadingQueue>", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "assets", - "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": "LoadingQueue", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "InferLoadedMap", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "M", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "assets", - "type": "Assets", - "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(config: M): LoadingQueue>", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "config", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "M", - "kind": "type" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "LoadingQueue", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "InferLoadedMap", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "M", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "config", - "type": "M", - "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", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "path", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "[", - "kind": "punctuation" - }, - { - "text": "MatchExtension", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "Basename", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "StripQueryHash", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "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": ": ", - "kind": "punctuation" - }, - { - "text": "LoadingQueue", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "R", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "path", - "type": "[MatchExtension>>] extends [never] ? never : S", - "optional": false - } - ], - "returnType": "LoadingQueue", - "description": "Fetches an asset by path, inferring the type from the file extension. Built-in extension mappings: - .fnt → BmFont - .woff, .woff2, .ttf, .otf → FontFace (family inferred from filename) Register additional mappings via registerExtension. Extend the return type by augmenting ExtensionTypeMap. Paths whose extension is **not** in ExtensionTypeMap are rejected at compile time — use the token form (load(MyType, path)) for unregistered extensions. ts const font = await loader.load('fonts/ui.fnt'); // BmFont const face = await loader.load('fonts/Roboto.woff2'); // FontFace, family='Roboto' const bm = await loader.load('fonts/ui.fnt'); // validated cast " - }, - { - "name": "load", - "signature": "load(path: [MatchExtension>>] extends [never] ? never : S): LoadingQueue>", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "path", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "[", - "kind": "punctuation" - }, - { - "text": "MatchExtension", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "Basename", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "StripQueryHash", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "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": ": ", - "kind": "punctuation" - }, - { - "text": "LoadingQueue", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "LoadByPath", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "path", - "type": "[MatchExtension>>] extends [never] ? never : S", - "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(type: ConstrainedLoadable, path: S, options?: unknown): LoadingQueue>", - "signatureTokens": [ - { - "text": "load", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "type", - "kind": "param" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "ConstrainedLoadable", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "T", - "kind": "type" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "S", - "kind": "type" - }, - { - "text": ">", - "kind": "punctuation" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "path", - "kind": "param" - }, - { - "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": "LoadReturn", - "kind": "type" + "text": "ext", + "kind": "param" }, { - "text": "<", + "text": ": ", "kind": "punctuation" }, { - "text": "T", - "kind": "type" + "text": "string", + "kind": "keyword" }, { - "text": ">", + "text": ")", "kind": "punctuation" }, { - "text": ">", + "text": ": ", "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" } ], "params": [ { - "name": "type", - "type": "ConstrainedLoadable", - "optional": false - }, - { - "name": "path", - "type": "S", + "name": "ext", + "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)." + "returnType": "boolean", + "description": "Returns true if a file extension is already mapped to an asset type. Extension is normalised (leading dot stripped, lower-cased)." }, { - "name": "load", - "signature": "load(type: T, paths: readonly string[], options?: unknown): LoadingQueue[]>", + "name": "hasLoadable", + "signature": "hasLoadable(type: AssetConstructor): boolean", "signatureTokens": [ { - "text": "load", + "text": "hasLoadable", "kind": "name" }, { @@ -3188,47 +880,58 @@ "kind": "punctuation" }, { - "text": "T", + "text": "AssetConstructor", "kind": "type" }, { - "text": ", ", + "text": ")", "kind": "punctuation" }, - { - "text": "paths", - "kind": "param" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "readonly", + "text": "boolean", "kind": "keyword" - }, + } + ], + "params": [ { - "text": " ", - "kind": "punctuation" - }, + "name": "type", + "type": "AssetConstructor", + "optional": false + } + ], + "returnType": "boolean", + "description": "Returns true if a handler or factory is already registered for the given constructor." + }, + { + "name": "keyFor", + "signature": "keyFor(resource: object): { source: string; type: AssetConstructor } | null", + "signatureTokens": [ { - "text": "string", - "kind": "keyword" + "text": "keyFor", + "kind": "name" }, { - "text": "[]", + "text": "(", "kind": "punctuation" }, { - "text": ", ", + "text": "resource", + "kind": "param" + }, + { + "text": ": ", "kind": "punctuation" }, { - "text": "options", - "kind": "param" + "text": "object", + "kind": "keyword" }, { - "text": "?", + "text": ")", "kind": "punctuation" }, { @@ -3236,73 +939,63 @@ "kind": "punctuation" }, { - "text": "unknown", - "kind": "keyword" + "text": "{ ", + "kind": "punctuation" }, { - "text": ")", - "kind": "punctuation" + "text": "source", + "kind": "name" }, { "text": ": ", "kind": "punctuation" }, { - "text": "LoadingQueue", - "kind": "type" + "text": "string", + "kind": "keyword" }, { - "text": "<", + "text": "; ", "kind": "punctuation" }, { - "text": "LoadReturn", - "kind": "type" + "text": "type", + "kind": "name" }, { - "text": "<", + "text": ": ", "kind": "punctuation" }, { - "text": "T", + "text": "AssetConstructor", "kind": "type" }, { - "text": ">", + "text": " }", "kind": "punctuation" }, { - "text": "[]", + "text": " | ", "kind": "punctuation" }, { - "text": ">", - "kind": "punctuation" + "text": "null", + "kind": "keyword" } ], "params": [ { - "name": "type", - "type": "T", - "optional": false - }, - { - "name": "paths", - "type": "readonly string[]", + "name": "resource", + "type": "object", "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)." + "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: T, items: Readonly>, options?: unknown): LoadingQueue>>", + "signature": "load(asset: Asset): LoadingQueue", "signatureTokens": [ { "text": "load", @@ -3313,31 +1006,39 @@ "kind": "punctuation" }, { - "text": "type", + "text": "asset", "kind": "param" }, { "text": ": ", "kind": "punctuation" }, + { + "text": "Asset", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, { "text": "T", "kind": "type" }, { - "text": ", ", + "text": ">", "kind": "punctuation" }, { - "text": "items", - "kind": "param" + "text": ")", + "kind": "punctuation" }, { "text": ": ", "kind": "punctuation" }, { - "text": "Readonly", + "text": "LoadingQueue", "kind": "type" }, { @@ -3345,29 +1046,56 @@ "kind": "punctuation" }, { - "text": "Record", + "text": "T", "kind": "type" }, { - "text": "<", + "text": ">", "kind": "punctuation" + } + ], + "params": [ + { + "name": "asset", + "type": "Asset", + "optional": false + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." + }, + { + "name": "load", + "signature": "load(assets: Assets, options?: LoadOptions): LoadingQueue>", + "signatureTokens": [ + { + "text": "load", + "kind": "name" }, { - "text": "K", - "kind": "type" + "text": "(", + "kind": "punctuation" }, { - "text": ", ", + "text": "assets", + "kind": "param" + }, + { + "text": ": ", "kind": "punctuation" }, { - "text": "BatchValue", + "text": "Assets", "kind": "type" }, { - "text": ">", + "text": "<", "kind": "punctuation" }, + { + "text": "M", + "kind": "type" + }, { "text": ">", "kind": "punctuation" @@ -3389,8 +1117,8 @@ "kind": "punctuation" }, { - "text": "unknown", - "kind": "keyword" + "text": "LoadOptions", + "kind": "type" }, { "text": ")", @@ -3409,7 +1137,7 @@ "kind": "punctuation" }, { - "text": "Record", + "text": "InferLoadedMap", "kind": "type" }, { @@ -3417,15 +1145,55 @@ "kind": "punctuation" }, { - "text": "K", + "text": "M", "kind": "type" }, { - "text": ", ", + "text": ">", "kind": "punctuation" }, { - "text": "LoadReturn", + "text": ">", + "kind": "punctuation" + } + ], + "params": [ + { + "name": "assets", + "type": "Assets", + "optional": false + }, + { + "name": "options", + "type": "LoadOptions", + "optional": true + } + ], + "returnType": "LoadingQueue>", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." + }, + { + "name": "load", + "signature": "load(leaf: AssetRef, options?: LoadOptions): LoadingQueue", + "signatureTokens": [ + { + "text": "load", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "leaf", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetRef", "kind": "type" }, { @@ -3441,45 +1209,24 @@ "kind": "punctuation" }, { - "text": ">", + "text": ", ", "kind": "punctuation" }, { - "text": ">", - "kind": "punctuation" - } - ], - "params": [ - { - "name": "type", - "type": "T", - "optional": false + "text": "options", + "kind": "param" }, { - "name": "items", - "type": "Readonly>", - "optional": false + "text": "?", + "kind": "punctuation" }, { - "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": "loadAll", - "signature": "loadAll(): Promise", - "signatureTokens": [ - { - "text": "loadAll", - "kind": "name" + "text": ": ", + "kind": "punctuation" }, { - "text": "(", - "kind": "punctuation" + "text": "LoadOptions", + "kind": "type" }, { "text": ")", @@ -3490,7 +1237,7 @@ "kind": "punctuation" }, { - "text": "Promise", + "text": "LoadingQueue", "kind": "type" }, { @@ -3498,24 +1245,35 @@ "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" + "text": "T", + "kind": "type" }, { "text": ">", "kind": "punctuation" } ], - "params": [], - "returnType": "Promise", - "description": "Starts backgroundLoad and returns a promise that resolves when every queued background asset has finished loading (successfully or not). Individual asset errors are reported via onError but do not reject the returned promise." + "params": [ + { + "name": "leaf", + "type": "AssetRef", + "optional": false + }, + { + "name": "options", + "type": "LoadOptions", + "optional": true + } + ], + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." }, { - "name": "loadBundle", - "signature": "loadBundle(name: string, options: LoadBundleOptions): Promise", + "name": "load", + "signature": "load(leaf: T, options?: LoadOptions): LoadingQueue", "signatureTokens": [ { - "text": "loadBundle", + "text": "load", "kind": "name" }, { @@ -3523,7 +1281,7 @@ "kind": "punctuation" }, { - "text": "name", + "text": "leaf", "kind": "param" }, { @@ -3531,8 +1289,8 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "T", + "kind": "type" }, { "text": ", ", @@ -3542,12 +1300,16 @@ "text": "options", "kind": "param" }, + { + "text": "?", + "kind": "punctuation" + }, { "text": ": ", "kind": "punctuation" }, { - "text": "LoadBundleOptions", + "text": "LoadOptions", "kind": "type" }, { @@ -3559,7 +1321,7 @@ "kind": "punctuation" }, { - "text": "Promise", + "text": "LoadingQueue", "kind": "type" }, { @@ -3567,8 +1329,8 @@ "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" + "text": "T", + "kind": "type" }, { "text": ">", @@ -3577,25 +1339,25 @@ ], "params": [ { - "name": "name", - "type": "string", + "name": "leaf", + "type": "T", "optional": false }, { "name": "options", - "type": "LoadBundleOptions", - "optional": false + "type": "LoadOptions", + "optional": true } ], - "returnType": "Promise", - "description": "Loads all assets declared in the named bundle concurrently. Dispatches onBundleProgress (and the optional onProgress callback) after each asset completes. If any assets fail, a BundleLoadError is thrown after all assets have settled, containing every individual failure. Throws immediately if name has not been registered." + "returnType": "LoadingQueue", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." }, { - "name": "loadContainer", - "signature": "loadContainer(url: string): Promise", + "name": "load", + "signature": "load(path: [MatchExtension>>] extends [never] ? never : S): LoadingQueue", "signatureTokens": [ { - "text": "loadContainer", + "text": "load", "kind": "name" }, { @@ -3603,7 +1365,7 @@ "kind": "punctuation" }, { - "text": "url", + "text": "path", "kind": "param" }, { @@ -3611,19 +1373,27 @@ "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "[", + "kind": "punctuation" }, { - "text": ")", + "text": "MatchExtension", + "kind": "type" + }, + { + "text": "<", "kind": "punctuation" }, { - "text": ": ", + "text": "Basename", + "kind": "type" + }, + { + "text": "<", "kind": "punctuation" }, { - "text": "Promise", + "text": "StripQueryHash", "kind": "type" }, { @@ -3631,46 +1401,31 @@ "kind": "punctuation" }, { - "text": "void", - "kind": "keyword" + "text": "S", + "kind": "type" }, { "text": ">", "kind": "punctuation" - } - ], - "params": [ - { - "name": "url", - "type": "string", - "optional": false - } - ], - "returnType": "Promise", - "description": "Load every asset packed into a binary container (.exoa) in a **single request**. Unlike loadBundle (one fetch per entry), a container is one file with an embedded index: its bytes are fetched once (and cached cross-session like any asset), then each slice is unpacked through its type's handler and stored under the entry's alias — retrievable with get exactly as if it had been loaded individually. Each entry's asset type must support byte-source construction (AssetHandler.createFromBytes); the factory-backed core types (textures, audio, JSON, text, binary, …) do. Throws on a malformed container, an unknown type, or a type that cannot be built from bytes." - }, - { - "name": "peek", - "signature": "peek(type: typeof Json, alias: string): T | null", - "signatureTokens": [ + }, { - "text": "peek", - "kind": "name" + "text": ">", + "kind": "punctuation" }, { - "text": "(", + "text": ">", "kind": "punctuation" }, { - "text": "type", - "kind": "param" + "text": "]", + "kind": "punctuation" }, { - "text": ": ", + "text": " ", "kind": "punctuation" }, { - "text": "typeof", + "text": "extends", "kind": "keyword" }, { @@ -3678,25 +1433,33 @@ "kind": "punctuation" }, { - "text": "Json", - "kind": "type" + "text": "[", + "kind": "punctuation" }, { - "text": ", ", - "kind": "punctuation" + "text": "never", + "kind": "keyword" }, { - "text": "alias", - "kind": "param" + "text": "]", + "kind": "punctuation" }, { - "text": ": ", + "text": " ? ", "kind": "punctuation" }, { - "text": "string", + "text": "never", "kind": "keyword" }, + { + "text": " : ", + "kind": "punctuation" + }, + { + "text": "S", + "kind": "type" + }, { "text": ")", "kind": "punctuation" @@ -3706,39 +1469,38 @@ "kind": "punctuation" }, { - "text": "T", + "text": "LoadingQueue", "kind": "type" }, { - "text": " | ", + "text": "<", "kind": "punctuation" }, { - "text": "null", - "kind": "keyword" + "text": "R", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "type", - "type": "typeof Json", - "optional": false - }, - { - "name": "alias", - "type": "string", + "name": "path", + "type": "[MatchExtension>>] extends [never] ? never : S", "optional": false } ], - "returnType": "T | null", - "description": "Returns the loaded asset for alias, or null if it has not been loaded yet. Non-throwing alternative to get." + "returnType": "LoadingQueue", + "description": "Fetches an asset by path, inferring the type from the file extension. Built-in extension mappings: - .fnt → BmFont - .woff, .woff2, .ttf, .otf → FontFace (family inferred from filename) Register additional mappings via defineAsset (its extensions). Extend the return type by augmenting ExtensionTypeMap. Paths whose extension is **not** in ExtensionTypeMap are rejected at compile time — use the descriptor form (load(Asset.kind(kind, path))) for unregistered extensions. ts const font = await loader.load('fonts/ui.fnt'); // BmFont const face = await loader.load('fonts/Roboto.woff2'); // FontFace, family='Roboto' const bm = await loader.load('fonts/ui.fnt'); // validated cast " }, { - "name": "peek", - "signature": "peek(type: T, alias: string): LoadReturn | null", + "name": "load", + "signature": "load(path: [MatchExtension>>] extends [never] ? never : S): LoadingQueue>", "signatureTokens": [ { - "text": "peek", + "text": "load", "kind": "name" }, { @@ -3746,7 +1508,7 @@ "kind": "punctuation" }, { - "text": "type", + "text": "path", "kind": "param" }, { @@ -3754,35 +1516,27 @@ "kind": "punctuation" }, { - "text": "T", - "kind": "type" - }, - { - "text": ", ", + "text": "[", "kind": "punctuation" }, { - "text": "alias", - "kind": "param" + "text": "MatchExtension", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" - }, - { - "text": ")", - "kind": "punctuation" + "text": "Basename", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "LoadReturn", + "text": "StripQueryHash", "kind": "type" }, { @@ -3790,7 +1544,7 @@ "kind": "punctuation" }, { - "text": "T", + "text": "S", "kind": "type" }, { @@ -3798,79 +1552,67 @@ "kind": "punctuation" }, { - "text": " | ", + "text": ">", "kind": "punctuation" }, { - "text": "null", - "kind": "keyword" - } - ], - "params": [ + "text": ">", + "kind": "punctuation" + }, { - "name": "type", - "type": "T", - "optional": false + "text": "]", + "kind": "punctuation" }, { - "name": "alias", - "type": "string", - "optional": false - } - ], - "returnType": "LoadReturn | null", - "description": "Returns the loaded asset for alias, or null if it has not been loaded yet. Non-throwing alternative to get." - }, - { - "name": "register", - "signature": "register(type: AssetConstructor, factory: AssetFactory): this", - "signatureTokens": [ + "text": " ", + "kind": "punctuation" + }, { - "text": "register", - "kind": "name" + "text": "extends", + "kind": "keyword" }, { - "text": "(", + "text": " ", "kind": "punctuation" }, { - "text": "type", - "kind": "param" + "text": "[", + "kind": "punctuation" }, { - "text": ": ", - "kind": "punctuation" + "text": "never", + "kind": "keyword" }, { - "text": "AssetConstructor", - "kind": "type" + "text": "]", + "kind": "punctuation" }, { - "text": "<", + "text": " ? ", "kind": "punctuation" }, { - "text": "T", - "kind": "type" + "text": "never", + "kind": "keyword" }, { - "text": ">", + "text": " : ", "kind": "punctuation" }, { - "text": ", ", - "kind": "punctuation" + "text": "S", + "kind": "type" }, { - "text": "factory", - "kind": "param" + "text": ")", + "kind": "punctuation" }, { "text": ": ", "kind": "punctuation" }, { - "text": "AssetFactory", + "text": "LoadingQueue", "kind": "type" }, { @@ -3878,47 +1620,42 @@ "kind": "punctuation" }, { - "text": "T", + "text": "LoadByPath", "kind": "type" }, { - "text": ">", + "text": "<", "kind": "punctuation" }, { - "text": ")", - "kind": "punctuation" + "text": "S", + "kind": "type" }, { - "text": ": ", + "text": ">", "kind": "punctuation" }, { - "text": "this", - "kind": "keyword" + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "type", - "type": "AssetConstructor", - "optional": false - }, - { - "name": "factory", - "type": "AssetFactory", + "name": "path", + "type": "[MatchExtension>>] extends [never] ? never : S", "optional": false } ], - "returnType": "this", - "description": "Registers a custom AssetFactory for type. Registration is prototype-chain aware: the factory will also handle any subclass of type that does not have its own explicit registration. Built-in types can be overridden by registering a replacement factory for the same constructor." + "returnType": "LoadingQueue>", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." }, { - "name": "registerAssetType", - "signature": "registerAssetType(typeName: K, handler: { getIdentityKey?: unknown; load: unknown }): this", + "name": "load", + "signature": "load(path: [KindByPath] extends [never] ? never : S): LoadingQueue>>", "signatureTokens": [ { - "text": "registerAssetType", + "text": "load", "kind": "name" }, { @@ -3926,7 +1663,7 @@ "kind": "punctuation" }, { - "text": "typeName", + "text": "path", "kind": "param" }, { @@ -3934,147 +1671,95 @@ "kind": "punctuation" }, { - "text": "K", - "kind": "type" - }, - { - "text": ", ", + "text": "[", "kind": "punctuation" }, { - "text": "handler", - "kind": "param" + "text": "KindByPath", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "{ ", - "kind": "punctuation" + "text": "S", + "kind": "type" }, { - "text": "getIdentityKey", - "kind": "name" + "text": ">", + "kind": "punctuation" }, { - "text": "?", + "text": "]", "kind": "punctuation" }, { - "text": ": ", + "text": " ", "kind": "punctuation" }, { - "text": "unknown", + "text": "extends", "kind": "keyword" }, { - "text": "; ", + "text": " ", "kind": "punctuation" }, { - "text": "load", - "kind": "name" - }, - { - "text": ": ", + "text": "[", "kind": "punctuation" }, { - "text": "unknown", + "text": "never", "kind": "keyword" }, { - "text": " }", - "kind": "punctuation" - }, - { - "text": ")", + "text": "]", "kind": "punctuation" }, { - "text": ": ", + "text": " ? ", "kind": "punctuation" }, { - "text": "this", + "text": "never", "kind": "keyword" - } - ], - "params": [ - { - "name": "typeName", - "type": "K", - "optional": false - }, - { - "name": "handler", - "type": "{ getIdentityKey?: unknown; load: unknown }", - "optional": false - } - ], - "returnType": "this", - "description": "Associates a string type name with a simple load handler. The handler's load method receives the full config object (including source and every extra field declared via AssetDefinitions augmentation) plus a AssetLoaderContext containing the loader instance. This form is intended for custom asset types that manage their own network access; the persistent cache layer is bypassed." - }, - { - "name": "registerAssetType", - "signature": "registerAssetType(typeName: string, ctor: AssetConstructor, factory?: AssetFactory): this", - "signatureTokens": [ - { - "text": "registerAssetType", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": "typeName", - "kind": "param" }, { - "text": ": ", + "text": " : ", "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "S", + "kind": "type" }, { - "text": ", ", + "text": ")", "kind": "punctuation" }, - { - "text": "ctor", - "kind": "param" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "AssetConstructor", + "text": "LoadingQueue", "kind": "type" }, { - "text": ", ", + "text": "<", "kind": "punctuation" }, { - "text": "factory", - "kind": "param" - }, - { - "text": "?", - "kind": "punctuation" + "text": "ResourceForKind", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "AssetFactory", + "text": "KindByPath", "kind": "type" }, { @@ -4082,52 +1767,38 @@ "kind": "punctuation" }, { - "text": "unknown", - "kind": "keyword" + "text": "S", + "kind": "type" }, { "text": ">", "kind": "punctuation" }, { - "text": ")", + "text": ">", "kind": "punctuation" }, { - "text": ": ", + "text": ">", "kind": "punctuation" - }, - { - "text": "this", - "kind": "keyword" } ], "params": [ { - "name": "typeName", - "type": "string", - "optional": false - }, - { - "name": "ctor", - "type": "AssetConstructor", + "name": "path", + "type": "[KindByPath] extends [never] ? never : S", "optional": false - }, - { - "name": "factory", - "type": "AssetFactory", - "optional": true } ], - "returnType": "this", - "description": "Associates a string type name (e.g. 'tileMap') with the constructor used as the asset token and, optionally, registers a factory for it. Required for declaration-merge extensions of AssetDefinitions so that loader.load({ map: { type: 'tileMap', source: '…' } }) works." + "returnType": "LoadingQueue>>", + "description": "Fetches and processes one or more assets. - **Path string** — inferred from the file extension; resolves the asset. - **Asset** — a single typed asset reference from Asset.kind(...). - **Assets** — a typed catalog from Assets.from(...); keys become aliases. (The inline record-catalog form { alias: { kind, source } } is no longer a public overload — build catalogs with Assets.from(...); a runtime record fallback is retained only for internal multi-alias plumbing.) 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. Per-asset options ride on the Asset.kind(kind, source, options) descriptor (or the extra fields of a config object)." }, { - "name": "registerExtension", - "signature": "registerExtension(ext: string, type: AssetConstructor): this", + "name": "loadContainer", + "signature": "loadContainer(url: string): Promise", "signatureTokens": [ { - "text": "registerExtension", + "text": "loadContainer", "kind": "name" }, { @@ -4135,7 +1806,7 @@ "kind": "punctuation" }, { - "text": "ext", + "text": "url", "kind": "param" }, { @@ -4147,55 +1818,46 @@ "kind": "keyword" }, { - "text": ", ", + "text": ")", "kind": "punctuation" }, - { - "text": "type", - "kind": "param" - }, { "text": ": ", "kind": "punctuation" }, { - "text": "AssetConstructor", + "text": "Promise", "kind": "type" }, { - "text": ")", + "text": "<", "kind": "punctuation" }, { - "text": ": ", - "kind": "punctuation" + "text": "void", + "kind": "keyword" }, { - "text": "this", - "kind": "keyword" + "text": ">", + "kind": "punctuation" } ], "params": [ { - "name": "ext", + "name": "url", "type": "string", "optional": false - }, - { - "name": "type", - "type": "AssetConstructor", - "optional": false } ], - "returnType": "this", - "description": "Associates a file extension with an asset type so that loader.load('path.ext') (the single-string overload) can infer the type automatically. ext is matched case-insensitively and the leading dot is optional. The type must already have a registered factory (via register or registerAssetType). ts loader.registerExtension('tmj', TiledMap); const map = await loader.load('world.tmj'); // TiledMap " + "returnType": "Promise", + "description": "Load every asset packed into a binary container (.exoa) in a **single request**. A container is one file with an embedded index: its bytes are fetched once (and cached cross-session like any asset), then each slice is unpacked through its type's handler and stored under the entry's alias — retrievable with get exactly as if it had been loaded individually. Each entry's asset type must support byte-source construction (AssetHandler.createFromBytes); the factory-backed core types (textures, audio, JSON, text, binary, …) do. Throws on a malformed container, an unknown type, or a type that cannot be built from bytes." }, { - "name": "registerManifest", - "signature": "registerManifest(manifest: AssetManifest): this", + "name": "register", + "signature": "register(type: AssetConstructor, factory: AssetFactory): this", "signatureTokens": [ { - "text": "registerManifest", + "text": "register", "kind": "name" }, { @@ -4203,7 +1865,35 @@ "kind": "punctuation" }, { - "text": "manifest", + "text": "type", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetConstructor", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "factory", "kind": "param" }, { @@ -4211,9 +1901,21 @@ "kind": "punctuation" }, { - "text": "AssetManifest", + "text": "AssetFactory", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "T", "kind": "type" }, + { + "text": ">", + "kind": "punctuation" + }, { "text": ")", "kind": "punctuation" @@ -4229,13 +1931,18 @@ ], "params": [ { - "name": "manifest", - "type": "AssetManifest", + "name": "type", + "type": "AssetConstructor", + "optional": false + }, + { + "name": "factory", + "type": "AssetFactory", "optional": false } ], "returnType": "this", - "description": "Validates and registers an AssetManifest, making its bundles available to loadBundle. Throws if any bundle name is already registered or if two entries for the same (type, alias) pair have conflicting paths or options. Equivalent definitions (same path, deeply-equal options) are allowed and de-duplicated silently." + "description": "Registers a custom AssetFactory for type. Registration is prototype-chain aware: the factory will also handle any subclass of type that does not have its own explicit registration. Built-in types can be overridden by registering a replacement factory for the same constructor." }, { "name": "registerSeamlessAdapter", @@ -4489,7 +2196,7 @@ } ], "returnType": "this", - "description": "Sets the maximum number of simultaneous background-queue fetches. Takes effect on the next backgroundLoad or loadAll call." + "description": "Sets the maximum number of simultaneous background-queue fetches. Takes effect on the next awaitBackground call or load(…, { background })." }, { "name": "unload", @@ -4788,63 +2495,6 @@ "id": "events", "title": "Events", "members": [ - { - "name": "onBundleProgress", - "signature": "onBundleProgress: Signal<[name, loaded, total]>", - "signatureTokens": [ - { - "text": "onBundleProgress", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "Signal", - "kind": "type" - }, - { - "text": "<", - "kind": "punctuation" - }, - { - "text": "[", - "kind": "punctuation" - }, - { - "text": "name", - "kind": "type" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "loaded", - "kind": "type" - }, - { - "text": ", ", - "kind": "punctuation" - }, - { - "text": "total", - "kind": "type" - }, - { - "text": "]", - "kind": "punctuation" - }, - { - "text": ">", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "Dispatched after each asset within a named bundle completes loading." - }, { "name": "onError", "signature": "onError: Signal<[type, alias, error]>", diff --git a/site/src/content/api/object-layer-options.json b/site/src/content/api/object-layer-options.json index 60ca9d9a5..4ee646234 100644 --- a/site/src/content/api/object-layer-options.json +++ b/site/src/content/api/object-layer-options.json @@ -55,7 +55,7 @@ }, { "name": "drawOrder", - "signature": "drawOrder?: \"index\" | \"topdown\"", + "signature": "drawOrder?: \"topdown\" | \"index\"", "signatureTokens": [ { "text": "drawOrder", @@ -70,7 +70,7 @@ "kind": "punctuation" }, { - "text": "\"index\"", + "text": "\"topdown\"", "kind": "keyword" }, { @@ -78,7 +78,7 @@ "kind": "punctuation" }, { - "text": "\"topdown\"", + "text": "\"index\"", "kind": "keyword" } ], diff --git a/site/src/content/api/object-layer.json b/site/src/content/api/object-layer.json index 5bf260af9..2f3d12c9d 100644 --- a/site/src/content/api/object-layer.json +++ b/site/src/content/api/object-layer.json @@ -964,7 +964,7 @@ }, { "name": "drawOrder", - "signature": "drawOrder: \"index\" | \"topdown\"", + "signature": "drawOrder: \"topdown\" | \"index\"", "signatureTokens": [ { "text": "drawOrder", @@ -975,7 +975,7 @@ "kind": "punctuation" }, { - "text": "\"index\"", + "text": "\"topdown\"", "kind": "keyword" }, { @@ -983,7 +983,7 @@ "kind": "punctuation" }, { - "text": "\"topdown\"", + "text": "\"index\"", "kind": "keyword" } ], diff --git a/site/src/content/api/sound.json b/site/src/content/api/sound.json index 02b7e3ff2..799ad5b11 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", @@ -814,7 +843,7 @@ ], "params": [], "returnType": null, - "description": "Load lifecycle of this sound. Directly constructed sounds are 'ready'; deferred handles returned by loader.get(Sound, …) start 'loading' and become 'ready' once the payload fills in, or 'failed' when the load errors." + "description": "Load lifecycle of this sound. Directly constructed sounds are 'ready'; deferred handles returned by loader.get('theme.ogg') / loader.get(Asset.kind('sound', src)) start 'loading' and become 'ready' once the payload fills in, or 'failed' when the load errors." }, { "name": "maxDistance", @@ -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: 'idle' | 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "velocity", "signature": "velocity: Vector | null", diff --git a/site/src/content/api/subtitle-asset.json b/site/src/content/api/subtitle-asset.json index ad62e53bb..fa11876c8 100644 --- a/site/src/content/api/subtitle-asset.json +++ b/site/src/content/api/subtitle-asset.json @@ -1,6 +1,6 @@ { "title": "SubtitleAsset", - "description": "Dispatch token for subtitle loading (WebVTT and SRT). `loader.load(SubtitleAsset, 'subs.vtt')` returns `Promise`. `loader.load(SubtitleAsset, 'subs.srt')` returns `Promise`. Format is detected from the file extension; unknown extensions default to VTT.", + "description": "Dispatch token for subtitle loading (WebVTT and SRT). `loader.load(Asset.kind('vtt', 'subs.vtt'))` returns `Promise`. `loader.load(Asset.kind('vtt', 'subs.srt'))` returns `Promise`. Format is detected from the file extension; unknown extensions default to VTT.", "symbol": "SubtitleAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for subtitle loading (WebVTT and SRT).", - "`loader.load(SubtitleAsset, 'subs.vtt')` returns `Promise`. `loader.load(SubtitleAsset, 'subs.srt')` returns `Promise`. Format is detected from the file extension; unknown extensions default to VTT." + "`loader.load(Asset.kind('vtt', 'subs.vtt'))` returns `Promise`. `loader.load(Asset.kind('vtt', 'subs.srt'))` returns `Promise`. Format is detected from the file extension; unknown extensions default to VTT." ], "importLine": "import { SubtitleAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/svg-asset.json b/site/src/content/api/svg-asset.json index 9c58865a6..9b87177ee 100644 --- a/site/src/content/api/svg-asset.json +++ b/site/src/content/api/svg-asset.json @@ -1,6 +1,6 @@ { "title": "SvgAsset", - "description": "Dispatch token for SVG loading. `loader.load(SvgAsset, 'icon.svg')` returns `Promise`.", + "description": "Dispatch token for SVG loading. `loader.load(Asset.kind('svg', 'icon.svg'))` returns `Promise`.", "symbol": "SvgAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for SVG loading.", - "`loader.load(SvgAsset, 'icon.svg')` returns `Promise`." + "`loader.load(Asset.kind('svg', 'icon.svg'))` returns `Promise`." ], "importLine": "import { SvgAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/text-asset.json b/site/src/content/api/text-asset.json index dad5dc297..ddf5f8a9e 100644 --- a/site/src/content/api/text-asset.json +++ b/site/src/content/api/text-asset.json @@ -1,6 +1,6 @@ { "title": "TextAsset", - "description": "Dispatch token for plain text loading. `loader.load(TextAsset, 'greeting.txt')` returns `Promise`.", + "description": "Dispatch token for plain text loading. `loader.load(Asset.kind('text', 'greeting.txt'))` returns `Promise`.", "symbol": "TextAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for plain text loading.", - "`loader.load(TextAsset, 'greeting.txt')` returns `Promise`." + "`loader.load(Asset.kind('text', 'greeting.txt'))` returns `Promise`." ], "importLine": "import { TextAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/texture-factory-options.json b/site/src/content/api/texture-factory-options.json index 123abdd87..099ed486e 100644 --- a/site/src/content/api/texture-factory-options.json +++ b/site/src/content/api/texture-factory-options.json @@ -55,7 +55,7 @@ }, { "name": "samplerOptions", - "signature": "samplerOptions?: SamplerOptions", + "signature": "samplerOptions?: Partial", "signatureTokens": [ { "text": "samplerOptions", @@ -69,14 +69,26 @@ "text": ": ", "kind": "punctuation" }, + { + "text": "Partial", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, { "text": "SamplerOptions", "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" } ], "params": [], "returnType": null, - "description": "Sampler parameters (wrap mode, filter, etc.) forwarded to the Texture constructor." + "description": "Sampler parameters (wrap mode, filter, etc.) forwarded to the Texture constructor; any subset." } ], "paragraphs": [], diff --git a/site/src/content/api/texture.json b/site/src/content/api/texture.json index 707d85d06..ed6965593 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", @@ -789,7 +818,7 @@ ], "params": [], "returnType": null, - "description": "Load lifecycle of this texture. Directly constructed textures are 'ready'; deferred handles returned by loader.get(Texture, …) start 'loading' and become 'ready' once the payload fills in, or 'failed' (showing the Texture.missing checker) when the load errors." + "description": "Load lifecycle of this texture. Directly constructed textures are 'ready'; deferred handles returned by loader.get('hero.png') / loader.get(Asset.kind('texture', src)) start 'loading' and become 'ready' once the payload fills in, or 'failed' (showing the Texture.missing checker) when the load errors." }, { "name": "powerOfTwo", @@ -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: 'idle' | 'loading' | 'ready' | 'failed' (asset-system v2 §6)." + }, { "name": "version", "signature": "version: number", diff --git a/site/src/content/api/tiled-functions.json b/site/src/content/api/tiled-functions.json index 425aec403..061c2dea6 100644 --- a/site/src/content/api/tiled-functions.json +++ b/site/src/content/api/tiled-functions.json @@ -6,11 +6,11 @@ "subsystem": "tiled", "importPath": "@codexo/exojs-tiled", "tier": "stable", - "memberCount": 4, + "memberCount": 6, "counts": { "constructors": 0, "methods": 2, - "properties": 2, + "properties": 4, "events": 0 }, "sections": [ @@ -200,7 +200,89 @@ ], "params": [], "returnType": null, - "description": "Default immutable Tiled extension descriptor. Registers two asset bindings: - tiledRuntimeMapBinding — loader.load(TileMap, 'world.tmj') → returns a format-independent runtime TileMap (common case). - tiledMapBinding — loader.load(TiledMap, 'world.tmj') → returns the raw parsed TiledMap source model (advanced/diagnostic). Depends on tilemapExtension so that snapshot construction always materialises the generic tilemap runtime before the Tiled adapter. Use with ApplicationOptions.extensions or call import '@codexo/exojs-tiled/register' for global auto-registration." + "description": "Default immutable Tiled extension descriptor. Registers two asset bindings: - tiledRuntimeMapBinding — loader.load(Asset.kind('tileMap', 'world.tmj')) → returns a format-independent runtime TileMap (common case). - tiledMapBinding — loader.load(Asset.kind('tiledMap', 'world.tmj')) → returns the raw parsed TiledMap source model (advanced/diagnostic). Depends on tilemapExtension so that snapshot construction always materialises the generic tilemap runtime before the Tiled adapter. Use with ApplicationOptions.extensions or call import '@codexo/exojs-tiled/register' for global auto-registration." + }, + { + "name": "tiledMapBinding", + "signature": "tiledMapBinding: AssetBinding", + "signatureTokens": [ + { + "text": "tiledMapBinding", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetBinding", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "TiledMap", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "TiledLoadOptions", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Declarative asset binding for TiledMap. loader.load(Asset.kind('tiledMap', 'world.tmj')) resolves through this binding, but no extensions are claimed, so a plain loader.load('world.tmj') does not resolve to TiledMap. The .tmj extension (and generic .json Tiled loading) is reserved for the format-independent TileMap runtime asset binding." + }, + { + "name": "tiledRuntimeMapBinding", + "signature": "tiledRuntimeMapBinding: AssetBinding", + "signatureTokens": [ + { + "text": "tiledRuntimeMapBinding", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "AssetBinding", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "TileMap", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "TiledLoadOptions", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Declarative asset binding for the runtime TileMap produced from a .tmj Tiled map file. This is the common-case binding: loader.load(Asset.kind('tileMap', 'world.tmj')) fetches and validates the TMJ, resolves external .tsj tilesets, loads tileset textures via the sub-load loader.load(Asset.kind('tiledMap', source)), and synchronously converts the parsed TiledMap source model into a format-independent runtime TileMap via TiledMap.toTileMap. The sub-load flow guarantees that calling both load(TileMap) and load(TiledMap) for the same URL shares the Loader's cached TiledMap (no duplicate JSON fetches). Claiming the tmj extension enables the auto-routing shorthand loader.load('world.tmj') — the ExtensionTypeMap augmentation in this package maps 'tmj' → TileMap." } ], "paragraphs": [], diff --git a/site/src/content/api/tiled-load-options.json b/site/src/content/api/tiled-load-options.json index 66434625b..0901a60a9 100644 --- a/site/src/content/api/tiled-load-options.json +++ b/site/src/content/api/tiled-load-options.json @@ -51,7 +51,7 @@ ], "params": [], "returnType": null, - "description": "Explicit format hint for ambiguous file extensions. .tmj/.tsj paths are recognised by extension and need no hint. Provide format: 'tiled' when loading Tiled map data from a generic .json path: loader.load(TileMap, 'world.json', { format: 'tiled' }). 'tiled' is the only accepted value, so a foreign format (e.g. 'ldtk') is a compile error rather than a silent runtime fall-through. The hint participates in the asset identity key, so the same source loaded under different (future) formats resolves to distinct cache entries." + "description": "Explicit format hint for ambiguous file extensions. .tmj/.tsj paths are recognised by extension and need no hint. Provide format: 'tiled' when loading Tiled map data from a generic .json path: loader.load(Asset.kind('tileMap', 'world.json', { format: 'tiled' })). 'tiled' is the only accepted value, so a foreign format (e.g. 'ldtk') is a compile error rather than a silent runtime fall-through. The hint participates in the asset identity key, so the same source loaded under different (future) formats resolves to distinct cache entries." } ], "paragraphs": [], diff --git a/site/src/content/api/tiled-map-binding.json b/site/src/content/api/tiled-map-binding.json deleted file mode 100644 index d4b5b7e0f..000000000 --- a/site/src/content/api/tiled-map-binding.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "title": "tiledMapBinding", - "description": "Declarative asset binding for TiledMap. Token-only: `loader.load(TiledMap, 'world.tmj')` resolves through this binding, but no `extensions` are claimed, so a plain `loader.load('world.tmj')` does not resolve to `TiledMap`. The `.tmj` extension (and generic `.json` Tiled loading) is reserved for the format-independent `TileMap` runtime asset binding.", - "symbol": "tiledMapBinding", - "kind": "namespace", - "subsystem": "tiled", - "importPath": "@codexo/exojs-tiled", - "tier": "stable", - "memberCount": 3, - "counts": { - "constructors": 0, - "methods": 1, - "properties": 2, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Declarative asset binding for TiledMap.", - "Token-only: `loader.load(TiledMap, 'world.tmj')` resolves through this binding, but no `extensions` are claimed, so a plain `loader.load('world.tmj')` does not resolve to `TiledMap`. The `.tmj` extension (and generic `.json` Tiled loading) is reserved for the format-independent `TileMap` runtime asset binding." - ], - "importLine": "import { tiledMapBinding } from '@codexo/exojs-tiled'", - "sourceLink": null - }, - { - "id": "methods", - "title": "Methods", - "members": [ - { - "name": "create", - "signature": "create(): { getIdentityKey: unknown; load: unknown }", - "signatureTokens": [ - { - "text": "create", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "getIdentityKey", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "load", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" - } - ], - "params": [], - "returnType": "{ getIdentityKey: unknown; load: unknown }", - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "type", - "signature": "type: typeof TiledMap", - "signatureTokens": [ - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "TiledMap", - "kind": "type" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "typeNames", - "signature": "typeNames: string[]", - "signatureTokens": [ - { - "text": "typeNames", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "packages/exojs-tiled/src/tiledMapBinding.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tiled/src/tiledMapBinding.ts" - } - } - ], - "sourcePath": "packages/exojs-tiled/src/tiledMapBinding.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tiled/src/tiledMapBinding.ts" -} diff --git a/site/src/content/api/tiled-runtime-map-binding.json b/site/src/content/api/tiled-runtime-map-binding.json deleted file mode 100644 index e175fb377..000000000 --- a/site/src/content/api/tiled-runtime-map-binding.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "title": "tiledRuntimeMapBinding", - "description": "Declarative asset binding for the runtime TileMap produced from a `.tmj` Tiled map file. This is the common-case binding: `loader.load(TileMap, 'world.tmj')` fetches and validates the TMJ, resolves external `.tsj` tilesets, loads tileset textures via the sub-load `loader.load(TiledMap, source)`, and synchronously converts the parsed TiledMap source model into a format-independent runtime TileMap via TiledMap.toTileMap. The sub-load flow guarantees that calling both `load(TileMap)` and `load(TiledMap)` for the same URL shares the Loader's cached `TiledMap` (no duplicate JSON fetches). Claiming the `tmj` extension enables the auto-routing shorthand `loader.load('world.tmj')` — the ExtensionTypeMap augmentation in this package maps `'tmj' → TileMap`.", - "symbol": "tiledRuntimeMapBinding", - "kind": "namespace", - "subsystem": "tiled", - "importPath": "@codexo/exojs-tiled", - "tier": "stable", - "memberCount": 4, - "counts": { - "constructors": 0, - "methods": 1, - "properties": 3, - "events": 0 - }, - "sections": [ - { - "id": "import", - "title": "Import", - "members": [], - "paragraphs": [ - "Declarative asset binding for the runtime TileMap produced from a `.tmj` Tiled map file.", - "This is the common-case binding: `loader.load(TileMap, 'world.tmj')` fetches and validates the TMJ, resolves external `.tsj` tilesets, loads tileset textures via the sub-load `loader.load(TiledMap, source)`, and synchronously converts the parsed TiledMap source model into a format-independent runtime TileMap via TiledMap.toTileMap.", - "The sub-load flow guarantees that calling both `load(TileMap)` and `load(TiledMap)` for the same URL shares the Loader's cached `TiledMap` (no duplicate JSON fetches).", - "Claiming the `tmj` extension enables the auto-routing shorthand `loader.load('world.tmj')` — the ExtensionTypeMap augmentation in this package maps `'tmj' → TileMap`." - ], - "importLine": "import { tiledRuntimeMapBinding } from '@codexo/exojs-tiled'", - "sourceLink": null - }, - { - "id": "methods", - "title": "Methods", - "members": [ - { - "name": "create", - "signature": "create(): { getIdentityKey: unknown; load: unknown }", - "signatureTokens": [ - { - "text": "create", - "kind": "name" - }, - { - "text": "(", - "kind": "punctuation" - }, - { - "text": ")", - "kind": "punctuation" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "getIdentityKey", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": "; ", - "kind": "punctuation" - }, - { - "text": "load", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "unknown", - "kind": "keyword" - }, - { - "text": " }", - "kind": "punctuation" - } - ], - "params": [], - "returnType": "{ getIdentityKey: unknown; load: unknown }", - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "properties", - "title": "Properties", - "members": [ - { - "name": "extensions", - "signature": "extensions: string[]", - "signatureTokens": [ - { - "text": "extensions", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "type", - "signature": "type: typeof TileMap", - "signatureTokens": [ - { - "text": "type", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "typeof", - "kind": "keyword" - }, - { - "text": " ", - "kind": "punctuation" - }, - { - "text": "TileMap", - "kind": "type" - } - ], - "params": [], - "returnType": null, - "description": "" - }, - { - "name": "typeNames", - "signature": "typeNames: string[]", - "signatureTokens": [ - { - "text": "typeNames", - "kind": "name" - }, - { - "text": ": ", - "kind": "punctuation" - }, - { - "text": "string", - "kind": "keyword" - }, - { - "text": "[]", - "kind": "punctuation" - } - ], - "params": [], - "returnType": null, - "description": "" - } - ], - "paragraphs": [], - "importLine": null, - "sourceLink": null - }, - { - "id": "source", - "title": "Source", - "members": [], - "paragraphs": [], - "importLine": null, - "sourceLink": { - "label": "packages/exojs-tiled/src/tiledRuntimeMapBinding.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts" - } - } - ], - "sourcePath": "packages/exojs-tiled/src/tiledRuntimeMapBinding.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tiled/src/tiledRuntimeMapBinding.ts" -} diff --git a/site/src/content/api/batch-value.json b/site/src/content/api/value-asset.json similarity index 57% rename from site/src/content/api/batch-value.json rename to site/src/content/api/value-asset.json index 1f40edd81..7f640142a 100644 --- a/site/src/content/api/batch-value.json +++ b/site/src/content/api/value-asset.json @@ -1,7 +1,7 @@ { - "title": "BatchValue", - "description": "Accepted value types in the homogeneous batch load API. Either a raw source string or a flat config object containing at least `source` plus any type-specific extra fields.", - "symbol": "BatchValue", + "title": "ValueAsset", + "description": "A value/ref-kind asset descriptor (asset-system v2 delta §4). Structurally an Asset, but branded so a catalog classifies its leaf as a deferred `AssetRef` — even when `T` is an object type (e.g. typed JSON), where the plain `T extends object` heuristic would otherwise misread it as a resource. The brand is a phantom (never present at runtime).", + "symbol": "ValueAsset", "kind": "type", "subsystem": "resources", "importPath": "@codexo/exojs", @@ -19,10 +19,9 @@ "title": "Import", "members": [], "paragraphs": [ - "Accepted value types in the homogeneous batch load API.", - "Either a raw source string or a flat config object containing at least `source` plus any type-specific extra fields." + "A value/ref-kind asset descriptor (asset-system v2 delta §4). Structurally an Asset, but branded so a catalog classifies its leaf as a deferred `AssetRef` — even when `T` is an object type (e.g. typed JSON), where the plain `T extends object` heuristic would otherwise misread it as a resource. The brand is a phantom (never present at runtime)." ], - "importLine": "import { BatchValue } from '@codexo/exojs'", + "importLine": "import { ValueAsset } from '@codexo/exojs'", "sourceLink": null }, { @@ -30,35 +29,23 @@ "title": "Definition", "members": [ { - "name": "BatchValue", - "signature": "string | { source: string } & Record", + "name": "ValueAsset", + "signature": "Asset & { [VALUE_ASSET]: true }", "signatureTokens": [ { - "text": "string", - "kind": "keyword" - }, - { - "text": " | ", - "kind": "punctuation" - }, - { - "text": "{ ", - "kind": "punctuation" - }, - { - "text": "source", - "kind": "name" + "text": "Asset", + "kind": "type" }, { - "text": ": ", + "text": "<", "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "T", + "kind": "type" }, { - "text": " }", + "text": ">", "kind": "punctuation" }, { @@ -66,27 +53,23 @@ "kind": "punctuation" }, { - "text": "Record", - "kind": "type" - }, - { - "text": "<", + "text": "{ ", "kind": "punctuation" }, { - "text": "string", - "kind": "keyword" + "text": "[VALUE_ASSET]", + "kind": "name" }, { - "text": ", ", + "text": ": ", "kind": "punctuation" }, { - "text": "unknown", + "text": "true", "kind": "keyword" }, { - "text": ">", + "text": " }", "kind": "punctuation" } ], @@ -106,11 +89,11 @@ "paragraphs": [], "importLine": null, "sourceLink": { - "label": "src/resources/Loader.ts", - "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" + "label": "src/resources/Asset.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Asset.ts" } } ], - "sourcePath": "src/resources/Loader.ts", - "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Loader.ts" + "sourcePath": "src/resources/Asset.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/src/resources/Asset.ts" } diff --git a/site/src/content/api/wasm-asset.json b/site/src/content/api/wasm-asset.json index f262db0ed..49f229922 100644 --- a/site/src/content/api/wasm-asset.json +++ b/site/src/content/api/wasm-asset.json @@ -1,6 +1,6 @@ { "title": "WasmAsset", - "description": "Dispatch token for WebAssembly module loading. `loader.load(WasmAsset, 'module.wasm')` returns `Promise`.", + "description": "Dispatch token for WebAssembly module loading. `loader.load(Asset.kind('wasm', 'module.wasm'))` returns `Promise`.", "symbol": "WasmAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for WebAssembly module loading.", - "`loader.load(WasmAsset, 'module.wasm')` returns `Promise`." + "`loader.load(Asset.kind('wasm', 'module.wasm'))` returns `Promise`." ], "importLine": "import { WasmAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/api/xml-asset.json b/site/src/content/api/xml-asset.json index 63b49101e..ebd8a196f 100644 --- a/site/src/content/api/xml-asset.json +++ b/site/src/content/api/xml-asset.json @@ -1,6 +1,6 @@ { "title": "XmlAsset", - "description": "Dispatch token for XML document loading. `loader.load(XmlAsset, 'data.xml')` returns `Promise`. Throws if the file cannot be parsed as well-formed XML.", + "description": "Dispatch token for XML document loading. `loader.load(Asset.kind('xml', 'data.xml'))` returns `Promise`. Throws if the file cannot be parsed as well-formed XML.", "symbol": "XmlAsset", "kind": "class", "subsystem": "resources", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Dispatch token for XML document loading.", - "`loader.load(XmlAsset, 'data.xml')` returns `Promise`. Throws if the file cannot be parsed as well-formed XML." + "`loader.load(Asset.kind('xml', 'data.xml'))` returns `Promise`. Throws if the file cannot be parsed as well-formed XML." ], "importLine": "import { XmlAsset } from '@codexo/exojs'", "sourceLink": null diff --git a/site/src/content/guide/assets/aseprite.mdx b/site/src/content/guide/assets/aseprite.mdx index 264138c9d..056da4fcb 100644 --- a/site/src/content/guide/assets/aseprite.mdx +++ b/site/src/content/guide/assets/aseprite.mdx @@ -90,11 +90,11 @@ Once the extension is active, load the JSON export by the `AsepriteSheet` type t import { AsepriteSheet } from '@codexo/exojs-aseprite'; // By type token (most explicit) -const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); +const sheet = await loader.load(Asset.kind('asepriteSheet', 'sprites/hero.json')); // By config-map type name — the public `'asepriteSheet'` lookup const fromConfig = await loader.load({ - hero: { type: 'asepriteSheet', source: 'sprites/hero.json' }, + hero: { kind: 'asepriteSheet', source: 'sprites/hero.json' }, }); ``` @@ -112,7 +112,7 @@ playable clip. Call `play(tag)` with a tag name to start it: ```js async load(loader) { - this.sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); + this.sheet = await loader.load(Asset.kind('asepriteSheet', 'sprites/hero.json')); } init() { @@ -179,7 +179,7 @@ maps it directly onto [`AnimatedSpriteClipDefinition.repeat`](/ExoJS/en/api/anim ```ts no-check import { AsepriteSheet } from '@codexo/exojs-aseprite'; -const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); +const sheet = await loader.load(Asset.kind('asepriteSheet', 'sprites/hero.json')); const player = sheet.createAnimatedSprite(); player.onComplete.add(clip => { diff --git a/site/src/content/guide/assets/ldtk.mdx b/site/src/content/guide/assets/ldtk.mdx index 2ad4805b9..72e64279e 100644 --- a/site/src/content/guide/assets/ldtk.mdx +++ b/site/src/content/guide/assets/ldtk.mdx @@ -82,14 +82,14 @@ registered), or by the `'ldtkMap'` config-map type name: import { LdtkMap } from '@codexo/exojs-ldtk'; // By type token (most explicit) -const world = await loader.load(LdtkMap, 'https://example.com/levels/world.ldtk'); +const world = await loader.load(Asset.kind('ldtkMap', 'https://example.com/levels/world.ldtk')); // By path — the `.ldtk` extension is registered, so the type is inferred const sameWorld = await loader.load('https://example.com/levels/world.ldtk'); // By config-map type name — the public `'ldtkMap'` lookup const fromConfig = await loader.load({ - world: { type: 'ldtkMap', source: 'https://example.com/levels/world.ldtk' }, + world: { kind: 'ldtkMap', source: 'https://example.com/levels/world.ldtk' }, }); ``` diff --git a/site/src/content/guide/assets/loading-and-resources.mdx b/site/src/content/guide/assets/loading-and-resources.mdx index 3848f04b7..0db98c788 100644 --- a/site/src/content/guide/assets/loading-and-resources.mdx +++ b/site/src/content/guide/assets/loading-and-resources.mdx @@ -20,16 +20,16 @@ For most scenes you don't need to think about ownership — just use the `loader ## Choose the form that fits -The loader offers several overloads. Pick the smallest one that covers your case: +The loader offers several forms. Pick the smallest one that covers your case: | Form | Best for | |------|----------| -| `loader.load(Texture, 'hero.png')` | One asset, no alias needed | -| `loader.load(Texture, { hero: 'hero.png' })` | Several assets of the same type | -| `loader.load({ hero: { type: 'texture', … }, … })` | Mixed types in one call | -| `new Asset(…)` / `new Assets(…)` | Reusable, file-level asset references | +| `loader.load('hero.png')` | One asset, path doubles as its identity — no alias needed | +| `Asset.kind('texture', path)` / `Asset.kind('sound', path)` / `Asset.kind('json', path)` | The path is computed at runtime (not a literal), or you want an explicit type | +| `Assets.from({ hero: 'hero.png', … })` | A reusable, named group of assets shared across scenes | +| `loader.load({ hero: { kind: 'texture', … }, … })` | Mixed types in one call, config objects with type-specific options | -When in doubt, start simple. Promote to `Asset`/`Assets` when the same definition is referenced from more than one place. +When in doubt, start with a bare path string. Reach for `Assets.from` when the same group of assets is referenced from more than one place, and `X.of(...)` whenever the path isn't a string literal. Awaiting loads sequentially makes each one wait for the previous download to finish. Group independent `loader.load(...)` calls in a single `Promise.all` so unrelated resource types fetch concurrently. @@ -37,79 +37,84 @@ Awaiting loads sequentially makes each one wait for the previous download to fin ## Loading a single asset -The path-string form resolves directly to the finished asset: +The path *is* the identity — a bare string resolves directly to the finished asset, with its type inferred from the extension: ```js async load(loader) { - const texture = await loader.load(Texture, 'image/hero.png'); + const texture = await loader.load('image/hero.png'); this.hero = new Sprite(texture); } ``` -The trade-off is no alias — the texture is not registered for retrieval by name later. Use the record form when you'll call `loader.get(...)` after the promise resolves. +There's no separate alias step: call `loader.get('image/hero.png')` again anywhere later — same string, same `Texture` instance. `Texture`, `Sound`, and the data tokens (`Json`, `TextAsset`, `CsvAsset`, …) resolve this way from their file extension. Types that can't be inferred from a path (`AudioStream`, `Video`, `BmFont`, `FontAsset`, …) need an explicit token — see [`X.of(...)`](#dynamic-paths-and-non-seamless-types) below. ## Homogeneous batch -When you need several assets of the same type, pass a record: +When you need several assets of the same type, load them in parallel and keep the returned instances: ```js async load(loader) { - await loader.load(Texture, { - hero: 'image/hero.png', - ground: 'image/ground.png', - coin: 'image/coin.png', - }); -} - -init(loader) { - this.hero = new Sprite(loader.get(Texture, 'hero')); - this.ground = new Sprite(loader.get(Texture, 'ground')); + [this.hero, this.ground, this.coin] = await Promise.all([ + loader.load('image/hero.png'), + loader.load('image/ground.png'), + loader.load('image/coin.png'), + ]); } ``` -Keys are aliases — names you choose. The `loader.basePath` option in `ApplicationOptions` prepends a base prefix to all relative URLs, so these keys resolve to e.g. `assets/image/hero.png`. +The `loader.basePath` option in `ApplicationOptions` prepends a base prefix to all relative paths, so these resolve to e.g. `assets/image/hero.png`. -Calling `loader.get(Texture, 'hero')` returns the loaded `Texture` instance synchronously — the promise from `loader.load(...)` already settled before `init` ran. +Because the path is the identity, `loader.get('image/hero.png')` returns the very same `Texture` instance from anywhere else in your code — `init`, a later scene, a system — as long as the string matches. - -`loader.get(...)` never returns `undefined` as a fallback — ask for an alias you didn't register in `load` and it throws. Usually that means a typo in the name or a forgotten `loader.load(...)`, so you want it to fail loudly. + +For `Texture`, `Sound`, and the data tokens, `loader.get(...)` always returns synchronously — even before the fetch finishes. Ask for a path you haven't loaded yet and you get a placeholder handle back immediately, which then heals in place once the network request resolves. Check `.ready` / `.state` (see [Status channel](#status-channel-instead-of-throwing) below) instead of an existence check or try/catch. -Values can also be config objects when you need type-specific fields alongside the source: +## Dynamic paths and non-seamless types -```js -await loader.load(Texture, { - hero: 'image/hero.png', - heroAlt: { source: 'image/hero-alt.png', /* type-specific options */ }, -}); +A bare string only works for a *literal* path — the type is inferred at compile time from the extension. When the path is computed at runtime, or the type can't be inferred from a path at all, use the type's `X.of(...)` factory: + +```ts +import { Asset, Texture, AudioStream } from '@codexo/exojs'; + +// Dynamic path — not a string literal, so it needs an explicit token +async _changeLevel(levelName: string) { + const bg = await this.app.loader.load(Asset.kind('texture', `image/levels/${levelName}.png`)); + this.background.setTexture(bg); +} + +// AudioStream, Video, BmFont, FontAsset, ImageAsset don't have a bare-path +// form at all — they always need .of(), even for a literal path +const music = await loader.load(Asset.kind('music', 'audio/theme.ogg')); ``` + +`X.of(...)` produces a loadable reference, and only `loader.load(...)` accepts it. `loader.get(X.of(...))` is not a supported call — for a literal path use `loader.get('literal.png')` directly; for a dynamic path, `await loader.load(X.of(path))` and hold onto the resolved value instead of trying to `get()` it back out synchronously. + + ## Multiple resource types in parallel When a scene needs different asset types, load them in parallel with `Promise.all`: ```js -import { Texture, AudioStream, Sound, Json } from '@codexo/exojs'; - async load(loader) { - await Promise.all([ - loader.load(Texture, { sky: 'image/sky.png' }), - loader.load(AudioStream, { ambient: 'audio/ambient.ogg' }), - loader.load(Sound, { coin: 'audio/coin.wav', jump: 'audio/jump.wav' }), - loader.load(Json, { levels: 'data/levels.json' }), + [this.sky, this.ambient, this.coin, this.jump, this.levels] = await Promise.all([ + loader.load('image/sky.png'), + loader.load(Asset.kind('music', 'audio/ambient.ogg')), + loader.load('audio/coin.wav'), + loader.load('audio/jump.wav'), + loader.load('data/levels.json'), ]); } init(loader) { - this.sky = new Sprite(loader.get(Texture, 'sky')); - this.ambient = loader.get(AudioStream, 'ambient'); - this.levels = loader.get(Json, 'levels').value; + this.sky = new Sprite(this.sky); } ``` Wrapping the calls in `Promise.all` lets unrelated asset categories load in parallel. -For value assets such as `Json` (and `TextAsset`, `CsvAsset`, and the other data tokens), `loader.get(...)` hands back a lightweight `AssetRef` rather than the raw data. Read its `.value` to get the parsed result — after `load` has resolved, as above, `.value` is available synchronously. Resource assets like `Texture` and `AudioStream` still return their instance directly. +For value assets such as `Json` (and `TextAsset`, `CsvAsset`, and the other data tokens), `loader.load(...)` resolves directly to the parsed value. `loader.get(...)` on the same path hands back a lightweight `AssetRef` instead — read its `.value` once `.ready` is `true` (see below). ## Mixed asset map @@ -117,7 +122,7 @@ To load different types in a single call and receive a single typed result objec ```js async load(loader) { - await loader.load({ + const { hero, click } = await loader.load({ hero: { type: 'texture', source: 'image/hero.png', @@ -127,46 +132,45 @@ async load(loader) { source: 'audio/click.ogg', }, }); + + this.hero = new Sprite(hero); + this.click = click; } ``` -The result is an object whose keys match the input and whose values are the resolved resource instances. Both `loader.get(Texture, 'hero')` and `loader.get(Sound, 'click')` work after the promise resolves. +The result is an object whose keys match the input and whose values are the resolved resource instances directly — no separate `get()` step needed, though `loader.get('image/hero.png')` / `loader.get('audio/click.ogg')` still work afterward since the source path remains the identity. ## Reusable asset references -For assets used in multiple scenes, define them as file-level constants with `Asset` and `Assets`: +For assets used in multiple scenes, define a named catalog with `Assets.from`: ```ts -import { Asset, Assets } from '@codexo/exojs'; - -// A single typed, loadable reference -export const HeroAsset = new Asset({ - type: 'texture', - source: 'image/hero.png', -}); +import { Asset, Assets, AudioStream, Json } from '@codexo/exojs'; -// A typed group of related assets -export const TitleAssets = new Assets({ - logo: { type: 'texture', source: '/logo.png' }, - music: { type: 'music', source: '/title.ogg' }, // 'music' loads an AudioStream +export const TitleAssets = Assets.from({ + logo: 'sprites/logo.png', // bare path → Texture + music: Asset.kind('music', 'audio/title.ogg'), // non-seamless → needs .of() + config: Asset.kind<{ startLevel: string }>('json', 'data/config.json'), }); ``` -Load them directly: +Every property is a real, usable handle *before* any loader touches it — `TitleAssets.logo` is already a `Texture` (in the `'loading'` state), and `TitleAssets.config` is already an `AssetRef`. Load the whole catalog, then read the same properties: ```js -// Resolves to Texture -const hero = await loader.load(HeroAsset); +// Fetches every entry and heals each handle in place +await loader.load(TitleAssets); -// Resolves to { logo: Texture, music: AudioStream } -const title = await loader.load(TitleAssets); +// Same objects as before load() — now populated +this.logo = new Sprite(TitleAssets.logo); +this.startLevel = TitleAssets.config.value; ``` -Typed properties on `Assets` give autocomplete on the container itself: +Typed properties on the catalog give autocomplete and type inference for free: ```ts -TitleAssets.logo; // Asset -TitleAssets.music; // Asset +TitleAssets.logo; // Texture +TitleAssets.music; // AudioStream +TitleAssets.config; // AssetRef<{ startLevel: string }> ``` `Assets` also exposes a `.entries` property for spread composition — useful when you want to merge several asset groups into a single load call without nesting them: @@ -239,7 +243,7 @@ async load(loader) { ## Signals for a global loading screen -`LoadingQueue.onProgress` and `onBundleProgress` (above) are scoped to one call or one bundle. When you want a single loading screen that reflects *everything* the loader is doing — regardless of how many separate `loader.load(...)` calls triggered it — subscribe to the loader instance's own signals instead: `onLoadStart`, `onLoadProgress`, `onLoadComplete`, and `onLoadError`. These track one shared, loader-wide batch: as long as any foreground load is in flight, new loads join the same batch instead of starting a new one. +`LoadingQueue.onProgress` (above) is scoped to a single `loader.load(...)` call. When you want a single loading screen that reflects *everything* the loader is doing — regardless of how many separate `loader.load(...)` calls triggered it — subscribe to the loader instance's own signals instead: `onLoadStart`, `onLoadProgress`, `onLoadComplete`, and `onLoadError`. These track one shared, loader-wide batch: as long as any foreground load is in flight, new loads join the same batch instead of starting a new one. | Signal | Payload | Fires | |--------|---------|-------| @@ -279,147 +283,151 @@ class BootScene extends Scene { Unlike `LoadingQueue.onProgress`, which you attach to the return value of one `loader.load(...)` call, these four are properties on the `Loader` itself — subscribe once (for example in your boot scene's `init`) and they report every foreground load for the lifetime of that loader. -## Aliases and identity +## Status channel instead of throwing -Aliases come from the keys you supply in a record or `Assets` container. The same `Asset` reference can appear under multiple aliases — the loader issues only one network request and stores the result under each alias independently: +For `Texture`, `Sound`, and the data tokens, the path (or `X.of(...)` reference) *is* the identity — there's no separate alias to register or forget. Every handle and `AssetRef` exposes the same small status contract: + +```ts +interface AssetStatus { + readonly state: 'loading' | 'ready' | 'failed'; + readonly ready: boolean; // true iff state === 'ready' + readonly error: Error | null; +} +``` ```js -const HeroAsset = new Asset({ type: 'texture', source: 'image/hero.png' }); +const tex = loader.get('hero.png'); // synchronous, never throws + +if (tex.ready) { + // draw it +} else if (tex.state === 'failed') { + // tex still renders a visible "missing" checker texture +} + +await tex.loaded; // Promise — resolves once ready, rejects on failure +``` -await loader.load({ heroA: HeroAsset, heroB: HeroAsset }); +The same `.state` / `.ready` / `.error` / `.loaded` contract applies to `AssetRef` (from `Json`, `TextAsset`, and the other value tokens) — read `.value` once `.ready` is `true`: -loader.get(Texture, 'heroA'); // Texture -loader.get(Texture, 'heroB'); // same Texture instance +```js +const config = loader.get('data/config.json'); // AssetRef +await config.loaded; +console.log(config.value.startLevel); ``` -`loader.unload(asset)` removes all aliases mapped to that asset's identity at once: +`loader.unload(...)` releases a handle's claim; the object itself keeps its identity and heals back to `'loading'` if you `get()` the same source again: ```js -loader.unload(HeroAsset); // removes heroA and heroB +loader.unload(TitleAssets); // releases every leaf in the catalog ``` + +Types that aren't seamless — `AudioStream`, `Video`, `BmFont`, `FontAsset`, and custom types — have no placeholder to hand back, so there's nothing to `get()` speculatively. Load them by reference and hold onto the resolved value: `const music = await loader.load(Asset.kind('music', 'audio/theme.ogg'))`. There is no `has()`/`peek()` guard — for a seamless handle read `.ready` / `.state`; for a non-seamless resource, `await` the load (or its returned handle's `.loaded` promise) before you use it. + + ## When are resources available? The lifecycle guarantees that: -- Inside `load`, you can `await loader.load(...)` to register and fetch assets. -- After `load` resolves, every alias you registered is available via `loader.get(...)`. -- `init` runs once `load` is complete — it is safe to read assets there. -- After `init`, the same `loader` is still available. Subsequent calls to `loader.get(...)` from `update`, `draw`, or other places work as long as the asset was loaded. - -If you call `loader.get(SomeType, 'unknown-alias')`, the loader throws — there is no silent fallback. This is deliberate: a missing asset usually means you forgot to register it in `load`, and you want to catch that immediately. +- Inside `load`, you can `await loader.load(...)` to fetch assets. +- `init` runs once `load` is complete — it is safe to read assets there, and every seamless handle you loaded is already `.ready`. +- After `init`, the same `loader` is still available. Subsequent calls to `loader.get('same/path.png')` from `update`, `draw`, or other places return the same instance. +- Calling `loader.get('a/path/you-never-loaded.png')` from anywhere kicks off the fetch itself and hands back a `'loading'` placeholder — check `.ready` before relying on the payload being present. ## Loading on demand -`loader.load(...)` works any time, not just inside the `load` hook: +`loader.load(...)` works any time, not just inside the `load` hook. A computed path isn't a string literal, so use `Asset.kind('texture', ...)`: ```js async _changeLevel(levelName) { - await this.app.loader.load(Texture, { - [`level-${levelName}`]: `image/levels/${levelName}.png`, - }); - - this.background.setTexture( - this.app.loader.get(Texture, `level-${levelName}`) + const bg = await this.app.loader.load( + Asset.kind('texture', `image/levels/${levelName}.png`) ); + this.background.setTexture(bg); } ``` The frame loop continues running while the promise is pending. -## Manifests and bundles +## Loading per game phase -For larger projects, declare all assets in a manifest and load bundles on demand: +Split a large project into per-phase catalogs and load each one as the player reaches it, instead of fetching everything up front. A catalog is just an `Assets.from(...)` group (see [Reusable asset references](#reusable-asset-references)): -```js -import { defineAssetManifest } from '@codexo/exojs'; - -const manifest = defineAssetManifest({ - bundles: { - menu: [ - { type: Texture, alias: 'logo', path: 'image/logo.png' }, - { type: AudioStream, alias: 'theme', path: 'audio/theme.ogg' }, - ], - 'level-1': [ - { type: Texture, alias: 'tiles', path: 'image/level-1/tiles.png' }, - { type: Json, alias: 'map', path: 'data/level-1.json' }, - ], - }, -}); +```ts +import { Asset, Assets, AudioStream, Json } from '@codexo/exojs'; -loader.registerManifest(manifest); +export const MenuAssets = Assets.from({ + logo: 'image/logo.png', + music: Asset.kind('music', 'audio/theme.ogg'), +}); -// Later: -await loader.loadBundle('menu'); -await loader.loadBundle('level-1'); +export const Level1Assets = Assets.from({ + tiles: 'image/level-1/tiles.png', + map: Asset.kind<{ spawn: [number, number] }>('json', 'data/level-1.json'), +}); ``` -`defineAssetManifest` validates the shape at authoring time — non-empty bundle names, valid entries, no duplicate aliases within a type — so a typo throws where you declare it, not deep inside a load. After a bundle resolves, read its assets by the aliases you declared, exactly like a direct load: +Load the menu catalog once and keep it resident, then load level catalogs as the player progresses: ```js -await loader.loadBundle('menu'); +// In the menu scene +async load(loader) { + await loader.load(MenuAssets); +} -this.logo = new Sprite(loader.get(Texture, 'logo')); -this.theme = loader.get(AudioStream, 'theme'); +// Later, when entering gameplay +async _enterLevel1() { + await this.app.loader.load(Level1Assets); + this.tiles = Level1Assets.tiles; + this.map = Level1Assets.map.value; +} ``` -Bundles are useful when scene loads should be granular — load the menu bundle once and keep it resident, then load level bundles as the player progresses. Treat bundle names and aliases as part of your project's asset contract. +Loading is idempotent: a catalog whose leaves are already resident resolves immediately without re-fetching, so you can call `loader.load(Level1Assets)` again from anywhere without a guard. Treat catalog names and their keys as part of your project's asset contract. ### Progress for a loading screen -`loadBundle` accepts an `onProgress` callback that fires after each asset in the bundle settles — drive a progress bar with it: +`loader.load(catalog)` returns a `LoadingQueue` — attach `onProgress` for a per-catalog bar, exactly as in [Progress and parallel loading](#progress-and-parallel-loading): ```js -await loader.loadBundle('level-1', { - onProgress: (loaded, total) => { - this.bar.value = loaded / total; - }, -}); -``` +const loading = loader.load(Level1Assets); -For a single screen that reflects every bundle, subscribe to the loader-wide `onBundleProgress` signal instead. It reports the bundle name alongside the counts: - -```js -loader.onBundleProgress.add((name, loaded, total) => { - console.log(`${name}: ${loaded}/${total}`); +loading.onProgress.add((p) => { + this.bar.value = p.loaded / p.total; }); + +await loading; ``` +For one screen that reflects every load regardless of how many catalogs are in flight, use the loader-wide signals from [Signals for a global loading screen](#signals-for-a-global-loading-screen). + ### Handling failures -If any asset in a bundle fails, `loadBundle` waits for the rest to settle and then throws a `BundleLoadError`. Its `failures` array lists every entry that errored — the type, alias, and underlying error — so you can show a retry prompt or fall back to a placeholder rather than dropping the whole bundle: +Awaiting a catalog rejects if any leaf fails. Wrap the await and read each leaf's status channel to find which one — a failed seamless handle still renders a visible "missing" fallback, so the scene keeps running: ```js -import { BundleLoadError } from '@codexo/exojs'; - try { - await loader.loadBundle('level-1'); -} catch (error) { - if (error instanceof BundleLoadError) { - for (const failure of error.failures) { - console.warn(`Failed: ${failure.alias}`, failure.error); - } - // retry, substitute a fallback asset, or surface a message + await loader.load(Level1Assets); +} catch { + if (Level1Assets.tiles.state === 'failed') { + console.warn('tiles failed:', Level1Assets.tiles.error); + // substitute a fallback, or surface a retry prompt } } ``` +There is no separate bundle error type — every leaf carries its own `.state` / `.error` (see [Status channel](#status-channel-instead-of-throwing)). + ### Pre-warming in the background -To load a bundle ahead of time without blocking the current scene, pass `background: true`. The bundle loads through a low-priority queue while the frame loop keeps running: +To fetch a catalog ahead of time without blocking the current scene, pass `{ background: true }`. Every leaf is still claimed and heals in place, but its fetch is routed through a low-priority queue while the frame loop keeps running: ```js // Kick off the next level while the player is still in this one -loader.loadBundle('level-2', { background: true }); +loader.load(Level2Assets, { background: true }); ``` -Guard against re-loading with `loader.hasBundle(name)`, which returns `true` once every asset in the bundle is resident in memory: - -```js -if (!loader.hasBundle('level-2')) { - await loader.loadBundle('level-2'); -} -``` +A backgrounded leaf is boosted to fetch immediately if something `get()`s or foreground-`load()`s it before the queue reaches it — so there's nothing to guard against. Request it in the background early, then `await loader.load(Level2Assets)` when you actually need it and it resolves as soon as the in-flight fetch finishes. `loader.awaitBackground()` resolves once the background queue has fully drained. ## Caching and IndexedDB @@ -476,61 +484,93 @@ All three share one async interface, so swapping `WebStorageStore` for `IndexedD ## Custom asset types (advanced) -Register a string type name to teach the loader about domain-specific resources. First, extend `AssetDefinitions` via declaration merging: +Teach the loader about a domain-specific resource with `defineAsset`. One descriptor feeds three consumers: the loader-local handler, the kind's placeholder strategy, and — for leaf-capable kinds — bare-path inference. + +First extend `AssetDefinitions` via declaration merging so the type system knows the new kind, then call `defineAsset`: ```ts +import { Asset, defineAsset } from '@codexo/exojs'; +import type { AssetHandler } from '@codexo/exojs/extensions'; + +// 1. The resource your handler produces. +export class HeightField { + public constructor(public readonly rows: readonly number[][]) {} + + // Annotation static — how the type is loaded by reference (a dynamic path, + // per-asset options, or a key inside `Assets.from({...})`). + public static of(source: string): Asset { + return new Asset({ kind: 'heightField', source }) as unknown as Asset; + } +} + +// 2. Register the kind with the type system (declaration merging). declare module '@codexo/exojs' { interface AssetDefinitions { - tileMap: { - resource: TileMapData; - config: { - source: string; - format: 'tmx' | 'tiled-json'; - }; - }; + heightField: { resource: HeightField; config: { source: string } }; } } -``` - -Then register the handler: - -```ts -loader.registerAssetType('tileMap', { - getIdentityKey(config) { - // Two configs with the same source but different format are distinct assets - return `${config.source}:${config.format}`; - }, - async load(config, context) { - const text = await context.fetchText(config.source); - return parseTileMap(text, config.format); +// 3. Bind the kind to a loader-local handler. Returns an AssetBinding. +export const heightFieldBinding = defineAsset({ + type: HeightField, + kind: 'heightField', + isValue: false, // a resource loaded by reference — see the note below + create() { + return { + async load(req, ctx) { + const text = await ctx.fetchText(req.source); + return new HeightField(parseHeightField(text)); + }, + } satisfies AssetHandler; }, }); + +function parseHeightField(text: string): number[][] { + return text.split('\n').map((row) => row.split(',').map(Number)); +} ``` -The handler context provides: +The handler `context` routes fetches through the loader's cache strategy and IndexedDB stores: -| Method | Returns | +| Member | Returns | |--------|---------| | `context.fetchText(source)` | `Promise` | | `context.fetchJson(source)` | `Promise` | | `context.fetchArrayBuffer(source)` | `Promise` | -| `context.identityKey` | `string` — the active in-flight key for diagnostics | +| `context.loader` | the `Loader` — load sub-assets (e.g. a texture the file references) through it | -Using `context.fetch*` routes through the loader's cache strategy and IDB stores. `getIdentityKey` controls in-flight deduplication — omit it when `source` alone determines the result; supply it when extra config fields change the output. +Supply an optional `getIdentityKey(req)` on the handler to control in-flight deduplication — omit it when `source` alone determines the result; supply it when extra config fields change the output. -Once registered, all `Asset` and `Assets` APIs work for your type: +Install the binding by passing it on an [`Extension`](/ExoJS/en/api/extension/) to `ApplicationOptions.extensions`. Once installed, the type works everywhere the built-ins do: -```ts -const map = new Asset({ - type: 'tileMap', - source: 'maps/level-1.tmx', - format: 'tmx', +```js +// Dynamic path or explicit reference +const field = await loader.load(HeightField.of('maps/level-1.hf')); + +// Or grouped in a catalog +const World = Assets.from({ level1: HeightField.of('maps/level-1.hf') }); +``` + + +`HeightField.of(...)` always works. To *also* allow a bare `loader.load('maps/level-1.hf')` (extension-inferred), the kind must be **leaf-capable** — either a value kind (`isValue: true`) or a resource with a `seamless` adapter that heals in place — and it must declare its file suffixes. Register them on the descriptor and mirror the suffix→kind mapping in the type system: + +```ts no-check +export const heightFieldBinding = defineAsset({ + type: HeightField, + kind: 'heightField', + seamless: heightFieldSeamlessAdapter, // heals in place, like Texture/Sound + extensions: ['hf'], + create() { /* … */ }, }); -const tileMap: TileMapData = await loader.load(map); +declare module '@codexo/exojs' { + interface ExtensionKindMap { hf: 'heightField'; } +} ``` +The built-in `texture` and `sound` bindings are the reference for a seamless resource; `json` / `text` for a value kind. + + ## Examples diff --git a/site/src/content/guide/assets/tiled-maps.mdx b/site/src/content/guide/assets/tiled-maps.mdx index 33a6f1df1..78c0e01c8 100644 --- a/site/src/content/guide/assets/tiled-maps.mdx +++ b/site/src/content/guide/assets/tiled-maps.mdx @@ -87,14 +87,14 @@ XML (`.tmx`/`.tsx`), infinite maps, isometric or hexagonal orientations, collect import { TiledMap } from '@codexo/exojs-tiled'; // By type token (most explicit) -const map = await loader.load(TiledMap, 'levels/forest.tmj'); +const map = await loader.load(Asset.kind('tiledMap', 'levels/forest.tmj')); // By path — the `.tmj` extension is registered, so the type is inferred const sameMap = await loader.load('levels/forest.tmj'); // By config-map type name — the public `'tiledMap'` lookup const fromConfig = await loader.load({ - level: { type: 'tiledMap', source: 'levels/forest.tmj' }, + level: { kind: 'tiledMap', source: 'levels/forest.tmj' }, }); ``` diff --git a/site/src/content/guide/audio/audio-basics.mdx b/site/src/content/guide/audio/audio-basics.mdx index 1ea878288..9be782fe9 100644 --- a/site/src/content/guide/audio/audio-basics.mdx +++ b/site/src/content/guide/audio/audio-basics.mdx @@ -28,18 +28,19 @@ Use `Sound` when you need many overlapping instances of the same short clip. Use Both types are loaded through the `Loader`, like textures, then played through `app.audio`: ```js -import { AudioStream, Scene, Sound } from '@codexo/exojs'; +import { Asset, AudioStream, Scene } from '@codexo/exojs'; class AudioScene extends Scene { async load(loader) { - await loader.load(Sound, { laser: 'audio/laser.ogg' }); - await loader.load(AudioStream, { theme: 'audio/theme.ogg' }); + const [laser, theme] = await Promise.all([ + loader.load('audio/laser.ogg'), + loader.load(Asset.kind('music', 'audio/theme.ogg')), + ]); + this.laser = laser; + this.theme = theme; } - init(loader) { - this.laser = loader.get(Sound, 'laser'); - this.theme = loader.get(AudioStream, 'theme'); - + init() { // Playing returns a live Voice — keep it to control this instance. this.themeVoice = this.app.audio.play(this.theme, { loop: true, volume: 0.6 }); } @@ -139,7 +140,7 @@ Pass `{ toVolume }` to fade the incoming voice to something other than full, and For rapid-fire SFX (gunshots, footsteps, UI clicks), set `poolSize` higher and use the default FIFO strategy: ```js -const gunshot = loader.get(Sound, 'gunshot'); +const gunshot = loader.get('audio/gunshot.ogg'); gunshot.poolSize = 24; // ... hold spacebar to fire rapidly ... diff --git a/site/src/content/guide/audio/audio-effects.mdx b/site/src/content/guide/audio/audio-effects.mdx index a08e23ea9..75b403759 100644 --- a/site/src/content/guide/audio/audio-effects.mdx +++ b/site/src/content/guide/audio/audio-effects.mdx @@ -236,10 +236,9 @@ Convolves the signal with a real impulse response instead of `ReverbEffect`'s pr ```js import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { Sound } from '@codexo/exojs'; -await loader.load(Sound, { hall: 'hall.wav' }); -const convReverb = new ConvolutionEffect({ impulse: loader.get(Sound, 'hall'), wet: 0.6 }); +const hall = await loader.load('hall.wav'); +const convReverb = new ConvolutionEffect({ impulse: hall, wet: 0.6 }); app.audio.master.addEffect(convReverb); ``` @@ -280,7 +279,7 @@ app.audio.registerBus(ambientBus); ambientBus.addEffect(new ReverbEffect({ wet: 0.6, durationSeconds: 3 })); ambientBus.addEffect(new LowpassFilter({ frequency: 800 })); -const wind = loader.get(Sound, 'wind'); +const wind = loader.get('audio/wind.ogg'); // Route this play onto the ambient bus — reverb + lowpass applied. this.app.audio.play(wind, { bus: ambientBus, loop: true }); ``` diff --git a/site/src/content/guide/audio/audio-reactive-visualization.mdx b/site/src/content/guide/audio/audio-reactive-visualization.mdx index 59bb7bd1e..fd7a1f9ae 100644 --- a/site/src/content/guide/audio/audio-reactive-visualization.mdx +++ b/site/src/content/guide/audio/audio-reactive-visualization.mdx @@ -25,16 +25,15 @@ Both the analyser and the beat detector connect as parallel taps. They never aff An `AudioAnalyser` wraps a Web Audio `AnalyserNode` and exposes frequency and time-domain data. You point it at a source, then call its getters each frame: ```js -import { AudioStream, Scene } from '@codexo/exojs'; +import { Asset, AudioStream, Scene } from '@codexo/exojs'; import { AudioAnalyser } from '@codexo/exojs-audio-fx'; class VisualizerScene extends Scene { async load(loader) { - await loader.load(AudioStream, { track: 'audio/track.ogg' }); + this.music = await loader.load(Asset.kind('music', 'audio/track.ogg')); } - init(loader) { - this.music = loader.get(AudioStream, 'track'); + init() { this.app.audio.play(this.music, { loop: true }); // Tap the bus the track plays through. @@ -192,10 +191,8 @@ For ring-buffer patterns where only the newest column changes, `commitRect(col, Here is a complete scene that combines an analyser-driven bar graph with beat-triggered background color changes: ```js -import { - Application, AudioStream, - Color, Graphics, Scene, -} from '@codexo/exojs'; +import { Asset, Application, AudioStream, + Color, Graphics, Scene } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; const app = new Application({ canvas: { width: 800, height: 600 } }); @@ -203,13 +200,12 @@ document.body.append(app.canvas); class AudioReactiveScene extends Scene { async load(loader) { - await loader.load(AudioStream, { track: 'audio/track.ogg' }); + this.music = await loader.load(Asset.kind('music', 'audio/track.ogg')); } - init(loader) { + init() { const { width, height } = this.app.canvas; - this.music = loader.get(AudioStream, 'track'); this.app.audio.play(this.music, { loop: true }); // Both taps read the music bus the track plays through. diff --git a/site/src/content/guide/audio/beat-detection.mdx b/site/src/content/guide/audio/beat-detection.mdx index f727686f0..d214a3963 100644 --- a/site/src/content/guide/audio/beat-detection.mdx +++ b/site/src/content/guide/audio/beat-detection.mdx @@ -17,10 +17,10 @@ This chapter covers what the detector does and the events it fires. The next cha The detector taps a live audio source — an `AudioBus`, a `Voice`, an `AudioNode`, or a `MediaStream` — as a parallel branch that does not affect the source's main signal chain. It does not accept a `Sound`/`AudioStream` descriptor; tap the bus the audio plays through (e.g. `app.audio.music`) or the individual `Voice` you got back from `play()`: ```js -import { AudioStream } from '@codexo/exojs'; +import { Asset, AudioStream } from '@codexo/exojs'; import { BeatDetector } from '@codexo/exojs-audio-fx'; -const track = loader.get(AudioStream, 'track'); +const track = await loader.load(Asset.kind('music', 'audio/track.ogg')); this.app.audio.play(track, { loop: true }); const detector = new BeatDetector(); diff --git a/site/src/content/guide/audio/spatial-audio.mdx b/site/src/content/guide/audio/spatial-audio.mdx index 7e6c364bc..b657a82ec 100644 --- a/site/src/content/guide/audio/spatial-audio.mdx +++ b/site/src/content/guide/audio/spatial-audio.mdx @@ -43,9 +43,7 @@ Valid target types are: A `Sound` descriptor carries a `position`. When set, it seeds the spatial position of every voice played from that sound: ```js -import { Sound } from '@codexo/exojs'; - -const pickup = loader.get(Sound, 'pickup-sfx'); +const pickup = loader.get('audio/pickup.wav'); pickup.position = { x: 320, y: 180 }; // seeds the voice's initial position this.app.audio.play(pickup); ``` @@ -108,7 +106,7 @@ The most common pattern is to set the listener's target to the camera or player ```js init(loader) { - this.pickupSfx = loader.get(Sound, 'pickup-sfx'); + this.pickupSfx = loader.get('audio/pickup.wav'); this.pickupSfx.volume = 0.6; app.audio.listener.target = this.player; @@ -131,7 +129,7 @@ A `Sound` you never give a `position` plays non-spatially — full volume in bot For ambient sounds that should follow the listener's general area but not overlap, set a large `refDistance`: ```js -const waterfall = loader.get(Sound, 'waterfall'); +const waterfall = loader.get('audio/waterfall.ogg'); waterfall.distanceModel = 'inverse'; waterfall.refDistance = 300; waterfall.position = { x: 600, y: 200 }; diff --git a/site/src/content/guide/debugging/custom-renderers.mdx b/site/src/content/guide/debugging/custom-renderers.mdx index 6f90a7e05..5880d238d 100644 --- a/site/src/content/guide/debugging/custom-renderers.mdx +++ b/site/src/content/guide/debugging/custom-renderers.mdx @@ -21,7 +21,7 @@ Reach for `CallbackRenderPass` for almost all custom draw work — it slots into ```ts -import { CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite, Texture } from '@codexo/exojs'; +import { CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, Scene, Sprite } from '@codexo/exojs'; class CustomPassScene extends Scene { private back!: Sprite; @@ -31,8 +31,8 @@ class CustomPassScene extends Scene { private angle = 0; override init(loader): void { - this.back = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5).setPosition(280, 300); - this.front = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5).setPosition(520, 300); + this.back = new Sprite(loader.get('image/hero.png')).setAnchor(0.5).setPosition(280, 300); + this.front = new Sprite(loader.get('image/hero.png')).setAnchor(0.5).setPosition(520, 300); this.between = new Graphics(); this.pipeline = new RenderPipeline() diff --git a/site/src/content/guide/effects/particles.mdx b/site/src/content/guide/effects/particles.mdx index e8265bbf2..02710d588 100644 --- a/site/src/content/guide/effects/particles.mdx +++ b/site/src/content/guide/effects/particles.mdx @@ -43,10 +43,10 @@ const app = new Application(); // picks up particlesExtension automatically A particle system needs a texture and a capacity: ```js -import { BlendModes, Texture } from '@codexo/exojs'; +import { BlendModes } from '@codexo/exojs'; import { ParticleSystem } from '@codexo/exojs-particles'; -const system = new ParticleSystem(loader.get(Texture, 'spark'), { +const system = new ParticleSystem(loader.get('image/spark.png'), { capacity: 4000, }); ``` diff --git a/site/src/content/guide/effects/post-processing.mdx b/site/src/content/guide/effects/post-processing.mdx index 58f8b143b..c58f3bbe5 100644 --- a/site/src/content/guide/effects/post-processing.mdx +++ b/site/src/content/guide/effects/post-processing.mdx @@ -60,7 +60,7 @@ That pipeline is already three passes — scene render, blur, composite. Post-pr Bloom requires two renderings of the same scene: one at normal brightness (the base), one with only the bright parts tinted and blurred (the glow). The glow is composited additively over the base. Because the same sprite is drawn twice with different tints, each off-screen step is a `CallbackRenderPass` that sets the tint before rendering: ```ts -import { BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; class BloomScene extends Scene { private baseRt!: RenderTexture; @@ -77,7 +77,7 @@ class BloomScene extends Scene { this.glowRt = new RenderTexture(800, 600); this.blurredRt = new RenderTexture(800, 600); - this.bunny = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5); + this.bunny = new Sprite(loader.get('image/bunny.png')).setAnchor(0.5); this.baseSprite = new Sprite(this.baseRt); this.glowSprite = new Sprite(this.blurredRt) .setTint(new Color(255, 255, 255, 0.8)) @@ -171,7 +171,7 @@ Each `filter.apply()` call is one additional render pass. For a chain of N filte Reuse a render target as its own input to create motion trails. The feedback pass deliberately omits `clear`, so the decayed previous frame accumulates instead of being wiped: ```ts -import { CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs'; class TrailScene extends Scene { private feedbackRt!: RenderTexture; @@ -184,7 +184,7 @@ class TrailScene extends Scene { this.feedbackRt = new RenderTexture(800, 600); this.decay = new Sprite(this.feedbackRt) .setTint(new Color(255, 255, 255, 0.93)); // 93% opaque = 7% fade per frame - this.hero = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5); + this.hero = new Sprite(loader.get('image/hero.png')).setAnchor(0.5); this.final = new Sprite(this.feedbackRt); this.pipeline = new RenderPipeline() diff --git a/site/src/content/guide/getting-started/project-structure.mdx b/site/src/content/guide/getting-started/project-structure.mdx index c572b0a53..1eb03a2c4 100644 --- a/site/src/content/guide/getting-started/project-structure.mdx +++ b/site/src/content/guide/getting-started/project-structure.mdx @@ -55,7 +55,7 @@ The application owns runtime configuration — canvas size, clear color, render ## Where assets go -Files under `public/assets/` are served as-is and addressed by path at runtime — for example `loader.load(Texture, { hero: 'assets/hero.png' })`. The [Loading and resources](/ExoJS/en/guide/assets/loading-and-resources/) chapter covers the loader in detail. +Files under `public/assets/` are served as-is and addressed by path at runtime — for example `loader.load('assets/hero.png')`. The path's extension tells the loader what type to produce, so no import of `Texture` is needed just to load it. The [Loading and resources](/ExoJS/en/guide/assets/loading-and-resources/) chapter covers the loader in detail. `public/` is the folder the dev server serves from, not part of the URL. A file at `public/assets/hero.png` is loaded as `assets/hero.png` — keeping the `public/` prefix in the path gives you a 404 at runtime. diff --git a/site/src/content/guide/getting-started/your-first-scene.mdx b/site/src/content/guide/getting-started/your-first-scene.mdx index 693c666e7..33968a6e0 100644 --- a/site/src/content/guide/getting-started/your-first-scene.mdx +++ b/site/src/content/guide/getting-started/your-first-scene.mdx @@ -45,7 +45,7 @@ Scenes have four lifecycle hooks. You override the ones you need: - `update(delta)` — per-frame logic. - `draw(context)` — per-frame rendering. -Adding a single sprite means using `load` to declare a texture, `init` to construct the sprite, and `draw` to render it: +Adding a single sprite means using `load` to fetch the texture and build the sprite, `init` to position it, and `draw` to render it: diff --git a/site/src/content/guide/input/keyboard-and-actions.mdx b/site/src/content/guide/input/keyboard-and-actions.mdx index ea93916ac..c4fef6506 100644 --- a/site/src/content/guide/input/keyboard-and-actions.mdx +++ b/site/src/content/guide/input/keyboard-and-actions.mdx @@ -191,15 +191,15 @@ Scene-scoped bindings from `this.inputs` are cleaned up when the scene unloads, ```js -import { GamepadAxis, GamepadButton, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { GamepadAxis, GamepadButton, Keyboard, Scene, Sprite } from '@codexo/exojs'; class PlayerScene extends Scene { async load(loader) { - await loader.load(Texture, { hero: 'image/hero.png' }); + await loader.load('image/hero.png'); } init(loader) { - this.sprite = new Sprite(loader.get(Texture, 'hero')) + this.sprite = new Sprite(loader.get('image/hero.png')) .setAnchor(0.5) .setPosition(400, 300); diff --git a/site/src/content/guide/input/mouse-and-pointer.mdx b/site/src/content/guide/input/mouse-and-pointer.mdx index c29cfa588..6105b0668 100644 --- a/site/src/content/guide/input/mouse-and-pointer.mdx +++ b/site/src/content/guide/input/mouse-and-pointer.mdx @@ -139,7 +139,7 @@ A `Sprite` (or any `RenderNode`) can be made interactive and draggable directly: ```js init(loader) { - this.sprite = new Sprite(loader.get(Texture, 'hero')); + this.sprite = new Sprite(loader.get('image/hero.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(400, 300); diff --git a/site/src/content/guide/physics/physics-basics.mdx b/site/src/content/guide/physics/physics-basics.mdx index 5104d8308..9b5c86968 100644 --- a/site/src/content/guide/physics/physics-basics.mdx +++ b/site/src/content/guide/physics/physics-basics.mdx @@ -201,7 +201,7 @@ Putting it together — a static floor, a dynamic ball bound to a sprite, steppe `fixedUpdate`: ```ts -import { Loader, type RenderingContext, Scene, Sprite, Texture, type Time } from '@codexo/exojs'; +import { Loader, type RenderingContext, Scene, Sprite, type Time } from '@codexo/exojs'; import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; class DropScene extends Scene { @@ -209,7 +209,7 @@ class DropScene extends Scene { private ball!: Sprite; public override async load(loader: Loader): Promise { - await loader.load(Texture, { ball: 'image/ball.png' }); + await loader.load('image/ball.png'); } public override init(loader: Loader): void { @@ -223,7 +223,7 @@ class DropScene extends Scene { ); // Dynamic ball: a sprite plus a body + circle collider, linked by `attach`. - this.ball = new Sprite(loader.get(Texture, 'ball')); + this.ball = new Sprite(loader.get('image/ball.png')); this.addChild(this.ball); this.world.attach(this.ball, { diff --git a/site/src/content/guide/recipes/audio-reactive-scene.mdx b/site/src/content/guide/recipes/audio-reactive-scene.mdx index baaa22b92..51f8ebf33 100644 --- a/site/src/content/guide/recipes/audio-reactive-scene.mdx +++ b/site/src/content/guide/recipes/audio-reactive-scene.mdx @@ -22,10 +22,8 @@ Three systems run in parallel, each driven by a different facet of the audio ana One analyser for frequency data, one beat detector for rhythm, one particle system for visual feedback: ```js -import { - Application, AudioStream, - Scene, View, Texture, Vector, Color, Ease, -} from '@codexo/exojs'; +import { Asset, Application, AudioStream, + Scene, View, Vector, Color, Ease } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, @@ -36,12 +34,16 @@ const app = new Application({ extensions: [particlesExtension] }); class AudioReactiveScene extends Scene { async load(loader) { - await loader.load(AudioStream, { track: 'audio/track.ogg' }); - await loader.load(Texture, { particle: 'image/particle.png' }); + const [music, particleTexture] = await Promise.all([ + loader.load(Asset.kind('music', 'audio/track.ogg')), + loader.load('image/particle.png'), + ]); + this.music = music; + this.particleTexture = particleTexture; } - init(loader) { - this.app.audio.play(loader.get(AudioStream, 'track'), { loop: true, volume: 0.8 }); + init() { + this.app.audio.play(this.music, { loop: true, volume: 0.8 }); // Analysis — both taps read the music bus the track plays through. this.analyser = new AudioAnalyser({ source: this.app.audio.music, fftSize: 512 }); @@ -49,7 +51,7 @@ class AudioReactiveScene extends Scene { this._bg = new Color(20, 24, 40, 1); // Particles — burst on beat - this.particles = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 5000 }); + this.particles = new ParticleSystem(this.particleTexture, { capacity: 5000 }); this.burst = new BurstSpawn({ schedule: [{ time: 0, count: 120 }], lifetime: new Constant(0.8), diff --git a/site/src/content/guide/recipes/camera-follow-and-parallax.mdx b/site/src/content/guide/recipes/camera-follow-and-parallax.mdx index a136ee9f5..e90b5f9eb 100644 --- a/site/src/content/guide/recipes/camera-follow-and-parallax.mdx +++ b/site/src/content/guide/recipes/camera-follow-and-parallax.mdx @@ -21,7 +21,7 @@ Snapping `view.setCenter` onto the player every frame feels rigid. Ease it inste ```js init(loader) { this._view = new View(400, 300, 800, 600); - this.player = new Sprite(loader.get(Texture, 'hero')); + this.player = new Sprite(loader.get('image/hero.png')); } update(delta) { diff --git a/site/src/content/guide/recipes/cinematics.mdx b/site/src/content/guide/recipes/cinematics.mdx index b8985115e..8bb16924c 100644 --- a/site/src/content/guide/recipes/cinematics.mdx +++ b/site/src/content/guide/recipes/cinematics.mdx @@ -19,8 +19,13 @@ The key technique: **tween chains and delays**. Each tween starts at a specific ```js class CinematicScene extends Scene { async load(loader) { - await loader.load(Texture, { boss: 'image/boss.png' }); - await loader.load(AudioStream, { track: 'audio/track.ogg' }); + // AudioStream has no bare-path form, so use `.of(...)` — and since + // `get(Asset.kind('music', ...))` isn't supported, keep the loaded + // instances as direct references instead of looking them up later. + [this.bossTexture, this.trackStream] = await Promise.all([ + loader.load('image/boss.png'), + loader.load(Asset.kind('music', 'audio/track.ogg')), + ]); } init(loader) { @@ -28,14 +33,14 @@ class CinematicScene extends Scene { this.view = new View(220, 300, 800, 600); // Boss — starts small, scales up during the pan - this.boss = new Sprite(loader.get(Texture, 'boss')) + this.boss = new Sprite(this.bossTexture) .setAnchor(0.5) .setScale(0.4) .setPosition(560, 320) .setTint(new Color(255, 130, 130)); // Music — start quiet, fade in over the sequence. Keep the Voice to tween. - this.musicVoice = this.app.audio.play(loader.get(AudioStream, 'track'), { loop: true, volume: 0.2 }); + this.musicVoice = this.app.audio.play(this.trackStream, { loop: true, volume: 0.2 }); // Shutter bars — open at the very start this.barSize = { v: 0 }; diff --git a/site/src/content/guide/recipes/game-feel.mdx b/site/src/content/guide/recipes/game-feel.mdx index 316d6de07..7d43b1742 100644 --- a/site/src/content/guide/recipes/game-feel.mdx +++ b/site/src/content/guide/recipes/game-feel.mdx @@ -22,7 +22,7 @@ When the player takes damage, flash the sprite red for a fraction of a second an import { ColorFilter, Signal } from '@codexo/exojs'; init(loader) { - this.player = new Sprite(loader.get(Texture, 'hero')); + this.player = new Sprite(loader.get('image/hero.png')); this.flash = new ColorFilter(new Color(255, 255, 255, 1)); this.player.filters = [this.flash]; diff --git a/site/src/content/guide/recipes/pause-menu.mdx b/site/src/content/guide/recipes/pause-menu.mdx index 9b23530e1..24087ead3 100644 --- a/site/src/content/guide/recipes/pause-menu.mdx +++ b/site/src/content/guide/recipes/pause-menu.mdx @@ -21,7 +21,7 @@ One scene, one UI layer. The pause overlay (a `Panel` + `Label`) lives on `scene ```js class GameScene extends Scene { init(loader) { - this.player = new Sprite(loader.get(Texture, 'hero')); + this.player = new Sprite(loader.get('image/hero.png')); this.addChild(this.player); // ... game setup ... diff --git a/site/src/content/guide/recipes/split-screen.mdx b/site/src/content/guide/recipes/split-screen.mdx index bc26e65f3..69ffda532 100644 --- a/site/src/content/guide/recipes/split-screen.mdx +++ b/site/src/content/guide/recipes/split-screen.mdx @@ -32,10 +32,10 @@ init(loader) { this._divider.drawRectangle(width / 2 - 1, 0, 2, height); // Shared world objects — both views see these - this._leftPlayer = new Sprite(loader.get(Texture, 'hero')) + this._leftPlayer = new Sprite(loader.get('image/hero.png')) .setAnchor(0.5) .setTint(new Color(120, 190, 255)); - this._rightPlayer = new Sprite(loader.get(Texture, 'hero')) + this._rightPlayer = new Sprite(loader.get('image/hero.png')) .setAnchor(0.5) .setTint(new Color(255, 180, 120)); } diff --git a/site/src/content/guide/recipes/ui-patterns.mdx b/site/src/content/guide/recipes/ui-patterns.mdx index 175620d88..8564021da 100644 --- a/site/src/content/guide/recipes/ui-patterns.mdx +++ b/site/src/content/guide/recipes/ui-patterns.mdx @@ -20,7 +20,7 @@ init(loader) { 'All wings hold formation and await my signal.', ]; - this.portrait = new Sprite(loader.get(Texture, 'portrait')) + this.portrait = new Sprite(loader.get('image/portrait.png')) .setAnchor(0.5).setScale(1.7).setPosition(170, 420); this.box = new Text('', { @@ -30,7 +30,7 @@ init(loader) { }); this.box.setPosition(270, 360); - this.beep = loader.get(Sound, 'beep'); + this.beep = loader.get('audio/beep.ogg'); this.lineIndex = 0; this.chars = 0; @@ -90,7 +90,7 @@ init(loader) { this.label = new Text('0%', { fillColor: Color.white, fontSize: 42 }); this.label.setPosition(360, 410); - this.ring = new Sprite(loader.get(Texture, 'uv')).setScale(2.2); + this.ring = new Sprite(loader.get('image/uv.png')).setScale(2.2); this.filter = new WebGl2ShaderFilter({ fragmentSource: progressGlsl, uniforms: { uProgress: 0 }, diff --git a/site/src/content/guide/rendering/animation.mdx b/site/src/content/guide/rendering/animation.mdx index 006c1d6d3..cc23aac77 100644 --- a/site/src/content/guide/rendering/animation.mdx +++ b/site/src/content/guide/rendering/animation.mdx @@ -21,7 +21,7 @@ A [`Tween`](/ExoJS/en/api/tween/) interpolates numeric properties on any target ```js init(loader) { - this.sprite = new Sprite(loader.get(Texture, 'hero')); + this.sprite = new Sprite(loader.get('image/hero.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(100, 300); diff --git a/site/src/content/guide/rendering/sprites.mdx b/site/src/content/guide/rendering/sprites.mdx index ff71ab3a3..81b38afb8 100644 --- a/site/src/content/guide/rendering/sprites.mdx +++ b/site/src/content/guide/rendering/sprites.mdx @@ -15,15 +15,15 @@ A [`Sprite`](/ExoJS/en/api/sprite/) is the primary drawable for textured quads A sprite needs a texture. The shortest path from file to visible quad: ```js -import { Scene, Sprite, Texture } from '@codexo/exojs'; +import { Scene, Sprite } from '@codexo/exojs'; class MyScene extends Scene { async load(loader) { - await loader.load(Texture, { hero: 'image/hero.png' }); + await loader.load('image/hero.png'); } init(loader) { - this.hero = new Sprite(loader.get(Texture, 'hero')); + this.hero = new Sprite(loader.get('image/hero.png')); this.addChild(this.hero); } @@ -46,7 +46,7 @@ A sprite's position places its anchor point in the parent's coordinate space. By init(loader) { const { width, height } = this.app.canvas; - this.hero = new Sprite(loader.get(Texture, 'hero')); + this.hero = new Sprite(loader.get('image/hero.png')); this.hero.setAnchor(0.5); this.hero.setPosition(width / 2, height / 2); this.addChild(this.hero); @@ -167,9 +167,9 @@ The `animations` map feeds into `AnimatedSprite.fromSpritesheet()`, covered in t ```js // Video as a sprite texture -import { RenderTexture, Sprite, Video } from '@codexo/exojs'; +import { Asset, RenderTexture, Sprite, Video } from '@codexo/exojs'; -const video = loader.get(Video, 'intro'); +const video = await loader.load(Asset.kind('video', 'intro.mp4')); const sprite = new Sprite(video.texture); // A RenderTexture (offscreen render) as a sprite texture diff --git a/site/src/content/guide/rendering/text.mdx b/site/src/content/guide/rendering/text.mdx index ecbb070b6..59cbbf334 100644 --- a/site/src/content/guide/rendering/text.mdx +++ b/site/src/content/guide/rendering/text.mdx @@ -32,13 +32,11 @@ label.style.fillColor = Color.tomato; // updates only the mesh tint — no atlas ## Font loading -System fonts (Arial, Times New Roman, etc.) work immediately. For custom web fonts, load a `FontFace` in the scene's `load` hook: +System fonts (Arial, Times New Roman, etc.) work immediately. For custom web fonts, load one via `Asset.kind('font', ...)` in the scene's `load` hook — fonts aren't a seamless type, so a bare path isn't enough; `.of()` is required even for a literal path, and `family` is a required option: ```js async load(loader) { - await loader.load(FontFace, { - myFont: 'font/MyFont.woff2', - }, { family: 'MyFont' }); + await loader.load(Asset.kind('font', 'font/MyFont.woff2', { family: 'MyFont' })); } init(loader) { @@ -50,7 +48,7 @@ init(loader) { } ``` -The `FontFace` asset is registered with the document's `document.fonts` set. Once loaded, it becomes available to all `Text` instances via the `fontFamily` style property. +Loading resolves to a `FontFace`, registered with the document's `document.fonts` set. Once loaded, it becomes available to all `Text` instances via the `fontFamily` style property. ## Style properties @@ -156,7 +154,7 @@ The text node's internal mesh is a `Container` child. Assigning to `text.text` r - The glyph atlas is shared across all `Text` instances and has a fixed default size. Large sets of unique glyph/style combinations can exhaust atlas space. - `Text` does not expose per-character styling. Use separate `Text` instances for mixed-style strings. - `Text` does not measure or report its pixel dimensions through the public API — the mesh bounds reflect the glyph quad size but measured width/height in pixels is not exposed as a public getter. -- Loader-based `FontFace` loading is the built-in path for custom fonts; any font family available to the browser's canvas text engine can be used once loaded. +- Loader-based `FontAsset` loading is the built-in path for custom fonts; any font family available to the browser's canvas text engine can be used once loaded. ## Examples diff --git a/site/src/content/guide/runtime/scene-graph.mdx b/site/src/content/guide/runtime/scene-graph.mdx index b2c492a7e..aa10792d0 100644 --- a/site/src/content/guide/runtime/scene-graph.mdx +++ b/site/src/content/guide/runtime/scene-graph.mdx @@ -18,8 +18,8 @@ Every scene starts with one container already in place — `this.root`. Adding a ```js init(loader) { - this.hero = new Sprite(loader.get(Texture, 'hero')); - this.coin = new Sprite(loader.get(Texture, 'coin')); + this.hero = new Sprite(loader.get('hero.png')); + this.coin = new Sprite(loader.get('coin.png')); this.addChild(this.hero); this.addChild(this.coin); @@ -33,8 +33,8 @@ init(loader) { this.world = new Container(); this.hud = new Container(); - this.world.addChild(new Sprite(loader.get(Texture, 'level'))); - this.hud.addChild(new Sprite(loader.get(Texture, 'health-bar'))); + this.world.addChild(new Sprite(loader.get('level.png'))); + this.hud.addChild(new Sprite(loader.get('health-bar.png'))); this.addChild(this.world); this.addChild(this.hud); @@ -51,8 +51,8 @@ When a parent transforms, its children move with it. If you set the parent's pos this.player = new Container(); this.player.setPosition(100, 0); -const body = new Sprite(loader.get(Texture, 'body')); -const head = new Sprite(loader.get(Texture, 'head')); +const body = new Sprite(loader.get('body.png')); +const head = new Sprite(loader.get('head.png')); head.setPosition(0, -32); this.player.addChild(body); diff --git a/site/src/content/guide/runtime/scenes-and-lifecycle.mdx b/site/src/content/guide/runtime/scenes-and-lifecycle.mdx index a5e6c11c2..04a8a17b7 100644 --- a/site/src/content/guide/runtime/scenes-and-lifecycle.mdx +++ b/site/src/content/guide/runtime/scenes-and-lifecycle.mdx @@ -153,14 +153,14 @@ You override the hooks you need. Empty hooks like `update` and `draw` do nothing The `load` hook is the async setup hook. Use it to register everything the scene needs and await the loader: ```js -import { Scene, Texture } from '@codexo/exojs'; +import { Scene } from '@codexo/exojs'; class GameScene extends Scene { async load(loader) { - await loader.load(Texture, { - hero: 'image/hero.png', - ground: 'image/ground.png', - }); + await Promise.all([ + loader.load('image/hero.png'), + loader.load('image/ground.png'), + ]); } } ``` @@ -173,7 +173,7 @@ The `init` hook is where you create the scene's actual objects. The same `loader ```js init(loader) { - this.hero = new Sprite(loader.get(Texture, 'hero')); + this.hero = new Sprite(loader.get('image/hero.png')); this.hero.setAnchor(0.5); this.hero.setPosition(400, 300); this.addChild(this.hero); diff --git a/site/src/content/guide/shipping/deployment.mdx b/site/src/content/guide/shipping/deployment.mdx index ed84e3c54..5f408757a 100644 --- a/site/src/content/guide/shipping/deployment.mdx +++ b/site/src/content/guide/shipping/deployment.mdx @@ -119,7 +119,7 @@ Files placed in `public/` are copied verbatim to `dist/` during the build. A fil Reference assets without the `public/` prefix: ```js no-check -await loader.load(Texture, { bunny: 'assets/bunny.png' }); // correct +await loader.load('assets/bunny.png'); // correct ``` **Case-sensitivity.** Development on Windows or macOS is case-insensitive, but most Linux hosting is not. A mismatch between `Bunny.png` on disk and `bunny.png` in code works locally and fails in production. Use consistent lowercase names. diff --git a/site/src/content/guide/shipping/troubleshooting.mdx b/site/src/content/guide/shipping/troubleshooting.mdx index 994528c35..0a1f0c2c5 100644 --- a/site/src/content/guide/shipping/troubleshooting.mdx +++ b/site/src/content/guide/shipping/troubleshooting.mdx @@ -116,8 +116,8 @@ Asset load failures typically produce network errors in the browser's developer **Vite `public/` path confusion.** Files in `public/` are served from the site root. A file at `public/assets/bunny.png` is available at `/assets/bunny.png`, not `public/assets/bunny.png`. Reference it without the `public/` prefix: ```js no-check -await loader.load(Texture, { bunny: 'assets/bunny.png' }); // correct -await loader.load(Texture, { bunny: 'public/assets/bunny.png' }); // wrong +await loader.load('assets/bunny.png'); // correct +await loader.load('public/assets/bunny.png'); // wrong ``` **Files not included in the build.** Only files in `public/` or explicitly imported by source code are copied to `dist/`. Assets referenced by path string (not `import`) must live in `public/`. diff --git a/site/src/content/guide/shipping/v0-8-x-to-v0-9-0.mdx b/site/src/content/guide/shipping/v0-8-x-to-v0-9-0.mdx index be8c7ab3b..0932fcf58 100644 --- a/site/src/content/guide/shipping/v0-8-x-to-v0-9-0.mdx +++ b/site/src/content/guide/shipping/v0-8-x-to-v0-9-0.mdx @@ -107,14 +107,14 @@ color.alpha // -> color.a import { Asset, Assets } from '@codexo/exojs'; const heroTexture = new Asset({ - type: 'texture', + kind: 'texture', source: 'images/hero.png', }); const titleAssets = new Assets({ - logo: { type: 'texture', source: 'images/logo.png' }, - music: { type: 'music', source: 'audio/title.ogg' }, - levels: { type: 'json', source: 'data/levels.json' }, + logo: { kind: 'texture', source: 'images/logo.png' }, + music: { kind: 'music', source: 'audio/title.ogg' }, + levels: { kind: 'json', source: 'data/levels.json' }, }); ``` diff --git a/src/audio/Sound.ts b/src/audio/Sound.ts index 27d7146a5..b5473ad70 100644 --- a/src/audio/Sound.ts +++ b/src/audio/Sound.ts @@ -171,14 +171,29 @@ export class Sound implements Playable { /** * Load lifecycle of this sound. Directly constructed sounds are `'ready'`; - * deferred handles returned by `loader.get(Sound, …)` start `'loading'` and - * become `'ready'` once the payload fills in, or `'failed'` when the load - * errors. + * deferred handles returned by `loader.get('theme.ogg')` / `loader.get(Asset.kind('sound', src))` + * start `'loading'` and become `'ready'` once the payload fills in, or + * `'failed'` when the load errors. */ public get loadState(): LoadStateValue { return this._loadState.value; } + /** Load lifecycle: `'idle' | '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/core/LoadState.ts b/src/core/LoadState.ts index a6ad1276c..c62ecbb08 100644 --- a/src/core/LoadState.ts +++ b/src/core/LoadState.ts @@ -1,5 +1,8 @@ -/** Load lifecycle of a seamless asset handle. Directly constructed assets are `'ready'`. */ -export type LoadStateValue = 'loading' | 'ready' | 'failed'; +/** + * Load lifecycle of a seamless asset handle. Directly constructed assets are + * `'ready'`; a catalog leaf awaiting adoption is `'idle'` (asset-system v2 §7). + */ +export type LoadStateValue = 'idle' | 'loading' | 'ready' | 'failed'; /** * Load-lifecycle tracker composed into seamless asset handles (Texture; later @@ -48,14 +51,31 @@ export class LoadState { } /** - * Enter `'loading'` and drop the cached promise (re-materialization). - * Only called from settled states — `'ready'` for a fresh placeholder, - * `'failed'` for a retry; beginning while a pending promise is materialized - * would strand its awaiters. + * Enter `'loading'`. From a settled state (`'ready'` fresh placeholder, + * `'failed'` retry) the cached promise is dropped so a fresh cycle + * re-materializes. From `'idle'` (a catalog leaf entering the loader on + * adoption) any pending promise is PRESERVED, so an awaiter taken before + * adoption still resolves on the eventual settle. */ public begin(): void { + if (this._value === 'ready' || this._value === 'failed') { + this._promise = null; + this._resolve = null; + this._reject = null; + } this._value = 'loading'; this._error = null; + } + + /** + * Enter `'idle'` — a catalog leaf/ref that exists but has not been adopted by + * a loader (asset-system v2 §7), as opposed to `'ready'` (a manually + * constructed, immediately usable resource). {@link loaded} on an idle handle + * stays pending; {@link begin} on adoption preserves that pending promise. + */ + public markIdle(): void { + this._value = 'idle'; + this._error = null; this._promise = null; this._resolve = null; this._reject = null; diff --git a/src/core/Scene.ts b/src/core/Scene.ts index d6b506b80..a46f61164 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -5,15 +5,12 @@ import { Container } from '#rendering/Container'; import type { RenderingContext } from '#rendering/RenderingContext'; import type { RenderNode } from '#rendering/RenderNode'; import type { Texture } from '#rendering/texture/Texture'; -import type { Asset } from '#resources/Asset'; +import type { Asset, ValueAsset } from '#resources/Asset'; import type { AssetInput } from '#resources/AssetDefinitions'; import type { AssetRef } from '#resources/AssetRef'; -import type { Assets } 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 { Assets, InferAssetsProperties } from '#resources/Assets'; +import type { InferLoadedMap, Loadable, LoadByPath, Loader, LoadOptions, LoadReturn, PathExtension } from '#resources/Loader'; import type { LoadingQueue } from '#resources/LoadingQueue'; -import type { PreSizeOptions } from '#resources/seamless'; -import type { BinaryAsset, CsvAsset, Json, SubtitleAsset, TextAsset, WasmAsset, XmlAsset } from '#resources/tokens'; import { UIRoot } from '#ui/UIRoot'; import type { Application } from './Application'; @@ -113,44 +110,32 @@ class SceneLoader implements Destroyable { return this._scene.app!.loader; } - 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(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; - public get(type: typeof CsvAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof XmlAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof SubtitleAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef; + // Legacy in-memory lookup by type + alias (advanced — mirrors Loader.get; a cache + // lookup, not a token fetch, which was removed). public get(type: T, alias: string): LoadReturn; - public get(typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { - return this._loader._getClaimed(this._scope, typeOrPath, source, options); + // Seamless/value access from an `Asset.kind()` descriptor (mirrors Loader.get(asset)): + // a value-kind descriptor returns AssetRef, a resource-kind descriptor the resource. + public get(asset: ValueAsset): AssetRef; + public get(asset: Asset): T; + // 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): unknown { + return this._loader._getClaimed(this._scope, typeOrPath, source); } - 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>; public load(asset: Asset): LoadingQueue; - 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>; + public load>(assets: Assets, options?: LoadOptions): LoadingQueue>; + // Single value-leaf (an `Assets.from()` AssetRef property): mirrors Loader.load(leaf). + public load(leaf: AssetRef, options?: LoadOptions): LoadingQueue; + // Single handle-hybrid leaf (an `Assets.from()` property): mirrors Loader.load(leaf). + public load(leaf: T, options?: LoadOptions): 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>; - public load(type: T, paths: readonly string[], options?: unknown): LoadingQueue>>; - public load(type: T, items: Readonly>, options?: unknown): LoadingQueue>>; - public load(arg0: unknown, arg1?: unknown, arg2?: unknown): LoadingQueue { - return this._loader._loadClaimed(this._scope, arg0, arg1, arg2); - } - - public backgroundLoad(): void; - public backgroundLoad(type: Loadable, source: string, options?: unknown): void; - // eslint-disable-next-line @typescript-eslint/unified-signatures -- mirrors Loader.backgroundLoad verbatim (rule disabled there too) - public backgroundLoad(type: Loadable, sources: readonly string[], options?: unknown): void; - public backgroundLoad(type?: Loadable, source?: string | readonly string[], options?: unknown): void { - this._loader._backgroundClaimed(this._scope, type, source, options); + public load(arg0: unknown, arg1?: unknown): LoadingQueue { + return this._loader._loadClaimed(this._scope, arg0, arg1); } public destroy(): void { diff --git a/src/core/serialization/serialize.ts b/src/core/serialization/serialize.ts index b9aa0c3b5..d81bf3110 100644 --- a/src/core/serialization/serialize.ts +++ b/src/core/serialization/serialize.ts @@ -3,7 +3,7 @@ import type { SceneNode } from '#core/SceneNode'; import type { Container } from '#rendering/Container'; import type { RenderNode } from '#rendering/RenderNode'; import type { AssetConstructor } from '#resources/FactoryRegistry'; -import type { Loadable, Loader } from '#resources/Loader'; +import type { Loader } from '#resources/Loader'; import { applyCommonFields, writeCommonFields } from './commonFields'; import { registerCoreSerializers } from './coreSerializers'; @@ -85,7 +85,7 @@ function createDeserializeContext(loader: Loader | null, version: number, regist return null; } - const resource = loader.peek(type as unknown as Loadable, source) as T | null; + const resource = loader._peekResource(type, source) as T | null; if (resource === null) { logger.warn(`An asset referenced by a node was not pre-loaded into the Loader before deserialize (e.g. "${source}"); it resolves to null.`, { diff --git a/src/extensions/Extension.ts b/src/extensions/Extension.ts index 9589ae9a7..9b93a9acd 100644 --- a/src/extensions/Extension.ts +++ b/src/extensions/Extension.ts @@ -4,6 +4,7 @@ import type { SceneNodeConstructor } from '#core/serialization/SerializationRegi import type { Drawable } from '#rendering/Drawable'; import type { RenderBackend } from '#rendering/RenderBackend'; import type { DrawableConstructor, Renderer } from '#rendering/Renderer'; +import type { AssetDefinitions } from '#resources/AssetDefinitions'; import type { AssetConstructor } from '#resources/FactoryRegistry'; import type { AssetLoaderContext, Loader } from '#resources/Loader'; import type { SeamlessAdapter } from '#resources/seamless'; @@ -105,6 +106,14 @@ export interface AssetBinding { */ readonly typeNames?: readonly string[]; readonly extensions?: readonly string[]; + /** + * The {@link AssetDefinitions} key this binding produces. When present, the + * binding was built by `defineAsset`, which registered the kind's placeholder + * strategy and suffix→kind inference GLOBALLY at import (so loader-free + * `Assets.from` resolves it). Purely informational on the binding itself — + * `materializeAssetBindings` does not consume it. + */ + readonly kind?: keyof AssetDefinitions; /** Optional seamless-handle adapter (asset-system v2), registered alongside the handler. */ readonly seamless?: SeamlessAdapter; create(loader: Loader): AssetHandler; diff --git a/src/rendering/text/BmFont.ts b/src/rendering/text/BmFont.ts index d799aaa07..71ea9610e 100644 --- a/src/rendering/text/BmFont.ts +++ b/src/rendering/text/BmFont.ts @@ -41,8 +41,8 @@ export interface BmFontData { * Pass directly to {@link BitmapText}. * * ```ts - * const font = await loader.load('fonts/ui.fnt'); // BmFont via extension - * const font = await loader.load(BmFont, 'fonts/ui.fnt'); // explicit token + * const font = await loader.load('fonts/ui.fnt'); // BmFont via extension + * const font = await loader.load(Asset.kind('bmFont', 'fonts/ui.fnt')); // explicit descriptor * const label = new BitmapText('Score: 0', font); * ``` * @stable diff --git a/src/rendering/text/HTMLText.ts b/src/rendering/text/HTMLText.ts index 0775e1d7d..fc24047e1 100644 --- a/src/rendering/text/HTMLText.ts +++ b/src/rendering/text/HTMLText.ts @@ -40,7 +40,7 @@ export interface HTMLTextOptions { * pass the raw font bytes (from {@link BinaryAsset} or a plain `fetch`): * * ```ts - * const bytes = await loader.load(BinaryAsset, 'roboto.woff2'); + * const bytes = await loader.load(Asset.kind('binary', 'roboto.woff2')); * htmlText.addFont('Roboto', bytes, 'woff2'); * ``` * @@ -161,7 +161,7 @@ export class HTMLText extends Container { * Registering the same family twice replaces the previous entry. * * ```ts - * const bytes = await loader.load(BinaryAsset, 'roboto.woff2'); + * const bytes = await loader.load(Asset.kind('binary', 'roboto.woff2')); * label.addFont('Roboto', bytes, 'woff2'); * ``` */ diff --git a/src/rendering/texture/Texture.ts b/src/rendering/texture/Texture.ts index 13cbc19b2..ef9f5c854 100644 --- a/src/rendering/texture/Texture.ts +++ b/src/rendering/texture/Texture.ts @@ -206,14 +206,30 @@ export class Texture { /** * Load lifecycle of this texture. Directly constructed textures are - * `'ready'`; deferred handles returned by `loader.get(Texture, …)` start - * `'loading'` and become `'ready'` once the payload fills in, or `'failed'` - * (showing the {@link Texture.missing} checker) when the load errors. + * `'ready'`; deferred handles returned by `loader.get('hero.png')` / + * `loader.get(Asset.kind('texture', src))` start `'loading'` and become `'ready'` once + * the payload fills in, or `'failed'` (showing the {@link Texture.missing} + * checker) when the load errors. */ public get loadState(): LoadStateValue { return this._loadState.value; } + /** Load lifecycle: `'idle' | '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/Asset.ts b/src/resources/Asset.ts index 532512135..d678fb09a 100644 --- a/src/resources/Asset.ts +++ b/src/resources/Asset.ts @@ -1,4 +1,4 @@ -import type { AnyAssetConfig, AssetDefinitions } from './AssetDefinitions'; +import type { AnyAssetConfig, AssetDefinitions, OptionsForKind, ValueAssetKind } from './AssetDefinitions'; // --------------------------------------------------------------------------- // Internal implementation @@ -13,8 +13,8 @@ export class AssetImpl { this._config = config; } - public get type(): keyof AssetDefinitions { - return this._config.type; + public get kind(): keyof AssetDefinitions { + return this._config.kind; } public get source(): string { @@ -30,12 +30,51 @@ export class AssetImpl { export interface Asset { /** @internal */ readonly _config: AnyAssetConfig; - readonly type: keyof AssetDefinitions; + readonly kind: keyof AssetDefinitions; readonly source: string; /** Phantom type marker — never actually present at runtime. */ readonly _resource?: T; } -type AssetConstructorFn = new (config: { type: K } & AssetDefinitions[K]['config']) => Asset; +declare const VALUE_ASSET: unique symbol; -export const Asset = AssetImpl as unknown as AssetConstructorFn; +/** + * A value/ref-kind asset descriptor (asset-system v2 delta §4). Structurally an + * {@link Asset}, but branded so a catalog classifies its leaf as a deferred + * `AssetRef` — even when `T` is an object type (e.g. typed JSON), where the + * plain `T extends object` heuristic would otherwise misread it as a resource. + * The brand is a phantom (never present at runtime). + */ +export type ValueAsset = Asset & { readonly [VALUE_ASSET]: true }; + +type AssetConstructorFn = new (config: { kind: K } & AssetDefinitions[K]['config']) => Asset; + +type AssetFacade = AssetConstructorFn & { + /** + * The single typed descriptor builder (asset-system v2 delta §3). Replaces the + * per-class `.of()` statics. `kind` autocompletes from {@link AssetDefinitions}; + * the resource type is inferred from `kind`; `options` is that kind's option bag. + * The `` generic is accepted ONLY for value/ref kinds, where it annotates the + * decoded value — passing `` to a resource kind is a type error. + * + * @example + * ```ts + * Asset.kind('texture', 'player.png'); // Asset + * Asset.kind('json', 'levels/01.json'); // ValueAsset → AssetRef in a catalog + * ``` + */ + kind( + kind: K, + source: string, + options?: OptionsForKind, + ): K extends ValueAssetKind ? ValueAsset : Asset; + kind(kind: ValueAssetKind, source: string, options?: OptionsForKind): ValueAsset; +}; + +export const Asset = AssetImpl as unknown as AssetFacade; + +// Attach the runtime `kind` static — the single POJO descriptor factory that +// backs `Asset.kind(...)`. +(Asset as unknown as { kind: (kind: keyof AssetDefinitions, source: string, options?: object) => Asset }).kind = (kind, source, options) => + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- generic `kind` widens to `keyof AssetDefinitions`, losing the type/config correlation `AnyAssetConfig` needs; the cast is required here, not just stylistic. + new AssetImpl({ kind, source, ...(options ?? {}) } as AnyAssetConfig); diff --git a/src/resources/AssetDefinitions.ts b/src/resources/AssetDefinitions.ts index 42d20a609..77fbb5693 100644 --- a/src/resources/AssetDefinitions.ts +++ b/src/resources/AssetDefinitions.ts @@ -7,11 +7,13 @@ import type { SamplerOptions } from '#rendering/texture/Sampler'; import type { Texture } from '#rendering/texture/Texture'; import type { Video } from '#rendering/video/Video'; -import type { Asset } from './Asset'; +import type { Asset, ValueAsset } from './Asset'; +import type { AssetRef } from './AssetRef'; +import type { ExtensionTypeMap } from './Loader'; export interface AssetDefinitions { bmFont: { resource: BmFont; config: { source: string } }; - texture: { resource: Texture; config: { source: string; mimeType?: string; samplerOptions?: SamplerOptions } }; + texture: { resource: Texture; config: { source: string; mimeType?: string; samplerOptions?: Partial } }; sound: { resource: Sound; config: { source: string; playbackOptions?: Partial; poolSize?: number; sprites?: Readonly> }; @@ -45,10 +47,148 @@ export interface AssetDefinitions { } export type AnyAssetConfig = { - [K in keyof AssetDefinitions]: { type: K } & AssetDefinitions[K]['config']; + [K in keyof AssetDefinitions]: { kind: K } & AssetDefinitions[K]['config'] & + // `parse` is a value-kind-only, SYNCHRONOUS post-load transform (delta §4/§5): + // it maps the decoded raw value and may not return a Promise (async parse is a + // follow-up — it would need the fill/store flow to await). + (K extends ValueAssetKind ? { parse?: (raw: AssetDefinitions[K]['resource']) => unknown } : object); }[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 = - I extends Asset ? T : I extends { type: infer K extends keyof AssetDefinitions } ? AssetDefinitions[K]['resource'] : never; + I extends Asset + ? T + : I extends { parse: (raw: never) => infer R } + ? R + : I extends { kind: 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 per-kind option bag: that kind's config minus the `source` field. */ +export type OptionsForKind = Omit; + +/** + * 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 `Asset.kind(...)` descriptor, or an explicit config. */ +export type CatalogEntry = string | Asset | AnyAssetConfig; + +/** + * The leaf type a {@link CatalogEntry} materializes as. A {@link ValueAsset} + * brand (from `Asset.kind('json', …)`) classifies as `AssetRef` FIRST, + * before the `T extends object` heuristic that (only) the unbranded legacy + * `Asset.kind(...)` descriptors still rely on. + */ +export type InferCatalogLeaf = E extends string + ? LeafForPath + : E extends ValueAsset + ? AssetRef + : E extends Asset + ? T extends object + ? T + : AssetRef + : E extends { kind: infer K extends keyof AssetDefinitions } + ? E extends { parse: (raw: never) => infer R } + ? K extends ValueAssetKind + ? AssetRef + : AssetDefinitions[K]['resource'] + : 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; + +// Compile-time cross-check: {@link ExtensionKindMap} (suffix→kind, this file) and +// {@link ExtensionTypeMap} (suffix→resource, Loader.ts) are hand-maintained twins +// of one runtime `defineAsset` binding. On every suffix they SHARE, the kind's +// resource must be the type map's resource — otherwise `Assets.from('x.png')` +// (kind-driven) and `loader.load('x.png')` (type-driven) would disagree. A drift +// (e.g. mapping `png` to a non-Texture kind in one map only) turns the offending +// entry to `false` and fails this assignment. +type SharedSuffix = keyof ExtensionKindMap & keyof ExtensionTypeMap; +type KindResourceForSuffix = AssetDefinitions[ExtensionKindMap[K]]['resource']; +type KindTypeAgreement = { + [K in SharedSuffix]: [KindResourceForSuffix, ExtensionTypeMap[K]] extends [ExtensionTypeMap[K], KindResourceForSuffix] ? true : false; +}; +type AssertKindTypeMapsAgree = KindTypeAgreement extends Record ? true : never; +const _kindTypeMapsAgree: AssertKindTypeMapsAgree = true; +void _kindTypeMapsAgree; diff --git a/src/resources/AssetManifest.ts b/src/resources/AssetManifest.ts deleted file mode 100644 index bc586ed14..000000000 --- a/src/resources/AssetManifest.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { Loadable } from './Loader'; - -/** - * A single asset declaration inside an {@link AssetManifest} bundle. - * - * `type` is the loadable class token (e.g. `Texture`, `Sound`), `alias` is - * the key used to retrieve the asset from the {@link Loader}, and `path` is - * the URL or relative path used to fetch it. - */ -export interface AssetEntry { - readonly type: T; - readonly alias: string; - readonly path: string; - readonly options?: unknown; -} - -/** - * Static description of all asset bundles in an application. - * - * Pass to {@link Loader.registerManifest} and then load individual bundles on - * demand with {@link Loader.loadBundle}. Use {@link defineAssetManifest} to - * construct a validated, type-safe manifest at authoring time. - */ -export interface AssetManifest { - readonly bundles: Readonly>; -} - -/** - * Options controlling how a bundle is loaded by {@link Loader.loadBundle}. - * - * Set `background` to `true` to load the bundle through the low-priority - * background queue, and supply `onProgress` for per-bundle progress updates. - */ -export interface LoadBundleOptions { - background?: boolean; - onProgress?: (loaded: number, total: number) => void; -} - -/** - * Thrown by {@link Loader.loadBundle} when one or more assets in the bundle - * fail to load. - * - * The `failures` array contains every entry that errored, letting callers - * distinguish individual per-asset failures from a wholesale network outage. - */ -export class BundleLoadError extends Error { - public readonly bundle: string; - public readonly failures: Array<{ - type: Loadable; - alias: string; - error: Error; - }>; - - public constructor( - bundle: string, - failures: Array<{ - type: Loadable; - alias: string; - error: Error; - }>, - ) { - super(`Failed to load bundle "${bundle}" (${failures.length} failure${failures.length === 1 ? '' : 's'}).`); - - this.name = 'BundleLoadError'; - this.bundle = bundle; - this.failures = failures; - } -} - -/** - * Validates and returns a strongly-typed {@link AssetManifest}. - * - * Validates the manifest shape at runtime (non-empty bundle names, valid entry - * fields, no duplicate aliases per type/bundle) and preserves the literal - * types of the input for downstream type inference. Throws a descriptive - * `Error` on any validation failure. - * - * @example - * ```ts - * const manifest = defineAssetManifest({ - * bundles: { - * ui: [{ type: Texture, alias: 'button', path: 'assets/button.png' }], - * }, - * }); - * loader.registerManifest(manifest); - * ``` - */ -export function defineAssetManifest(manifest: M): M { - validateAssetManifest(manifest); - - return manifest; -} - -function validateAssetManifest(manifest: AssetManifest): void { - if (!isObjectRecord(manifest)) { - throw new Error('Invalid asset manifest: manifest must be an object.'); - } - - if (!isObjectRecord(manifest.bundles)) { - throw new Error('Invalid asset manifest: manifest.bundles must be an object.'); - } - - for (const [bundleName, rawEntries] of Object.entries(manifest.bundles)) { - if (bundleName.trim().length === 0) { - throw new Error('Invalid asset manifest: bundle names must be non-empty strings.'); - } - - if (!Array.isArray(rawEntries)) { - throw new Error(`Invalid asset manifest: bundle "${bundleName}" must be an array of entries.`); - } - - const seenAliasesByType = new Map>(); - - for (const [index, rawEntry] of rawEntries.entries()) { - const location = `bundle "${bundleName}" entry[${index}]`; - - if (!isObjectRecord(rawEntry)) { - throw new Error(`Invalid asset manifest: ${location} must be an object.`); - } - - if (typeof rawEntry.type !== 'function') { - throw new Error(`Invalid asset manifest: ${location} has an invalid "type" token.`); - } - - assertNonEmptyString(rawEntry.alias, `${location} has an invalid "alias".`); - assertNonEmptyString(rawEntry.path, `${location} has an invalid "path".`); - - const type = rawEntry.type as Loadable; - const alias = rawEntry.alias; - - if (!seenAliasesByType.has(type)) { - seenAliasesByType.set(type, new Set()); - } - - const aliases = seenAliasesByType.get(type)!; - - if (aliases.has(alias)) { - throw new Error(`Invalid asset manifest: duplicate (${describeType(type)}, "${alias}") in bundle "${bundleName}".`); - } - - aliases.add(alias); - } - } -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function assertNonEmptyString(value: unknown, message: string): asserts value is string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Invalid asset manifest: ${message}`); - } -} - -function describeType(type: Loadable): string { - return type.name.length > 0 ? type.name : '(anonymous type)'; -} diff --git a/src/resources/AssetRef.ts b/src/resources/AssetRef.ts index 51ce93ea9..133debeba 100644 --- a/src/resources/AssetRef.ts +++ b/src/resources/AssetRef.ts @@ -2,7 +2,7 @@ import { LoadState, type LoadStateValue } from '#core/LoadState'; /** * Deferred handle for value assets (parsed JSON, text, CSV rows, …), returned - * by `loader.get(Json, …)` and friends. Values cannot heal in place the way + * by `loader.get(Asset.kind('json', src))` / a bare value path and friends. Values cannot heal in place the way * resource handles do, so the REF is the stable identity: {@link value} throws * until `'ready'`, {@link loaded} resolves with the value itself, and a failed * ref retries (healing in place) on the next `get`. @@ -12,16 +12,32 @@ export class AssetRef { public readonly _loadState = new LoadState(); private _value: T | undefined; private _hasValue = false; + private _parse: ((raw: unknown) => T) | undefined; public constructor() { this._loadState.begin(); } - /** Load lifecycle of this ref: `'loading' | 'ready' | 'failed'`. */ + /** Load lifecycle of this ref: `'idle' | 'loading' | 'ready' | 'failed'`. */ public get loadState(): LoadStateValue { return this._loadState.value; } + /** Load lifecycle: `'idle' | '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. @@ -41,8 +57,24 @@ export class AssetRef { return this._value as T; } - /** @internal */ - public _fill(value: T): void { + /** @internal — set the SYNCHRONOUS post-load transform (a config's `parse`) applied to the raw value in {@link _fill}. */ + public _setParse(parse: (raw: unknown) => T): void { + this._parse = parse; + } + + /** + * @internal — fill with the raw loaded value, applying `parse` if one was set. + * A throwing `parse` fails ONLY this ref (it does not propagate), so a sibling + * ref sharing the same raw source but a different `parse` is unaffected. + */ + public _fill(raw: unknown): void { + let value: T; + try { + value = (this._parse ? this._parse(raw) : raw) as T; + } catch (error) { + this._fail(error instanceof Error ? error : new Error(String(error))); + return; + } this._value = value; this._hasValue = true; this._loadState.settle(value); diff --git a/src/resources/AssetStatus.ts b/src/resources/AssetStatus.ts new file mode 100644 index 000000000..a2ffc65bf --- /dev/null +++ b/src/resources/AssetStatus.ts @@ -0,0 +1,17 @@ +import type { LoadStateValue } from '#core/LoadState'; + +/** + * 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: `'idle' | '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; +} diff --git a/src/resources/Assets.ts b/src/resources/Assets.ts index e21f9c75b..b25720217 100644 --- a/src/resources/Assets.ts +++ b/src/resources/Assets.ts @@ -1,25 +1,57 @@ -import type { Asset } from './Asset'; import { AssetImpl } from './Asset'; -import type { AnyAssetConfig, AssetInput, InferAssetResource } from './AssetDefinitions'; +import type { AnyAssetConfig, AssetDefinitions, CatalogEntry, InferCatalogLeaf, OptionsForKind } from './AssetDefinitions'; +import { createLeaf } from './assetKindRegistry'; +import { resolveKindByPath } from './extensionKindRegistry'; // --------------------------------------------------------------------------- // Helper types // --------------------------------------------------------------------------- -export type InferAssetsEntries> = { - [K in keyof M]: Asset>; +/** + * 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 = InferCatalogLeaf; + +export type InferAssetsEntries> = { + [K in keyof M]: InferLeaf; }; -type InferAssetsProperties> = { - readonly [K in keyof M]: Asset>; +export type InferAssetsProperties> = { + readonly [K in keyof M]: InferLeaf; }; // --------------------------------------------------------------------------- // Internal implementation // --------------------------------------------------------------------------- +/** + * Normalize a single catalog entry to a plain `{ kind, 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 `Asset.kind(...)`, compound suffixes, or extension registration. 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}". ` + + `Annotate it with Asset.kind(...), use a compound suffix, or register the type's extension (registerExtensionKind / an AssetBinding).`, + ); + } + const config = { 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) { @@ -27,16 +59,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); + 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 `{ kind, 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 { kind, source, ...rest } = config; + const opts = Object.keys(rest).length > 0 ? rest : undefined; + const leaf = createLeaf(kind, source, opts); - entries[key] = assetRef; + entries[key] = leaf; Object.defineProperty(this, key, { - value: assetRef, + value: leaf, enumerable: true, configurable: false, writable: false, @@ -54,23 +93,106 @@ 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 `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' }, + * const TitleAssets = Assets.from({ + * logo: 'sprites/logo.png', // bare path — kind inferred from suffix + * config: { kind: '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; +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 `Asset.kind(...)` descriptor, or an explicit config. Bare + * strings only resolve for leaf-capable kinds; ambiguous/unregistered + * suffixes need `Asset.kind(...)`. (asset-system v2 §4.1, §5) + * + * @remarks The `const` type parameter preserves each field's string LITERAL + * (e.g. `'ship.png'`) so the file suffix can be classified. Without it, under + * a `strictNullChecks: false` tsconfig (e.g. the examples config) the literal + * widens to `string`, `KindByPath` collapses to `never`, and every + * leaf degrades to `unknown` (surfacing as `{}`) — see the strict:false type + * test `test/type-tests/assets-strict-false.type-test.ts`. + */ + from>(definition: M): Assets; -type AssetsConstructorFn = new >(definition: M) => Assets; + /** + * Build a single meta-stamped leaf (a usable placeholder resource or an + * `AssetRef`) from ONE descriptor — the explicit single-asset alternative to + * wrapping it in a one-field {@link from} catalog (asset-system v2 §5). Accepts + * the same descriptor set as a catalog field: a bare path, an `Asset.kind(...)` + * descriptor, or an explicit `{ kind, source, ...opts }` config. The leaf + * starts `'idle'` until a loader adopts it. + * + * @example + * ```ts + * const chunk = Assets.one({ kind: 'json', source: `chunks/${cx}_${cy}.json` }); + * loader.load(chunk, { background: true }); + * await chunk.loaded; + * ``` + */ + one(entry: E): InferCatalogLeaf; + + /** + * Build a record of same-`kind` configs to SPREAD into {@link from} (or into + * another group), applying `shared` options to every entry (asset-system v2 + * §6). A per-entry object overrides the shared options; a bare-string entry + * takes just the shared options. Entries do not repeat the `kind`. + * + * @example + * ```ts + * Assets.from({ + * ...Assets.group('texture', { player: 'player.png', enemy: 'enemy.png' }, { samplerOptions: { minFilter: 'nearest' } }), + * ...Assets.group('sound', { jump: 'jump.wav', hit: 'hit.wav' }), + * }); + * ``` + */ + group)>>( + kind: K, + entries: E, + shared?: OptionsForKind, + ): { readonly [P in keyof E]: { kind: K } & AssetDefinitions[K]['config'] }; +}; + +(AssetsImpl as unknown as { from: unknown }).from = function from>(definition: M): Assets { + return new (AssetsImpl as unknown as AssetsConstructorFn)(definition as never); +}; + +(AssetsImpl as unknown as { one: unknown }).one = function one(entry: E): InferCatalogLeaf { + const { kind, source, ...rest } = _normalizeEntry(entry); + const opts = Object.keys(rest).length > 0 ? rest : undefined; + return createLeaf(kind, source, opts) as unknown as InferCatalogLeaf; +}; + +(AssetsImpl as unknown as { group: unknown }).group = function group( + kind: keyof AssetDefinitions, + entries: Record)>, + shared?: object, +): Record { + const out: Record = {}; + const base = shared ?? {}; + + for (const [key, entry] of Object.entries(entries)) { + // A per-entry object overrides the shared options; a bare string takes only + // the shared options. Either way the group's `kind` is stamped on. + out[key] = (typeof entry === 'string' ? { kind, source: entry, ...base } : { kind, ...base, ...entry }) as AnyAssetConfig; + } + + return out; +}; -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 a9a565f3f..6f9ded95b 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -5,22 +5,22 @@ import type { AssetHandler, AssetLoadRequest } from '#extensions/Extension'; import { type BmFont } from '#rendering/text/BmFont'; import type { Texture } from '#rendering/texture/Texture'; -import { Asset, AssetImpl } from './Asset'; +import { Asset, AssetImpl, type ValueAsset } from './Asset'; import { parseContainer } from './AssetContainer'; -import type { AssetDefinitions, AssetInput, InferAssetResource } from './AssetDefinitions'; +import type { AssetDefinitions, AssetInput, InferAssetResource, KindByPath, LeafForPath, ResourceForKind, ValueAssetKind } from './AssetDefinitions'; import type { AssetFactory } from './AssetFactory'; -import type { AssetManifest, LoadBundleOptions } from './AssetManifest'; -import { BundleLoadError, defineAssetManifest } from './AssetManifest'; +import { createLeaf } from './assetKindRegistry'; +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'; -import type { TextureFactoryOptions } from './factories/TextureFactory'; +import { resolveKindByPath } from './extensionKindRegistry'; import type { AssetConstructor } from './FactoryRegistry'; import { FactoryRegistry } from './FactoryRegistry'; import { LoadingQueue } from './LoadingQueue'; -import type { PreSizeOptions, SeamlessAdapter } from './seamless'; +import type { SeamlessAdapter } from './seamless'; import { BinaryAsset, CsvAsset, FontAsset, type ImageAsset, Json, SubtitleAsset, type SvgAsset, TextAsset, WasmAsset, XmlAsset } from './tokens'; // --------------------------------------------------------------------------- @@ -132,7 +132,7 @@ export type PathExtension = MatchExtension] extends [never]` guard is load-bearing: indexing * {@link ExtensionTypeMap} with `never` would silently produce `never` rather @@ -141,59 +141,8 @@ export type PathExtension = MatchExtension = [PathExtension] extends [never] ? unknown : ExtensionTypeMap[PathExtension]; /** - * Additional asset types accepted by the **token form** of {@link Loader.load} - * (`load(MyType, 'file.ext')`) for a given file extension, beyond the - * path-only type registered in {@link ExtensionTypeMap}. - * - * Augment via declaration merging when a format package ships an advanced - * source-model token that shares a file extension with its common-case - * runtime type. The path-only form (`load('file.ext')`) is unaffected and - * keeps resolving to the {@link ExtensionTypeMap} entry alone: - * ```ts - * declare module '@codexo/exojs' { - * interface ExtensionTypeMap { tmj: TileMap } // load('world.tmj') → TileMap - * interface ExtensionTokenTypeMap { tmj: TiledMap } // load(TiledMap, 'world.tmj') also allowed - * } - * ``` - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ExtensionTokenTypeMap {} - -/** Resolves the additional token types allowed for extension `E`, or `never` when none are registered. */ -type TokenTypesFor = E extends keyof ExtensionTokenTypeMap ? ExtensionTokenTypeMap[E] : never; - -/** - * Constrains a {@link Loadable} token against the types registered for a - * given path's extension. When the extension is in {@link ExtensionTypeMap}, - * `T` must produce a value assignable to the registered union (including any - * extra token types from {@link ExtensionTokenTypeMap}) — otherwise resolves - * to `never`, triggering a compile-time error. - * - * For paths with an unregistered extension — including non-literal `string` - * paths and extension-less paths, where no extension can be derived — the - * constraint is skipped and any `T` is accepted (runtime behaviour is - * unchanged). - * - * ```ts - * // ExtensionTypeMap: { ogg: Sound | Video } - * loader.load(Sound, 'effect.ogg') // ✓ Sound ∈ Sound | Video - * loader.load(BitmapText, 'effect.ogg') // ✗ BitmapText ∉ Sound | Video - * loader.load(Sound, 'theme.custom') // ✓ .custom not in map → unconstrained - * loader.load(Sound, dynamicPath) // ✓ string path → unconstrained - * ``` - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ConstrainedLoadable unknown, S extends string> = [PathExtension] extends [never] - ? T - : PathExtension extends keyof ExtensionTypeMap - ? LoadReturn extends ExtensionTypeMap[PathExtension] | TokenTypesFor> - ? T - : never - : T; - -/** - * Context object passed to custom asset-type load handlers registered via - * {@link Loader.registerAssetType}. + * Context object passed to custom asset-type load handlers bound via + * `bindAsset` / `defineAsset`. * * The `fetch*` helpers route through the loader's configured cache strategy * and IDB stores, giving custom handlers the same caching behaviour as @@ -218,14 +167,6 @@ export interface AssetLoaderContext { fetchJson(source: string): Promise; } -/** - * Accepted value types in the homogeneous batch load API. - * - * Either a raw source string or a flat config object containing at least - * `source` plus any type-specific extra fields. - */ -export type BatchValue = string | ({ source: string } & Record); - /** * Construction options for {@link Loader}. * @@ -244,15 +185,24 @@ export interface LoaderOptions { concurrency?: number; } +/** + * Options for the catalog/asset/leaf {@link Loader.load} forms. + * + * With `background: true` every adopted leaf is still claimed and registered + * (so it heals in place and a later {@link Loader.get} returns the same + * instance), but its fetch is routed through the low-priority background queue + * instead of started immediately: it streams concurrency-capped, drops from the + * queue if released at refcount 0, and is boosted to fetch now on a direct + * `get()`. Foreground loading (no options) is unaffected. + */ +export interface LoadOptions { + background?: boolean; +} + // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- -interface ManifestEntry { - readonly path: string; - readonly options?: unknown; -} - interface QueueEntry { readonly type: AssetConstructor; readonly alias: string; @@ -260,7 +210,7 @@ interface QueueEntry { readonly options?: unknown; } -/** Stored entry for handler-based {@link Loader.registerAssetType} registrations. */ +/** Stored entry for handler-based asset bindings (via `bindAsset`). */ interface HandlerEntry { load: (config: unknown, ctx: AssetLoaderContext) => Promise; /** Optional discriminator for in-flight identity keying; overrides source-only default. */ @@ -282,13 +232,11 @@ interface HandlerEntry { * SVG, VTT, binary, and WASM) and allows registering custom factories via * {@link register}. * - * Assets can be loaded in three ways: - * - **Direct** — `loader.load(Texture, 'hero.png')` fetches immediately and - * resolves to the finished asset. - * - **Bundle** — declare assets in a manifest with {@link registerManifest}, - * then call {@link loadBundle} to load groups on demand. - * - **Background** — call {@link backgroundLoad} or {@link loadAll} to - * pre-warm everything registered in the manifest at low priority. + * Assets can be loaded in two ways: + * - **Direct** — `loader.load(Assets.from({ hero: 'hero.png' }))` fetches + * immediately and resolves to the finished assets. + * - **Background** — pass `{ background: true }` to `load(...)` to pre-warm + * assets at low priority; {@link awaitBackground} resolves once the queue drains. * * Once loaded, assets are stored in memory and returned from cache on * subsequent `load` or {@link get} calls without re-fetching. @@ -296,7 +244,7 @@ interface HandlerEntry { * @example * ```ts * const loader = new Loader({ basePath: '/assets/', cache: new IndexedDbStore('game') }); - * const texture = await loader.load(Texture, 'hero.png'); + * const { hero } = await loader.load(Assets.from({ hero: 'hero.png' })); * ``` */ export class Loader { @@ -308,8 +256,6 @@ export class Loader { // WeakMap so it never retains resources; only object resources participate // (primitive results like parsed JSON/text are not keyable → null). private readonly _resourceKeys = new WeakMap(); - private readonly _manifest = new Map>(); - private readonly _bundles = new Map(); private readonly _inFlight = new Map>(); private readonly _typeIds = new WeakMap(); private readonly _preventStoreKeys = new Set(); @@ -323,7 +269,7 @@ export class Loader { private readonly _identityKeyToAliases = new Map>(); // In-flight promises keyed by identity (source-based) for cross-alias dedup private readonly _inFlightByIdentity = new Map>(); - // Handler entries registered via the handler-based registerAssetType overload + // Handler entries bound via bindAsset (the AssetBinding handler form) private readonly _handlerFunctions = new Map(); // Maps lower-case file extensions (without dot) to the constructor to use private readonly _extensionMap = new Map(); @@ -333,16 +279,41 @@ 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 }>(); - private readonly _valueTokens: ReadonlySet = new Set([Json, TextAsset, CsvAsset, XmlAsset, SubtitleAsset, BinaryAsset, WasmAsset]); + // 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 }>(); + // Single source for the value kind ↔ dispatch token mapping: both the + // membership set below and `_valueTokenForKind` derive from it, and a + // `Record` is compile-checked to cover exactly the value + // kinds (vtt + srt share the SubtitleAsset token). @internal + private readonly _valueTokenByKind: Readonly> = { + json: Json, + text: TextAsset, + csv: CsvAsset, + xml: XmlAsset, + vtt: SubtitleAsset, + srt: SubtitleAsset, + binary: BinaryAsset, + wasm: WasmAsset, + }; + + /** The value-asset token for a value kind name, or `undefined` for non-value / extension kinds. @internal */ + private _valueTokenForKind(kind: keyof AssetDefinitions): AssetConstructor | undefined { + return (this._valueTokenByKind as Partial>)[kind]; + } // ── Refcount / claims (asset-system v2 §4.7) ────────────────────────────── /** App-lifetime claim scope for direct `app.loader.get/load(…)` calls. @internal */ @@ -371,8 +342,6 @@ export class Loader { /** Dispatched after each background-queue item completes, with the running loaded/total counts. */ public readonly onProgress = new Signal<[loaded: number, total: number]>(); - /** Dispatched after each asset within a named bundle completes loading. */ - public readonly onBundleProgress = new Signal<[name: string, loaded: number, total: number]>(); /** Dispatched when any asset finishes loading and is stored in memory. */ public readonly onLoaded = new Signal<[type: AssetConstructor, alias: string, resource: unknown]>(); /** Dispatched when an asset fails to load during background or bundle loading. */ @@ -413,109 +382,6 @@ export class Loader { return this; } - /** - * Associates a string type name with a simple load handler. - * - * The handler's `load` method receives the full config object (including - * `source` and every extra field declared via `AssetDefinitions` augmentation) - * plus a {@link AssetLoaderContext} containing the loader instance. - * - * This form is intended for custom asset types that manage their own - * network access; the persistent cache layer is bypassed. - * - * @example - * ```ts - * loader.registerAssetType('tileMap', { - * load(config, { loader }) { - * return loadTileMap({ source: config.source, format: config.format, loader }); - * }, - * }); - * ``` - */ - public registerAssetType( - typeName: K, - handler: { - /** - * Optional discriminator for in-flight dedup and identity tracking. - * - * Return a string that uniquely identifies the conceptual asset given its - * full config. The default (when omitted) is `config.source`, which is - * correct for assets where the source URL alone determines the result. - * Supply this when extra config fields affect the loaded output — e.g. - * `\`${config.source}:${config.format}\`` — so that two assets with the - * same source but different format are kept separate in the in-flight map. - */ - getIdentityKey?(config: AssetDefinitions[K]['config']): string; - load(config: AssetDefinitions[K]['config'], context: AssetLoaderContext): Promise; - }, - ): this; - - /** - * Associates a string type name (e.g. `'tileMap'`) with the constructor - * used as the asset token and, optionally, registers a factory for it. - * - * Required for declaration-merge extensions of {@link AssetDefinitions} - * so that `loader.load({ map: { type: 'tileMap', source: '…' } })` works. - */ - public registerAssetType(typeName: string, ctor: AssetConstructor, factory?: AssetFactory): this; - - public registerAssetType( - typeName: string, - ctorOrHandler: - | AssetConstructor - | { - getIdentityKey?(config: unknown): string; - load(config: unknown, context: AssetLoaderContext): Promise; - }, - factory?: AssetFactory, - ): this { - if (typeof ctorOrHandler === 'function') { - this._assetTypeMap.set(typeName, ctorOrHandler); - - if (factory) { - this._registry.register(ctorOrHandler, factory); - } - } else { - // Handler-based form: create a synthetic constructor for type identity - const syntheticCtor = class {} as unknown as AssetConstructor; - - Object.defineProperty(syntheticCtor, 'name', { value: typeName }); - this._assetTypeMap.set(typeName, syntheticCtor); - - const boundIdentityKey = ctorOrHandler.getIdentityKey?.bind(ctorOrHandler); - - this._handlerFunctions.set(syntheticCtor, { - load: (config, ctx) => ctorOrHandler.load(config, ctx), - ...(boundIdentityKey !== undefined && { getIdentityKey: boundIdentityKey }), - }); - } - - return this; - } - - // ----------------------------------------------------------------------- - // Extension registration - // ----------------------------------------------------------------------- - - /** - * Associates a file extension with an asset type so that - * `loader.load('path.ext')` (the single-string overload) can infer the - * type automatically. - * - * `ext` is matched case-insensitively and the leading dot is optional. - * The type must already have a registered factory (via {@link register} or - * {@link registerAssetType}). - * - * ```ts - * loader.registerExtension('tmj', TiledMap); - * const map = await loader.load('world.tmj'); // TiledMap - * ``` - */ - public registerExtension(ext: string, type: AssetConstructor): this { - this._extensionMap.set(ext.replace(/^\./, '').toLowerCase(), type); - return this; - } - /** * Registers the seamless-handle adapter for `type`, enabling the deferred * `get(type, source)` form for it. One adapter per type. @@ -531,121 +397,10 @@ export class Loader { return this; } - /** - * Validates and registers an {@link AssetManifest}, making its bundles - * available to {@link loadBundle}. - * - * Throws if any bundle name is already registered or if two entries for - * the same (type, alias) pair have conflicting paths or options. - * Equivalent definitions (same path, deeply-equal options) are allowed - * and de-duplicated silently. - */ - public registerManifest(manifest: AssetManifest): this { - const normalizedManifest = defineAssetManifest(manifest); - const plannedDefinitions = new Map(); - const pendingBundles = new Array<[name: string, entries: QueueEntry[]]>(); - - for (const [bundleName, bundleEntries] of Object.entries(normalizedManifest.bundles)) { - if (this._bundles.has(bundleName)) { - throw new Error(`Bundle "${bundleName}" is already registered.`); - } - - const normalizedEntries = new Array(); - - for (const bundleEntry of bundleEntries) { - const type = bundleEntry.type; - const key = this._key(type, bundleEntry.alias); - const existingDefinition = plannedDefinitions.get(key) ?? this._getManifestEntry(type, bundleEntry.alias); - - if (existingDefinition && !this._isManifestDefinitionEquivalent(existingDefinition, bundleEntry.path, bundleEntry.options)) { - throw new Error(`Conflicting asset definition for (${this._describeType(type)}, "${bundleEntry.alias}") while registering bundle "${bundleName}".`); - } - - plannedDefinitions.set(key, { - path: bundleEntry.path, - options: bundleEntry.options, - }); - - normalizedEntries.push({ - type, - alias: bundleEntry.alias, - path: bundleEntry.path, - options: bundleEntry.options, - }); - } - - pendingBundles.push([bundleName, normalizedEntries]); - } - - for (const [bundleName, bundleEntries] of pendingBundles) { - for (const entry of bundleEntries) { - this._addManifestEntry(entry.type, entry.alias, entry.path, entry.options); - } - - this._bundles.set(bundleName, bundleEntries); - } - - return this; - } - - /** - * Loads all assets declared in the named bundle concurrently. - * - * Dispatches {@link onBundleProgress} (and the optional `onProgress` - * callback) after each asset completes. If any assets fail, a - * {@link BundleLoadError} is thrown after all assets have settled, - * containing every individual failure. Throws immediately if `name` has - * not been registered. - */ - public async loadBundle(name: string, options: LoadBundleOptions = {}): Promise { - const bundle = this._bundles.get(name); - - if (!bundle) { - throw new Error(`Unknown bundle "${name}".`); - } - - const total = bundle.length; - let loaded = 0; - const failures = new Array<{ - type: Loadable; - alias: string; - error: Error; - }>(); - - if (total === 0) { - this._emitBundleProgress(name, 0, 0, options.onProgress); - - return; - } - - await Promise.all( - bundle.map(async entry => { - try { - await (options.background - ? this._loadSingleBackground(entry.type, entry.alias, entry.path, entry.options) - : this._loadSingle(entry.type, entry.alias, entry.options, entry.path)); - } catch (error: unknown) { - failures.push({ - type: entry.type, - alias: entry.alias, - error: this._normalizeError(error), - }); - } finally { - loaded++; - this._emitBundleProgress(name, loaded, total, options.onProgress); - } - }), - ); - - if (failures.length > 0) { - throw new BundleLoadError(name, failures); - } - } - /** * Load every asset packed into a binary container (`.exoa`) in a **single - * request**. Unlike {@link loadBundle} (one fetch per entry), a container is - * one file with an embedded index: its bytes are fetched once (and cached + * request**. A container is one file with an embedded index: its bytes are + * fetched once (and cached * cross-session like any asset), then each slice is unpacked through its * type's handler and stored under the entry's alias — retrievable with * {@link get} exactly as if it had been loaded individually. @@ -682,52 +437,35 @@ export class Loader { ); } - /** - * Returns `true` if every asset in the named bundle has been loaded and is - * currently held in the in-memory resource store. - */ - public hasBundle(name: string): boolean { - const bundle = this._bundles.get(name); - - if (!bundle) { - return false; - } - - return bundle.every(entry => this._hasResource(entry.type, entry.alias)); - } - // ----------------------------------------------------------------------- - // Loading — Json overloads (generic widening) + // Loading — new Asset / Assets / config-map overloads // ----------------------------------------------------------------------- /** - * Fetches and processes one or more assets of the given type. + * Fetches and processes one or more assets. + * + * - **Path string** — inferred from the file extension; resolves the asset. + * - **Asset** — a single typed asset reference from `Asset.kind(...)`. + * - **Assets** — a typed catalog from `Assets.from(...)`; keys become aliases. * - * - **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. + * (The inline record-catalog form `{ alias: { kind, source } }` is no longer a + * public overload — build catalogs with `Assets.from(...)`; a runtime record + * fallback is retained only for internal multi-alias plumbing.) * * 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). + * Per-asset options ride on the `Asset.kind(kind, source, options)` descriptor + * (or the extra fields of a config object). */ - 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>; - - // ----------------------------------------------------------------------- - // Loading — new Asset / Assets / config-map overloads - // ----------------------------------------------------------------------- - public load(asset: Asset): LoadingQueue; - public load>(assets: Assets): LoadingQueue>; - public load>(config: M): LoadingQueue>; + public load>(assets: Assets, options?: LoadOptions): 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, options?: LoadOptions): LoadingQueue; + // Single handle-hybrid leaf (an `Assets.from()` property): adopt + resolve its value. + public load(leaf: T, options?: LoadOptions): LoadingQueue; // ----------------------------------------------------------------------- // Loading — extension-based (type inferred from file extension) @@ -740,12 +478,12 @@ export class Loader { * - `.fnt` → {@link BmFont} * - `.woff`, `.woff2`, `.ttf`, `.otf` → `FontFace` (family inferred from filename) * - * Register additional mappings via {@link registerExtension}. + * Register additional mappings via `defineAsset` (its `extensions`). * Extend the return type by augmenting {@link ExtensionTypeMap}. * * Paths whose extension is **not** in {@link ExtensionTypeMap} are rejected at - * compile time — use the token form (`load(MyType, path)`) for unregistered - * extensions. + * compile time — use the descriptor form (`load(Asset.kind(kind, path))`) for + * unregistered extensions. * * ```ts * const font = await loader.load('fonts/ui.fnt'); // BmFont @@ -761,21 +499,19 @@ 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>; - - // ----------------------------------------------------------------------- - // Loading — generic overloads (return type inferred from class) - // ----------------------------------------------------------------------- - - public load(type: ConstrainedLoadable, path: S, options?: unknown): LoadingQueue>; - public load(type: T, paths: readonly string[], options?: unknown): LoadingQueue>>; - public load(type: T, items: Readonly>, options?: unknown): LoadingQueue>>; + // Value-inclusive form — a bare value suffix (json/txt/csv/…) resolves the raw + // resource value (asset-system v2 §4.4); `ResourceForKind<'json'>` = `unknown`. + // Single-arg only: the runtime bare-path branch fires solely for + // `arg1 === undefined`, so an `options?` param here would advertise a + // parameter the runtime ignores (per-asset options go through `Asset.kind(kind, src, opts)`). + public load(path: [KindByPath] extends [never] ? never : S): LoadingQueue>>; // ----------------------------------------------------------------------- // Loading — implementation // ----------------------------------------------------------------------- - public load(arg0: unknown, arg1?: unknown, arg2?: unknown): LoadingQueue { - return this._loadClaimed(this._rootClaimer, arg0, arg1, arg2); + public load(arg0: unknown, arg1?: unknown): LoadingQueue { + return this._loadClaimed(this._rootClaimer, arg0, arg1); } /** @@ -786,7 +522,7 @@ export class Loader { * frees) is observationally a no-op for existing callers. * @internal */ - public _loadClaimed(claimer: symbol, arg0: unknown, arg1?: unknown, arg2?: unknown): LoadingQueue { + public _loadClaimed(claimer: symbol, arg0: unknown, arg1?: unknown): LoadingQueue { // 1. Single Asset if (arg0 instanceof AssetImpl) { const asset = arg0 as Asset; @@ -795,18 +531,21 @@ 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]>; + const background = (arg1 as LoadOptions | undefined)?.background === true; + + for (const [, leaf] of entries) { + this._adopt(leaf, claimer, background); + } - return this._createLoadingQueue(claimer, items, results => { + return this._createAdoptedQueue(entries, results => { const out: Record = {}; - for (const { alias } of items) { + for (const [alias] of entries) { out[alias] = results.get(alias); } @@ -814,13 +553,32 @@ 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; + const background = (arg1 as LoadOptions | undefined)?.background === true; + this._adopt(leaf, claimer, background); + + 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; - 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().`); + throw new Error(`Loader: no type registered for any extension of "${path}". Register one via defineAsset() (its extensions).`); } // FontAsset requires a family option — infer it from the filename when not provided @@ -846,101 +604,10 @@ export class Loader { return queue; } - // 3. Old path: first arg is a Loadable constructor - if (typeof arg0 === 'function') { - const ctor = arg0 as Loadable; - const source = arg1 as string | readonly string[] | Readonly>; - const options = arg2; - - if (typeof source === 'string') { - this._claim(this._key(ctor, source), ctor, source, claimer); - this._onFgBatchStart(source, source); - let notifyFn: ((success: boolean) => void) | null = null; - const promise = this._loadSingle(ctor, source, options).then( - v => { - notifyFn?.(true); - this._onFgBatchSettled(source, true); - return v; - }, - e => { - notifyFn?.(false); - this._onFgBatchSettled(source, false, this._normalizeError(e)); - throw e; - }, - ); - - const queue = new LoadingQueue(promise, 1); - notifyFn = queue._notifyItem.bind(queue); - - return queue; - } - - if (Array.isArray(source)) { - const paths = source as readonly string[]; - let notifyFn: ((success: boolean) => void) | null = null; - const results: unknown[] = new Array(paths.length); - const promises = paths.map((path, i) => { - this._claim(this._key(ctor, path), ctor, path, claimer); - this._onFgBatchStart(path, path); - return this._loadSingle(ctor, path, options).then( - v => { - results[i] = v; - notifyFn?.(true); - this._onFgBatchSettled(path, true); - }, - e => { - notifyFn?.(false); - this._onFgBatchSettled(path, false, this._normalizeError(e)); - throw e; - }, - ); - }); - - const promise = Promise.all(promises).then(() => results); - - const queue = new LoadingQueue(promise, paths.length); - notifyFn = queue._notifyItem.bind(queue); - - return queue; - } - - // Record - const entries = Object.entries(source as Record); - const result: Record = {}; - let notifyFn: ((success: boolean) => void) | null = null; - const promises = entries.map(([alias, pathOrConfig]) => { - const path = typeof pathOrConfig === 'string' ? pathOrConfig : pathOrConfig.source; - const itemOptions = - typeof pathOrConfig === 'string' - ? options - : { ...pathOrConfig, ...(typeof options === 'object' && options !== null ? (options as Record) : {}) }; - - this._claim(this._key(ctor, alias), ctor, alias, claimer); - this._onFgBatchStart(alias, path); - - return this._loadSingle(ctor, alias, itemOptions, path).then( - v => { - result[alias] = v; - notifyFn?.(true); - this._onFgBatchSettled(alias, true); - }, - e => { - notifyFn?.(false); - this._onFgBatchSettled(alias, false, this._normalizeError(e)); - throw e; - }, - ); - }); - - const promise = Promise.all(promises).then(() => result); - - const queue = new LoadingQueue(promise, entries.length); - notifyFn = queue._notifyItem.bind(queue); - - return queue; - } - - // 4. Plain config map: Record + // Internal/legacy record fallback: `Record`. The TYPED + // inline record-catalog overload was removed (asset-system v2 delta §5/§14) — + // typed callers go through `Assets.from({...})` — but the runtime path is kept + // for internal multi-alias/identity plumbing and its coverage. const configMap = arg0 as Record; const items = Object.entries(configMap).map(([alias, value]) => ({ alias, @@ -963,104 +630,31 @@ export class Loader { // ----------------------------------------------------------------------- /** - * Declares sources and enqueues them for low-priority background fetching in - * one step (replaces the removed `add()`; PixiJS model). Progress is - * reported via {@link onProgress}; already-loaded, in-flight, and - * already-queued sources are skipped. `get()`/`load()` on a queued source - * boosts it to the front. The zero-argument form enqueues everything - * registered in the manifest (bundles/manifest die with the asset graph). - */ - public backgroundLoad(): void; - public backgroundLoad(type: Loadable, source: string, options?: unknown): void; - public backgroundLoad(type: Loadable, sources: readonly string[], options?: unknown): void; - public backgroundLoad(type?: Loadable, source?: string | readonly string[], options?: unknown): void { - this._backgroundClaimed(this._rootClaimer, type, source, options); - } - - /** - * Claimed variant of {@link backgroundLoad}: identical queueing, but every - * declared source is claimed under `claimer` (refcount) so a scene-scoped - * pre-warm releases correctly and a not-yet-started entry is dropped from the - * queue at refcount 0. The public {@link backgroundLoad} delegates here under - * the app-lifetime {@link _rootClaimer}. - * @internal - */ - public _backgroundClaimed(claimer: symbol, type?: Loadable, source?: string | readonly string[], options?: unknown): void { - // Start a fresh progress batch only when idle; a re-entrant call while the - // queue is draining extends the running batch instead (mirrors the - // accounting in _loadSingleBackground). - if (this._backgroundQueue.length === 0 && this._backgroundActive === 0) { - this._backgroundLoaded = 0; - this._backgroundTotal = 0; - } - - if (type !== undefined && source !== undefined) { - const ctor: AssetConstructor = type; - const sources: readonly string[] = typeof source === 'string' ? [source] : source; - - for (const src of sources) { - this._claim(this._key(ctor, src), ctor, src, claimer); - this._addManifestEntry(ctor, src, src, options); - - if (this._hasResource(ctor, src)) continue; - if (this._inFlight.has(this._key(ctor, src))) continue; - if (this._isQueuedInBackground(ctor, src)) continue; - - this._backgroundQueue.push({ type: ctor, alias: src, path: src, options }); - this._backgroundTotal++; - } - - this._drainBackground(); - - return; - } - - for (const [manifestType, entries] of this._manifest) { - for (const [alias, entry] of entries) { - this._claim(this._key(manifestType, alias), manifestType, alias, claimer); - - if (this._hasResource(manifestType, alias)) continue; - - const key = this._key(manifestType, alias); - - if (this._inFlight.has(key)) continue; - if (this._isQueuedInBackground(manifestType, alias)) continue; - - this._backgroundQueue.push({ - type: manifestType, - alias, - path: entry.path, - options: entry.options, - }); - this._backgroundTotal++; - } - } - - this._drainBackground(); - } - - /** - * Starts {@link backgroundLoad} and returns a promise that resolves when - * every queued background asset has finished loading (successfully or not). + * Resolves when the low-priority background queue has fully drained — every + * leaf enqueued via `load(target, { background: true })` has finished loading + * (successfully or not). Kicks the queue first, so a concurrency change that + * left pending entries unstarted still makes progress. * * Individual asset errors are reported via {@link onError} but do not * reject the returned promise. */ - public loadAll(): Promise { + public awaitBackground(): Promise { return new Promise(resolve => { - this._backgroundResolve = resolve; - this.backgroundLoad(); + this._drainBackground(); if (this._backgroundQueue.length === 0 && this._backgroundActive === 0) { - this._backgroundResolve = null; resolve(); + + return; } + + this._backgroundResolve = resolve; }); } /** * Sets the maximum number of simultaneous background-queue fetches. - * Takes effect on the next {@link backgroundLoad} or {@link loadAll} call. + * Takes effect on the next {@link awaitBackground} call or `load(…, { background })`. */ public setConcurrency(n: number): this { this._concurrency = n; @@ -1069,65 +663,88 @@ export class Loader { } // ----------------------------------------------------------------------- - // Retrieval — Json overloads + // Retrieval // ----------------------------------------------------------------------- /** - * Seamless deferred access (asset-system v2). Returns SYNCHRONOUSLY and - * never throws: an already-loaded source returns the stored texture; an + * Seamless deferred access by path (asset-system v2). Returns SYNCHRONOUSLY + * and never throws: an already-loaded source returns the stored resource; an * unknown source returns a placeholder handle immediately, starts the * fetch, and fills the handle in place when the payload arrives (track it * via {@link Texture.loadState} / {@link Texture.loaded}). Failed loads * show the {@link 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 - * {@link load} — and options are first-wins: conflicting options on a + * The asset type is inferred from the file extension (basename-only, + * longest-suffix-first; see {@link 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. The same source always yields the same instance — also + * across {@link load} — and options are first-wins: conflicting options on a * later call are ignored with a one-time dev warning. * - * @remarks For a seamless type, `get(Type, source)` on an unloaded source + * @remarks For a seamless type, `get('sprite.png')` on an unloaded source * returns a `'loading'` placeholder and fetches URL `` — it no longer * throws "missing resource". A bare alias that isn't a real path (a typo, or a * not-yet-preloaded alias) therefore fetches that string and can 404 quietly * instead of throwing; preloaded aliases still return the stored payload. This * is intended seamless-by-default behaviour — the note is for debuggability. + * For a dynamic source, use `get(Asset.kind('texture', dynamicPath))`. */ - 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(path: LoadByPath extends Texture | Sound ? S : never, options?: unknown): LoadByPath; /** - * Seamless access by path alone — the asset type is inferred from the file - * extension (basename-only, longest-suffix-first; see {@link 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. + * 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: LoadByPath extends Texture | Sound ? S : never, options?: unknown): LoadByPath; + public get(path: [KindByPath] extends [never] ? never : S, options?: unknown): LeafForPath; + + /** + * Legacy in-memory lookup: retrieves a previously loaded asset by type + alias + * (for non-seamless types and `loadContainer`-loaded assets). This is a cache + * lookup, NOT a fetch — the token *fetch* forms `get(Type, src)` were removed. + * Prefer bare-path `get('x.png')` or `get(Asset.kind(...))`. + * @advanced + */ + public get(type: T, alias: string): LoadReturn; /** - * Seamless access to a value asset: returns a stable {@link 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. + * 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(type: typeof Json, source: string, options?: unknown): AssetRef; - public get(type: typeof TextAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof CsvAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof XmlAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof SubtitleAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof BinaryAsset, source: string, options?: unknown): AssetRef; - public get(type: typeof WasmAsset, source: string, options?: unknown): AssetRef; + public get>(catalog: Assets): InferAssetsProperties; /** - * 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 {@link peek} - * for a non-throwing alternative, or {@link has} to guard the call. + * Seamless/value access from an `Asset.kind(...)` descriptor (asset-system v2 §4.2) — + * the replacement for the removed `get(Type, dynamicSource)` form. Builds and + * adopts the descriptor's handle-hybrid leaf: a resource kind yields its + * heal-in-place handle, a value kind a stable {@link AssetRef}. A kind with + * neither a seamless adapter nor a value channel throws with guidance to use + * `load(Asset.kind(...))`. + * + * The return type follows the {@link ValueAsset} brand (as {@link InferCatalogLeaf} + * does): a value-kind descriptor (`Asset.kind('json', …)`) returns + * `AssetRef` — even for an object payload — while a resource-kind descriptor + * returns the resource itself, so the type always matches the runtime value. + * + * Unlike bare-path `get('x.png')`, this form is **not instance-deduped by + * source**: each call builds a fresh leaf, so repeated `get(Asset.kind(kind, sameSrc))` + * accumulates distinct handles (all healing to the same deduped backend + * payload). It is the dynamic-source escape hatch — capture the handle once. */ - public get(type: T, alias: string): LoadReturn; - public get(typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { - return this._getClaimed(this._rootClaimer, typeOrPath, source, options); + public get(asset: ValueAsset): AssetRef; + public get(asset: Asset): T; + + /** + * 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): unknown { + return this._getClaimed(this._rootClaimer, typeOrPath, source); } /** @@ -1139,77 +756,102 @@ export class Loader { * `'loading'`, which `_getSeamless` alone would not re-fetch). * @internal */ - public _getClaimed(claimer: symbol, typeOrPath: Loadable | string, source?: unknown, options?: unknown): unknown { - if (typeof typeOrPath === 'string') { - const path = typeOrPath; - const ctor = this._resolveExtensionType(path); + public _getClaimed(claimer: symbol, typeOrPath: Loadable | string | object, source?: 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 = {}; - if (ctor === undefined) { - throw new Error(`Loader: no type registered for any extension of "${path}". Register one via loader.registerExtension().`); + 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; } - const pathAdapter = this._seamlessAdapters.get(ctor); + return out; + } - if (pathAdapter === undefined) { - throw new Error(`Loader: type ${this._describeType(ctor)} inferred from "${path}" has no seamless adapter — use load() instead.`); + // Single `Asset.kind(...)` descriptor (e.g. `get(Asset.kind('json', 'x.json'))` / + // `get(Asset.kind('texture', dynamicPath))`) — build its handle-hybrid leaf from the + // config, adopt it, and return it. A value kind yields an AssetRef, a + // resource kind the seamless placeholder handle. Mirrors `load`'s AssetImpl + // branch and the single-meta-leaf path below. Must precede the string branch + // (an AssetImpl carries no stamped meta, so the guard above misses it). + if (typeOrPath instanceof AssetImpl) { + const { kind, source: src, ...rest } = typeOrPath._config; + const opts = Object.keys(rest).length > 0 ? rest : undefined; + + let leaf: object; + try { + leaf = createLeaf(kind, src, opts); + } catch { + throw new Error(`Loader: get() is for seamless/value assets; the "${kind}" kind has neither — use load(Asset.kind('${kind}', ...)) instead.`); } - const handle = this._getSeamless(ctor, pathAdapter, path, source); - this._claim(this._key(ctor, path), ctor, path, claimer); + this._adopt(leaf, claimer); - return handle; + return leaf; } - const ctor = typeOrPath; - const adapter = this._seamlessAdapters.get(ctor); + // Single meta-stamped leaf (e.g. `get(assets.ship)`) — adopt and return it. + if (_readMeta(typeOrPath) !== undefined) { + this._adopt(typeOrPath as object, claimer); - if (adapter !== undefined) { - if (typeof source === 'string') { - const handle = this._getSeamless(ctor, adapter, source, options); - this._claim(this._key(ctor, source), ctor, source, claimer); + return typeOrPath; + } - return handle; - } + if (typeof typeOrPath === 'string') { + const path = typeOrPath; + const ctor = this._resolveExtensionType(path); - if (Array.isArray(source)) { - const paths: readonly string[] = source; + if (ctor !== undefined) { + const pathAdapter = this._seamlessAdapters.get(ctor); - return paths.map(path => { - const handle = this._getSeamless(ctor, adapter, path, options); + if (pathAdapter !== undefined) { + const handle = this._getSeamless(ctor, pathAdapter, path, source); this._claim(this._key(ctor, path), ctor, path, claimer); return handle; - }); + } } - const out: Record = {}; - const items = source as Readonly>; + // 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; - for (const [key, path] of Object.entries(items)) { - out[key] = this._getSeamless(ctor, adapter, path, options); - this._claim(this._key(ctor, path), ctor, path, claimer); - } + if (valueToken !== undefined) { + const ref = this._getRef(valueToken, path, source); + this._claim(this._key(valueToken, path), valueToken, path, claimer); - return out; - } + return ref; + } - if (this._valueTokens.has(ctor) && typeof source === 'string') { - const ref = this._getRef(ctor, source, options); - this._claim(this._key(ctor, source), ctor, source, 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 defineAsset() (its extensions).`); + } - return ref; + throw new Error(`Loader: type ${this._describeType(ctor)} inferred from "${path}" has no seamless adapter — use load() instead.`); } - const alias = source as string; + // Not a container, meta-leaf, or path string: a Loadable type token. + // In-memory lookup for a non-seamless / non-value type (e.g. a bindAsset- + // bound custom type populated by loadContainer): read the stored resource by + // source key. Seamless/value fetch-by-token has been removed — use `Asset.kind(...)` + // or a bare path for those. + const ctor = typeOrPath as Loadable; + const src = source as string; const typeMap = this._resources.get(ctor); - if (!typeMap?.has(alias)) { - throw new Error(`Missing resource "${alias}" for type ${ctor.name}.`); + if (!typeMap?.has(src)) { + throw new Error(`Missing resource "${src}" for type ${ctor.name}.`); } - this._claim(this._key(ctor, alias), ctor, alias, claimer); + this._claim(this._key(ctor, src), ctor, src, claimer); - return typeMap.get(alias); + return typeMap.get(src); } /** @@ -1239,25 +881,6 @@ export class Loader { } } - /** - * Returns the loaded asset for `alias`, or `null` if it has not been - * loaded yet. Non-throwing alternative to {@link get}. - */ - public peek(type: typeof Json, alias: string): T | null; - public peek(type: T, alias: string): LoadReturn | null; - public peek(type: Loadable, alias: string): unknown { - const ctor = type; - - return this._resources.get(ctor)?.get(alias) ?? null; - } - - /** Returns `true` if the asset is currently held in the in-memory store. */ - public has(type: Loadable, alias: string): boolean { - const ctor = type; - - return this._resources.get(ctor)?.has(alias) ?? false; - } - /** * Reverse lookup: given a loaded resource object, return the asset type and * source key it was first loaded under, or `null` for runtime-created, @@ -1276,6 +899,16 @@ export class Loader { return this._resourceKeys.get(resource) ?? null; } + /** + * Non-throwing in-memory lookup: the resource stored under `(type, source)`, + * or `null` if none is held. Reads the store directly (no fetch, no seamless + * placeholder). Backs scene deserialization, which resolves an asset + * reference to a pre-loaded resource. @internal + */ + public _peekResource(type: AssetConstructor, source: string): unknown { + return this._resources.get(type)?.get(source) ?? null; + } + // ----------------------------------------------------------------------- // Unload // ----------------------------------------------------------------------- @@ -1293,7 +926,7 @@ export class Loader { public unload(arg0: unknown, arg1?: unknown): this { if (arg0 instanceof AssetImpl) { const asset = arg0 as Asset; - const ctor = this._assetTypeMap.get(asset.type); + const ctor = this._assetTypeMap.get(asset.kind); if (!ctor) return this; @@ -1315,25 +948,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; @@ -1553,9 +1177,9 @@ export class Loader { * Tears down the loader and all resources it owns. * * Destroys the factory registry (releasing object URLs), destroys every - * cache store, clears all in-memory assets, manifest entries, bundle - * definitions, and in-flight tracking, and disconnects all signals. - * Also calls `destroy?.()` on every handler registered via `bindAsset`. + * cache store, clears all in-memory assets and in-flight tracking, and + * disconnects all signals. Also calls `destroy?.()` on every handler + * registered via `bindAsset`. */ public destroy(): void { this._registry.destroy(); @@ -1576,8 +1200,6 @@ export class Loader { this._boundHandlers.length = 0; this._resources.clear(); - this._manifest.clear(); - this._bundles.clear(); this._inFlight.clear(); this._preventStoreKeys.clear(); this._inFlightByIdentity.clear(); @@ -1589,7 +1211,6 @@ export class Loader { this._seamlessAdapters.clear(); this._backgroundQueue.length = 0; this.onProgress.destroy(); - this.onBundleProgress.destroy(); this.onLoaded.destroy(); this.onError.destroy(); this.onLoadStart.destroy(); @@ -1602,6 +1223,128 @@ 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). + * + * With `background`, the leaf is still registered + claimed + healed in place, + * but its fetch is diverted into the low-priority background queue (see + * {@link _enqueueBackgroundFetch}) instead of started immediately — + * `load(target, { background: true })`. + * @internal + */ + public _adopt(handle: object, claimer: symbol, background = false): 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}".`); + } + + // A freshly-created catalog leaf is 'idle' until adopted; entering the loader + // here transitions it to 'loading' (asset-system v2 §7). A re-adopted handle + // already loading/ready/failed is left untouched. + const leafState = (handle as { _loadState?: { value: string; begin(): void } })._loadState; + if (leafState?.value === 'idle') leafState.begin(); + + const key = this._key(ctor, meta.src); + + 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, { 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. + if (stored !== undefined) { + handle._fill(stored); + } else if (background) { + this._enqueueBackgroundFetch(ctor, meta.src, meta.opts); + } else { + this._startRefFetch(ctor, meta.src, meta.opts); + } + } 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); + + return; + } + + const deferredEntry = this._deferred.get(key); + const stored = this._resources.get(ctor)?.get(meta.src); + + if (deferredEntry === undefined && stored === undefined) { + this._deferred.set(key, { handles: new Set([handle]), options: meta.opts }); + this._handleKeys.set(handle, key); + this._claim(key, ctor, meta.src, claimer); + + if (background) { + this._enqueueBackgroundFetch(ctor, meta.src, meta.opts); + } else { + this._startSeamlessFetch(ctor, meta.src, meta.opts); + } + + return; + } + + 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); + + adapter?.fill(handle, stored); + this._handleKeys.set(handle, key); + } 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); + } + /** * Seamless single-source resolution: an already-stored asset, an existing * deferred handle (retried in place when `'failed'`), or a fresh @@ -1618,24 +1361,34 @@ 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); - if (adapter.stateOf(entry.handle) === 'failed') { - adapter.begin(entry.handle); - this._startSeamlessFetch(type, source, entry.options); - } + const representative = this._representative(entry.handles); - return entry.handle; + if (representative !== undefined) { + if (adapter.stateOf(representative) === 'failed') { + adapter.begin(representative); + this._startSeamlessFetch(type, source, entry.options); + } else { + // A background-adopted source (`load(catalog, { background: true })`) + // is registered here yet still parked in the queue; a direct get() + // promotes it to fetch now. No-op when the source is not queued. + this._boostFromQueue(type, source); + } + + return representative; + } + // else: the handle set is (unexpectedly) empty — fall through and treat + // this as no live handle, creating a fresh placeholder below. } + // Bake the raw `get()` options into the placeholder so a bare `get()` + // renders with the requested sampler options instead of the default. 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); @@ -1659,24 +1412,29 @@ 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); + } else { + // A background-adopted value ref parked in the queue is boosted to + // fetch now on a direct get(). No-op when the source is not queued. + this._boostFromQueue(type, source); + } - 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); @@ -1700,6 +1458,29 @@ export class Loader { }); } + /** + * Divert an adopted leaf's fetch into the low-priority background queue + * (the `background: true` path of {@link _adopt}). The leaf is already + * registered in `_deferred`/`_refs` and claimed, so the fetch — whenever the + * queue drains it, or a `get()` boosts it — fills that same handle in place + * via {@link _storeResource}. A fresh progress batch starts only while idle, + * and the drain kicks the concurrency-capped processor. + */ + private _enqueueBackgroundFetch(type: AssetConstructor, source: string, options: unknown): void { + if (this._hasResource(type, source)) return; + if (this._inFlight.has(this._key(type, source))) return; + if (this._isQueuedInBackground(type, source)) return; + + if (this._backgroundQueue.length === 0 && this._backgroundActive === 0) { + this._backgroundLoaded = 0; + this._backgroundTotal = 0; + } + + this._backgroundQueue.push({ type, alias: source, path: source, options }); + this._backgroundTotal++; + this._drainBackground(); + } + /** * Register a claim on a resource key under a claim scope (idempotent per * scope). On an evicted key, kick a re-fetch into the existing, already @@ -1782,15 +1563,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 @@ -1831,47 +1614,9 @@ export class Loader { return this._inFlight.get(key); } - const entry = this._getManifestEntry(type, alias); - const path = explicitPath ?? entry?.path ?? alias; - const resolvedOptions = options ?? entry?.options; - - return this._trackInFlight(type, alias, this._dispatchFetch(type, alias, path, resolvedOptions)); - } - - private _loadSingleBackground(type: AssetConstructor, alias: string, path: string, options?: unknown): Promise { - if (this._hasResource(type, alias)) { - const typeMap = this._resources.get(type); - if (typeMap?.has(alias) === true) { - return Promise.resolve(typeMap.get(alias)); - } - } - - const key = this._key(type, alias); - const inFlight = this._inFlight.get(key); - - if (inFlight) { - return inFlight; - } - - if (!this._isQueuedInBackground(type, alias)) { - if (this._backgroundQueue.length === 0 && this._backgroundActive === 0) { - this._backgroundLoaded = 0; - this._backgroundTotal = 0; - } - - this._backgroundQueue.push({ type, alias, path, options }); - this._backgroundTotal++; - } - - this._drainBackground(); - - const started = this._inFlight.get(key); + const path = explicitPath ?? alias; - if (started) { - return started; - } - - return this._waitForBackgroundEntry(type, alias); + return this._trackInFlight(type, alias, this._dispatchFetch(type, alias, path, options)); } private _createLoadingQueue( @@ -1884,11 +1629,13 @@ export class Loader { const itemPromises = items.map(({ alias, asset }) => { this._onFgBatchStart(alias, asset.source); - const ctor = this._assetTypeMap.get(asset.type); + const ctor = this._assetTypeMap.get(asset.kind); if (!ctor) { // Must call _notifyItem(false) so LoadingProgress doesn't remain stuck. - return Promise.reject(new Error(`No constructor registered for asset type "${asset.type}". Call registerAssetType() first.`)).then( + return Promise.reject( + new Error(`No constructor registered for asset type "${asset.kind}". Bind it via defineAsset()/bindAsset() first.`), + ).then( () => { notifyFn?.(true); }, @@ -1924,6 +1671,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. @@ -1939,7 +1727,7 @@ export class Loader { const source = asset.source; const rawConfig = asset._config as Record; - const { type: _type, source: _src, ...extraOnly } = rawConfig; + const { kind: _kind, source: _src, ...extraOnly } = rawConfig; // Identity key: use handler's getIdentityKey if provided (config-sensitive dedup), // otherwise fall back to source-based identity (correct for URL-only assets). @@ -2211,59 +1999,6 @@ export class Loader { return this._backgroundQueue.some(entry => entry.type === type && entry.alias === alias); } - private _waitForBackgroundEntry(type: AssetConstructor, alias: string): Promise { - if (this._hasResource(type, alias)) { - const typeMap = this._resources.get(type); - if (typeMap?.has(alias) === true) { - return Promise.resolve(typeMap.get(alias)); - } - } - - const key = this._key(type, alias); - const inFlight = this._inFlight.get(key); - - if (inFlight) { - return inFlight; - } - - return new Promise((resolve, reject) => { - const onLoaded = (loadedType: AssetConstructor, loadedAlias: string, resource: unknown): void => { - if (loadedType !== type || loadedAlias !== alias) return; - - cleanup(); - resolve(resource); - }; - const onError = (errorType: AssetConstructor, errorAlias: string, error: Error): void => { - if (errorType !== type || errorAlias !== alias) return; - - cleanup(); - reject(error); - }; - const cleanup = (): void => { - this.onLoaded.remove(onLoaded); - this.onError.remove(onError); - }; - - this.onLoaded.add(onLoaded); - this.onError.add(onError); - - const pending = this._inFlight.get(key); - - if (pending) { - cleanup(); - pending.then(resolve, reject); - - return; - } - - if (this._hasResource(type, alias)) { - cleanup(); - const typeMap = this._resources.get(type); - resolve(typeMap?.get(alias)); - } - }); - } - private _onBackgroundItemDone(): void { this.onProgress.dispatch(this._backgroundLoaded, this._backgroundTotal); @@ -2323,7 +2058,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; @@ -2332,41 +2073,61 @@ 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); } } - private _emitBundleProgress(name: string, loaded: number, total: number, onProgress?: (loaded: number, total: number) => void): void { - this.onBundleProgress.dispatch(name, loaded, total); + /** 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; + } - if (onProgress) { - onProgress(loaded, total); + /** + * 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}`, + }); } - // ----------------------------------------------------------------------- - // Internal — manifest & storage - // ----------------------------------------------------------------------- + /** 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)); + } - private _addManifestEntry(type: AssetConstructor, alias: string, path: string, options?: unknown): void { - if (!this._manifest.has(type)) { - this._manifest.set(type, new Map()); + /** 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 typeManifest = this._manifest.get(type); - if (!typeManifest) { - return; - } - typeManifest.set(alias, { path, options }); - } + const result: Record = {}; - private _getManifestEntry(type: AssetConstructor, alias: string): ManifestEntry | undefined { - return this._manifest.get(type)?.get(alias); - } + for (const [key, value] of Object.entries(options as Record)) { + if (key === 'samplerOptions' || key === 'width' || key === 'height') { + continue; + } + + result[key] = value; + } - private _isManifestDefinitionEquivalent(entry: ManifestEntry, path: string, options?: unknown): boolean { - return entry.path === path && this._areOptionsEquivalent(entry.options, options); + return result; } private _areOptionsEquivalent(left: unknown, right: unknown): boolean { @@ -2408,8 +2169,8 @@ export class Loader { } // Same-prototype instances compare structurally by their own enumerable - // keys — the registerManifest contract allows deeply-equal options of any - // shared class, not just plain objects. Built-ins whose state is NOT + // keys — deeply-equal options of any shared class, not just plain objects, + // count as equivalent. Built-ins whose state is NOT // carried in enumerable own keys need explicit handling: Dates compare by // timestamp; other exotic containers stay reference-only (Object.is above) // so two distinct-but-similar instances never count as equivalent. @@ -2466,13 +2227,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; @@ -2485,30 +2255,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; } - adapter.fill(deferredEntry.handle, resource); + if (state === 'failed') { + adapter.begin(handle); + } + + 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/assetKindRegistry.ts b/src/resources/assetKindRegistry.ts new file mode 100644 index 000000000..6637c4017 --- /dev/null +++ b/src/resources/assetKindRegistry.ts @@ -0,0 +1,53 @@ +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 registration. @internal */ +export function registerAssetKind(kind: keyof AssetDefinitions, entry: AssetKindEntry): void { + const existing = kinds.get(kind); + 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); +} + +/** @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) { + const ref = new AssetRef(); + ref._loadState.markIdle(); // a catalog leaf is idle until a loader adopts it + // `parse` is a per-leaf post-load transform, not a fetch option — apply it on + // fill and keep it out of the source-keyed fetch opts. + const { parse, ...fetchOpts } = (opts ?? {}) as { parse?: (raw: unknown) => unknown }; + if (typeof parse === 'function') ref._setParse(parse); + const cleanOpts = Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined; + return _stampMeta(ref, { kind, src, opts: cleanOpts }); + } + + if (entry.adapter === undefined) { + throw new Error(`assetKindRegistry: resource kind "${kind}" has no seamless adapter.`); + } + + const placeholder = entry.adapter.createPlaceholder(opts) as { _loadState: { markIdle(): void } }; + placeholder._loadState.markIdle(); // idle until adopted (overrides createPlaceholder's 'loading') + return _stampMeta(placeholder as object, { kind, src, opts }); +} diff --git a/src/resources/assetMeta.ts b/src/resources/assetMeta.ts new file mode 100644 index 000000000..6a1347928 --- /dev/null +++ b/src/resources/assetMeta.ts @@ -0,0 +1,30 @@ +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. + * + * 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 { + if (typeof value !== 'object' || value === null) return undefined; + return (value as { [_assetMeta]?: AssetMeta })[_assetMeta]; +} diff --git a/src/resources/coreAssetBindings.ts b/src/resources/coreAssetBindings.ts index 016d3aae8..50a130f07 100644 --- a/src/resources/coreAssetBindings.ts +++ b/src/resources/coreAssetBindings.ts @@ -18,9 +18,13 @@ import { VideoFactory } from '#resources/factories/VideoFactory'; import { WasmFactory } from '#resources/factories/WasmFactory'; import { XmlFactory } from '#resources/factories/XmlFactory'; +import { Asset } from './Asset'; +import { registerAssetKind } from './assetKindRegistry'; +import { defineAsset } from './defineAsset'; +import { registerExtensionKind } from './extensionKindRegistry'; import type { AssetConstructor } from './FactoryRegistry'; import type { AssetLoaderContext, Loader } from './Loader'; -import { type SeamlessAdapter, soundSeamlessAdapter, textureSeamlessAdapter } from './seamless'; +import { soundSeamlessAdapter, textureSeamlessAdapter } from './seamless'; import { BinaryAsset, CsvAsset, FontAsset, ImageAsset, Json, SubtitleAsset, SvgAsset, TextAsset, WasmAsset, XmlAsset } from './tokens'; // --------------------------------------------------------------------------- @@ -67,15 +71,6 @@ function textFactoryHandler(makeFactory: () => { create(raw: string, options? }; } -// Typed binding factory that accepts abstract token classes. -function binding( - type: AssetConstructor, - opts: { typeNames?: readonly string[]; extensions?: readonly string[]; seamless?: SeamlessAdapter }, - create: (loader: Loader) => AssetHandler, -): AssetBinding { - return { type, ...opts, create }; -} - /** * Resolve a sub-asset reference (e.g. a BmFont page image) relative to its * parent's source. `new URL(ref, source)` only works when `source` is an @@ -103,127 +98,174 @@ export function resolveSubAssetPath(ref: string, source: string): string { // Core asset bindings // --------------------------------------------------------------------------- -const textureBinding = binding( - Texture, - { typeNames: ['texture'], extensions: ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif'], seamless: textureSeamlessAdapter }, - binaryFactoryHandler(() => new TextureFactory()), -); - -const soundBinding = binding( - Sound, - { typeNames: ['sound'], extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac'], seamless: soundSeamlessAdapter }, - binaryFactoryHandler(() => new SoundFactory()), -); - -const musicBinding = binding( - AudioStream, - { typeNames: ['music'] }, - binaryFactoryHandler(() => new MusicFactory()), -); - -const videoBinding = binding( - Video, - { typeNames: ['video'] }, - binaryFactoryHandler(() => new VideoFactory()), -); - -const jsonBinding = binding(Json as unknown as AssetConstructor, { typeNames: ['json'] }, () => ({ - async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { - return context.fetchJson(source); - }, - createFromBytes(bytes: ArrayBuffer): Promise { - return Promise.resolve(JSON.parse(new TextDecoder().decode(bytes))); - }, -})); +const textureBinding = defineAsset({ + type: Texture, + kind: 'texture', + extensions: ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif'], + seamless: textureSeamlessAdapter, + create: binaryFactoryHandler(() => new TextureFactory()), +}); -const textBinding = binding(TextAsset as unknown as AssetConstructor, { typeNames: ['text'] }, () => ({ - async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { - return context.fetchText(source); - }, - createFromBytes(bytes: ArrayBuffer): Promise { - return Promise.resolve(new TextDecoder().decode(bytes)); - }, -})); - -const svgBinding = binding( - SvgAsset as unknown as AssetConstructor, - { typeNames: ['svg'] }, - textFactoryHandler(() => new SvgFactory()), -); - -const subtitleBinding = binding(SubtitleAsset as unknown as AssetConstructor, { typeNames: ['vtt', 'srt'] }, () => { - const factory = new SubtitleFactory(); - return { - async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { - const text = await context.fetchText(source); - const url = (source.split('?')[0] ?? source).toLowerCase(); - const fmt = url.endsWith('.srt') ? 'srt' : 'vtt'; - const intermediate = await factory.process({ text: () => Promise.resolve(text), url: source }); - return factory.create({ ...intermediate, fmt }); +const soundBinding = defineAsset({ + type: Sound, + kind: 'sound', + extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac'], + seamless: soundSeamlessAdapter, + create: binaryFactoryHandler(() => new SoundFactory()), +}); + +// music/video/svg/font/image/bmFont are non-leaf resource kinds: no placeholder +// strategy, so `isValue: false` keeps them OUT of the global kind/inference +// registries (bare paths need `Asset.kind(...)`); their extensions still ride the binding. +const musicBinding = defineAsset({ + type: AudioStream, + kind: 'music', + isValue: false, + create: binaryFactoryHandler(() => new MusicFactory()), +}); + +const videoBinding = defineAsset({ + type: Video, + kind: 'video', + isValue: false, + create: binaryFactoryHandler(() => new VideoFactory()), +}); + +const jsonBinding = defineAsset({ + type: Json, + kind: 'json', + extensions: ['json'], + create: () => ({ + async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { + return context.fetchJson(source); }, - destroy() { - factory.destroy(); + createFromBytes(bytes: ArrayBuffer): Promise { + return Promise.resolve(JSON.parse(new TextDecoder().decode(bytes))); }, - }; + }), }); -const xmlBinding = binding( - XmlAsset as unknown as AssetConstructor, - { typeNames: ['xml'] }, - textFactoryHandler(() => new XmlFactory()), -); - -const csvBinding = binding( - CsvAsset as unknown as AssetConstructor, - { typeNames: ['csv'] }, - textFactoryHandler(() => new CsvFactory()), -); - -const binaryBinding = binding( - BinaryAsset as unknown as AssetConstructor, - { typeNames: ['binary'] }, - binaryFactoryHandler(() => new BinaryFactory()), -); - -const bmFontBinding = binding(BmFont, { typeNames: ['bmFont'], extensions: ['fnt'] }, (loader: Loader) => ({ - async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { - const text = await context.fetchText(source); - const fontData = parseBmFontText(text); - const textures = await Promise.all(fontData.pages.map(page => loader.load(Texture, resolveSubAssetPath(page, source)))); - return new BmFont(fontData, textures as Texture[]); +const textBinding = defineAsset({ + type: TextAsset as unknown as AssetConstructor, + kind: 'text', + extensions: ['txt'], + create: () => ({ + async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { + return context.fetchText(source); + }, + createFromBytes(bytes: ArrayBuffer): Promise { + return Promise.resolve(new TextDecoder().decode(bytes)); + }, + }), +}); + +const svgBinding = defineAsset({ + type: SvgAsset, + kind: 'svg', + isValue: false, + create: textFactoryHandler(() => new SvgFactory()), +}); + +// Subtitle serves two value kinds through one handler. `defineAsset` registers +// its primary kind `vtt` (+ the `vtt` suffix); the `srt` alias kind — a distinct +// AssetDefinitions key sharing this handler — is registered explicitly so both +// suffixes resolve to a value leaf and both load via the subtitle handler +// (routed at runtime by `typeNames: ['vtt', 'srt']`). +const subtitleBinding = defineAsset({ + type: SubtitleAsset as unknown as AssetConstructor, + kind: 'vtt', + typeNames: ['vtt', 'srt'], + extensions: ['vtt'], + create: () => { + const factory = new SubtitleFactory(); + return { + async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { + const text = await context.fetchText(source); + const url = (source.split('?')[0] ?? source).toLowerCase(); + const fmt = url.endsWith('.srt') ? 'srt' : 'vtt'; + const intermediate = await factory.process({ text: () => Promise.resolve(text), url: source }); + return factory.create({ ...intermediate, fmt }); + }, + destroy() { + factory.destroy(); + }, + }; }, -})); +}); + +registerAssetKind('srt', { isValue: true }); +registerExtensionKind('srt', 'srt'); + +const xmlBinding = defineAsset({ + type: XmlAsset, + kind: 'xml', + extensions: ['xml'], + create: textFactoryHandler(() => new XmlFactory()), +}); + +const csvBinding = defineAsset({ + type: CsvAsset, + kind: 'csv', + extensions: ['csv'], + create: textFactoryHandler(() => new CsvFactory()), +}); + +const binaryBinding = defineAsset({ + type: BinaryAsset, + kind: 'binary', + extensions: ['bin'], + create: binaryFactoryHandler(() => new BinaryFactory()), +}); + +const bmFontBinding = defineAsset({ + type: BmFont, + kind: 'bmFont', + extensions: ['fnt'], + isValue: false, + create: (loader: Loader) => ({ + async load({ source }: AssetLoadRequest, context: AssetLoaderContext): Promise { + const text = await context.fetchText(source); + const fontData = parseBmFontText(text); + const textures = await Promise.all(fontData.pages.map(page => loader.load(Asset.kind('texture', resolveSubAssetPath(page, source))))); + return new BmFont(fontData, textures); + }, + }), +}); // Conditional bindings — only registered when the environment supports them. const conditionalBindings: AssetBinding[] = []; if (typeof FontFace !== 'undefined') { conditionalBindings.push( - binding( - FontAsset as unknown as AssetConstructor, - { typeNames: ['font'], extensions: ['woff', 'woff2', 'ttf', 'otf'] }, - binaryFactoryHandler(() => new FontFactory()), - ), + defineAsset({ + type: FontAsset, + kind: 'font', + extensions: ['woff', 'woff2', 'ttf', 'otf'], + isValue: false, + create: binaryFactoryHandler(() => new FontFactory()), + }), ); } if (typeof HTMLImageElement !== 'undefined') { conditionalBindings.push( - binding( - ImageAsset as unknown as AssetConstructor, - { typeNames: ['image'] }, - binaryFactoryHandler(() => new ImageFactory()), - ), + defineAsset({ + type: ImageAsset, + kind: 'image', + isValue: false, + create: binaryFactoryHandler(() => new ImageFactory()), + }), ); } if (typeof WebAssembly !== 'undefined') { conditionalBindings.push( - binding( - WasmAsset as unknown as AssetConstructor, - { typeNames: ['wasm'] }, - binaryFactoryHandler(() => new WasmFactory()), - ), + defineAsset({ + type: WasmAsset, + kind: 'wasm', + extensions: ['wasm'], + create: binaryFactoryHandler(() => new WasmFactory()), + }), ); } diff --git a/src/resources/defineAsset.ts b/src/resources/defineAsset.ts new file mode 100644 index 000000000..df98354bf --- /dev/null +++ b/src/resources/defineAsset.ts @@ -0,0 +1,76 @@ +import type { AssetBinding, AssetHandler } from '#extensions/Extension'; + +import type { AssetDefinitions } from './AssetDefinitions'; +import { registerAssetKind } from './assetKindRegistry'; +import { registerExtensionKind } from './extensionKindRegistry'; +import type { AssetConstructor } from './FactoryRegistry'; +import type { Loader } from './Loader'; +import type { SeamlessAdapter } from './seamless'; + +/** + * One descriptor for a built-in asset type. Feeds three consumers from a single + * source: the per-Loader {@link AssetBinding} returned for `materializeAssetBindings`, + * the global kind→placeholder strategy, and the global suffix→kind inference. + * @advanced + */ +export interface DefineAssetDescriptor { + /** The runtime constructor the produced asset is an instance of. */ + readonly type: AssetConstructor; + /** The {@link AssetDefinitions} key this type registers under. */ + readonly kind: keyof AssetDefinitions; + /** File suffixes that map to this type. Feeds the per-loader map and — for a leaf-capable kind — global bare-path inference. */ + readonly extensions?: readonly string[]; + /** + * Config-map type names resolving to this handler. Defaults to `[kind]`. + * @internal — internal alias-compat only; not part of the public extension + * surface (extensions should rely on `kind`). + */ + readonly typeNames?: readonly string[]; + /** Seamless placeholder adapter for a resource kind that heals in place. */ + readonly seamless?: SeamlessAdapter; + /** Whether the catalog leaf is a deferred `AssetRef` (value kind). Defaults to `seamless === undefined`. */ + readonly isValue?: boolean; + /** Loader-local handler factory, called once per Loader by `materializeAssetBindings`. */ + readonly create: (loader: Loader) => AssetHandler; +} + +/** + * Declare a built-in asset type in one place. For a **leaf-capable** kind — one + * with a {@link SeamlessAdapter} (resource) or `isValue: true` (value) — this + * registers its placeholder strategy and suffix→kind inference GLOBALLY at import + * time, so a loader-free `Assets.from` resolves it before any Application exists. + * + * A **non-leaf** resource kind (`isValue: false` and no adapter — e.g. `bmFont`, + * `font`) has no placeholder strategy, so it is deliberately NOT registered + * globally: its bare path cannot be inferred and must be declared via `Asset.kind(...)` + * or an explicit config. Its `extensions` still travel on the returned binding + * for the per-Loader map. + * + * The returned {@link AssetBinding} flows through the unchanged + * `materializeAssetBindings` path exactly like any extension package's binding — + * `defineAsset` adds no second per-loader registration channel. + * @advanced + */ +export function defineAsset(descriptor: DefineAssetDescriptor): AssetBinding { + const isValue = descriptor.isValue ?? descriptor.seamless === undefined; + const leafCapable = descriptor.seamless !== undefined || isValue; + + if (leafCapable) { + registerAssetKind(descriptor.kind, { + ...(descriptor.seamless !== undefined && { adapter: descriptor.seamless }), + isValue, + }); + for (const ext of descriptor.extensions ?? []) { + registerExtensionKind(ext, descriptor.kind); + } + } + + return { + type: descriptor.type, + kind: descriptor.kind, + typeNames: descriptor.typeNames ?? [descriptor.kind], + ...(descriptor.extensions !== undefined && { extensions: descriptor.extensions }), + ...(descriptor.seamless !== undefined && { seamless: descriptor.seamless }), + create: descriptor.create, + }; +} diff --git a/src/resources/extensionKindRegistry.ts b/src/resources/extensionKindRegistry.ts new file mode 100644 index 000000000..ea415dab7 --- /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 / `Asset.kind(...)` 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 Asset.kind(...) 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 `MatchKind`/`KindByPath`). 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/src/resources/factories/BmFontFactory.ts b/src/resources/factories/BmFontFactory.ts index 2b5da97de..61f7a380d 100644 --- a/src/resources/factories/BmFontFactory.ts +++ b/src/resources/factories/BmFontFactory.ts @@ -1,7 +1,7 @@ import type { BmFontChar, BmFontData } from '#rendering/text/BmFont'; import { BmFont } from '#rendering/text/BmFont'; -import { Texture } from '#rendering/texture/Texture'; import { AbstractAssetFactory } from '#resources/AbstractAssetFactory'; +import { Asset } from '#resources/Asset'; import type { Loader } from '#resources/Loader'; // ── Parser helpers ──────────────────────────────────────────────────────────── @@ -101,7 +101,7 @@ export class BmFontLoaderFactory extends AbstractAssetFactory { public async create(source: unknown): Promise { const { text, url } = source as { text: string; url: string }; const fontData = parseBmFontText(text); - const textures = (await Promise.all(fontData.pages.map(page => this._loader.load(Texture, new URL(page, url).href)))) as Texture[]; + const textures = await Promise.all(fontData.pages.map(page => this._loader.load(Asset.kind('texture', new URL(page, url).href)))); return new BmFont(fontData, textures); } } diff --git a/src/resources/factories/TextureFactory.ts b/src/resources/factories/TextureFactory.ts index 2d0f81abd..c8a28b8cf 100644 --- a/src/resources/factories/TextureFactory.ts +++ b/src/resources/factories/TextureFactory.ts @@ -10,8 +10,8 @@ export interface TextureFactoryOptions { * omitted. */ mimeType?: string; - /** Sampler parameters (wrap mode, filter, etc.) forwarded to the {@link Texture} constructor. */ - samplerOptions?: SamplerOptions; + /** Sampler parameters (wrap mode, filter, etc.) forwarded to the {@link Texture} constructor; any subset. */ + samplerOptions?: Partial; } /** diff --git a/src/resources/index.ts b/src/resources/index.ts index 7ee52cd2a..df19fc275 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -1,16 +1,18 @@ export { AbstractAssetFactory } from './AbstractAssetFactory'; +export type { ValueAsset } from './Asset'; export { Asset } from './Asset'; export type { AnyAssetConfig, AssetDefinitions, AssetInput, InferAssetResource } from './AssetDefinitions'; export type { AssetFactory } from './AssetFactory'; -export type { AssetEntry, AssetManifest, LoadBundleOptions } from './AssetManifest'; -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'; export type { Database } from './Database'; +export type { DefineAssetDescriptor } from './defineAsset'; +export { defineAsset } from './defineAsset'; export type { AssetConstructor } from './FactoryRegistry'; export { IndexedDbDatabase } from './IndexedDbDatabase'; export type { IndexedDbKeyValueStoreOptions } from './IndexedDbKeyValueStore'; @@ -20,14 +22,12 @@ export { IndexedDbStore } from './IndexedDbStore'; export type { KeyValueStore } from './KeyValueStore'; export type { AssetLoaderContext, - BatchValue, - ConstrainedLoadable, - ExtensionTokenTypeMap, ExtensionTypeMap, InferLoadedMap, Loadable, LoadByPath, LoaderOptions, + LoadOptions, LoadReturn, PathExtension, } from './Loader'; diff --git a/src/resources/seamless.ts b/src/resources/seamless.ts index fc1f990ce..73594c828 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'; /** Pre-sizing options for deferred texture handles (spec §4.1 — the layout-jump fix). */ @@ -11,6 +12,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). * @@ -37,9 +50,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 */ @@ -49,8 +64,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); @@ -70,9 +87,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/src/resources/tokens.ts b/src/resources/tokens.ts index 356ae8535..645d55c79 100644 --- a/src/resources/tokens.ts +++ b/src/resources/tokens.ts @@ -1,75 +1,104 @@ +// Each dispatch token carries a distinct nominal brand (`_token`, `declare`-only, +// never emitted). Without it these otherwise-empty marker classes are +// structurally interchangeable, so `LoadReturn`'s `T extends typeof Json` / +// `… typeof WasmAsset` probes collapse — e.g. `Asset` is +// `Asset<{}>` (Module is an empty interface), which every resource class's +// `Asset.kind(...)` return is assignable to, making `LoadReturn` wrongly +// resolve to `WebAssembly.Module`. The brand makes each token match only its own +// `LoadReturn` branch and keeps resource classes out of all of them. (§5 typing bug.) + /** * Dispatch token for generic JSON loading. * - * `loader.load(Json, 'config.json')` returns `Promise`. - * Narrow via generic: `loader.load(Json, 'config.json')`. + * `loader.load(Asset.kind('json', 'config.json'))` returns `Promise`. + * Narrow via generic: `loader.load(Asset.kind('json', 'config.json'))`. * Handles all JSON shapes — objects, arrays, scalars. */ -export abstract class Json {} +export abstract class Json { + declare protected readonly _token: 'json'; +} /** * Dispatch token for plain text loading. * - * `loader.load(TextAsset, 'greeting.txt')` returns `Promise`. + * `loader.load(Asset.kind('text', 'greeting.txt'))` returns `Promise`. */ -export abstract class TextAsset {} +export abstract class TextAsset { + declare protected readonly _token: 'text'; +} /** * Dispatch token for SVG loading. * - * `loader.load(SvgAsset, 'icon.svg')` returns `Promise`. + * `loader.load(Asset.kind('svg', 'icon.svg'))` returns `Promise`. */ -export abstract class SvgAsset {} +export abstract class SvgAsset { + declare protected readonly _token: 'svg'; +} /** * Dispatch token for subtitle loading (WebVTT and SRT). * - * `loader.load(SubtitleAsset, 'subs.vtt')` returns `Promise`. - * `loader.load(SubtitleAsset, 'subs.srt')` returns `Promise`. + * `loader.load(Asset.kind('vtt', 'subs.vtt'))` returns `Promise`. + * `loader.load(Asset.kind('vtt', 'subs.srt'))` returns `Promise`. * Format is detected from the file extension; unknown extensions default to VTT. */ -export abstract class SubtitleAsset {} +export abstract class SubtitleAsset { + declare protected readonly _token: 'subtitle'; +} /** * Dispatch token for XML document loading. * - * `loader.load(XmlAsset, 'data.xml')` returns `Promise`. + * `loader.load(Asset.kind('xml', 'data.xml'))` returns `Promise`. * Throws if the file cannot be parsed as well-formed XML. */ -export abstract class XmlAsset {} +export abstract class XmlAsset { + declare protected readonly _token: 'xml'; +} /** * Dispatch token for CSV loading. * - * `loader.load(CsvAsset, 'table.csv')` returns `Promise`. + * `loader.load(Asset.kind('csv', 'table.csv'))` returns `Promise`. * Each inner array is one row; values are raw strings (no type coercion). */ -export abstract class CsvAsset {} +export abstract class CsvAsset { + declare protected readonly _token: 'csv'; +} /** * Dispatch token for image loading. * - * `loader.load(ImageAsset, 'img.png')` returns `Promise`. + * `loader.load(Asset.kind('image', 'img.png'))` returns `Promise`. */ -export abstract class ImageAsset {} +export abstract class ImageAsset { + declare protected readonly _token: 'image'; +} /** * Dispatch token for font loading. * - * `loader.load(FontAsset, 'font.woff2', { family: 'MyFont' })` returns `Promise`. + * `loader.load(Asset.kind('font', 'font.woff2', { family: 'MyFont' }))` returns `Promise`. */ -export abstract class FontAsset {} +export abstract class FontAsset { + declare protected readonly _token: 'font'; +} /** * Dispatch token for binary data loading. * - * `loader.load(BinaryAsset, 'data.bin')` returns `Promise`. + * `loader.load(Asset.kind('binary', 'data.bin'))` returns `Promise`. */ -export abstract class BinaryAsset {} +export abstract class BinaryAsset { + declare protected readonly _token: 'binary'; +} /** * Dispatch token for WebAssembly module loading. * - * `loader.load(WasmAsset, 'module.wasm')` returns `Promise`. + * `loader.load(Asset.kind('wasm', 'module.wasm'))` returns `Promise`. */ -export abstract class WasmAsset {} +export abstract class WasmAsset { + declare protected readonly _token: 'wasm'; +} 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/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index 88878e289..fedfb9164 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -30,7 +30,6 @@ exports[`root index export surface snapshot > sorted runtime export names match "Bounds", "BufferTypes", "BufferUsage", - "BundleLoadError", "Button", "CacheFirstStrategy", "CallbackRenderPass", @@ -206,7 +205,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "createRenderStats", "crossFade", "decodeAudioData", - "defineAssetManifest", + "defineAsset", "getAudioContext", "getOfflineAudioContext", "inRange", 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..31c37e497 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -15,13 +15,12 @@ exports[`root index type-level export inventory > all exported symbols with kind "Asset: interface", "AssetConstructor: type alias", "AssetDefinitions: interface", - "AssetEntry: interface", "AssetFactory: interface", "AssetInput: type alias", "AssetLoadRequest: interface", "AssetLoaderContext: interface", - "AssetManifest: interface", "AssetRef: class", + "AssetStatus: interface", "Assets: type alias", "AtlasMode: type alias", "AttributeType: type alias", @@ -40,7 +39,6 @@ exports[`root index type-level export inventory > all exported symbols with kind "AutoBackendConfig: interface", "BackendConfig: type alias", "BackendRenderPass: interface", - "BatchValue: type alias", "BinaryAsset: class", "BinaryFactory: class", "BiquadEffect: class", @@ -60,7 +58,6 @@ exports[`root index type-level export inventory > all exported symbols with kind "BufferTypes: enum", "BufferUsage: enum", "BuildInfo: interface", - "BundleLoadError: class", "Button: class", "ButtonOptions: interface", "CacheFirstStrategy: class", @@ -86,7 +83,6 @@ exports[`root index type-level export inventory > all exported symbols with kind "CollisionType: const enum", "Color: class", "ColorFilter: class", - "ConstrainedLoadable: type alias", "Container: class", "CrossFadeOptions: interface", "CsvAsset: class", @@ -100,6 +96,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "Database: interface", "DecodedImage: type alias", "DecompressFormat: type alias", + "DefineAssetDescriptor: interface", "DeserializeContext: interface", "Destroyable: interface", "DisposalScope: class", @@ -115,7 +112,6 @@ exports[`root index type-level export inventory > all exported symbols with kind "Envelope: class", "EnvelopeOptions: interface", "Extension: interface", - "ExtensionTokenTypeMap: interface", "ExtensionTypeMap: interface", "FadeSceneTransition: interface", "Filter: class", @@ -198,8 +194,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "Line: class", "LineLike: interface", "LinearGradient: class", - "LoadBundleOptions: interface", "LoadByPath: type alias", + "LoadOptions: interface", "LoadReturn: type alias", "LoadStateValue: type alias", "Loadable: type alias", @@ -390,6 +386,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "TypedEnum: type alias", "UIRoot: class", "UniformValue: type alias", + "ValueAsset: type alias", "ValueOf: type alias", "Vector: class", "Video: class", @@ -424,7 +421,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "createRenderStats: variable", "crossFade: function", "decodeAudioData: variable", - "defineAssetManifest: function", + "defineAsset: function", "getAudioContext: variable", "getOfflineAudioContext: variable", "inRange: variable", diff --git a/test/core/root-index-public-api.test.ts b/test/core/root-index-public-api.test.ts index 008f2aebc..c856adc96 100644 --- a/test/core/root-index-public-api.test.ts +++ b/test/core/root-index-public-api.test.ts @@ -6,8 +6,6 @@ describe('root index public API exports', () => { expect(exo.Scene).toBeDefined(); expect(exo.AnimatedSprite).toBeDefined(); expect(exo.View).toBeDefined(); - expect(exo.defineAssetManifest).toBeDefined(); - expect(exo.BundleLoadError).toBeDefined(); expect(exo.RenderTexture).toBeDefined(); expect(exo.BlurFilter).toBeDefined(); expect(exo.ColorFilter).toBeDefined(); diff --git a/test/core/root-index-resources.test.ts b/test/core/root-index-resources.test.ts index 9d016c67a..1a4512cb1 100644 --- a/test/core/root-index-resources.test.ts +++ b/test/core/root-index-resources.test.ts @@ -10,8 +10,6 @@ describe('root index resources exports', () => { expect(exo.WebStorageStore).toBeDefined(); expect(exo.IndexedDbKeyValueStore).toBeDefined(); expect(exo.Loader).toBeDefined(); - expect(exo.defineAssetManifest).toBeDefined(); - expect(exo.BundleLoadError).toBeDefined(); expect(exo.JsonFactory).toBeDefined(); expect(exo.TextFactory).toBeDefined(); expect(exo.SvgFactory).toBeDefined(); diff --git a/test/core/scene-loader-catalog.test.ts b/test/core/scene-loader-catalog.test.ts new file mode 100644 index 000000000..456f8a6e5 --- /dev/null +++ b/test/core/scene-loader-catalog.test.ts @@ -0,0 +1,148 @@ +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'; +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 +// 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 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); + 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: { kind: '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: { kind: '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'); + }); + + // 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: { kind: '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: { kind: '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: { kind: 'texture', source: 'ship.png' }, + config: { kind: '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/core/scene-loader.test.ts b/test/core/scene-loader.test.ts index 78fc382ea..66aa42c73 100644 --- a/test/core/scene-loader.test.ts +++ b/test/core/scene-loader.test.ts @@ -1,4 +1,3 @@ -import { Sound } from '#audio/Sound'; import type { Application } from '#core/Application'; import { Scene } from '#core/Scene'; import { materializeAssetBindings } from '#extensions/materialize'; @@ -55,7 +54,7 @@ describe('Scene.loader', () => { const scene = new Scene(); scene.app = app; - const handle = scene.loader.get(Sound, 'boom.ogg'); + const handle = scene.loader.get('boom.ogg'); await handle.loaded; expect(handle.audioBuffer).not.toBeNull(); @@ -68,7 +67,7 @@ describe('Scene.loader', () => { const { app } = makeAppWithAudio(); const scene = new Scene(); scene.app = app; - const handle = scene.loader.get(Sound, 'boom.ogg'); + const handle = scene.loader.get('boom.ogg'); await handle.loaded; scene.destroy(); // _disposal.destroy() → SceneLoader.destroy() → release all diff --git a/test/core/serialization-validation.test.ts b/test/core/serialization-validation.test.ts index e754c684a..7a27d3510 100644 --- a/test/core/serialization-validation.test.ts +++ b/test/core/serialization-validation.test.ts @@ -35,10 +35,10 @@ beforeEach(() => resetDefaultGlyphAtlasPool(mockPool as unknown as GlyphAtlasPoo afterEach(() => resetDefaultGlyphAtlasPool()); afterEach(_resetDefaultSerializers); -/** Minimal Loader stand-in exposing only `peek`/`keyFor` for asset resolution. */ +/** Minimal Loader stand-in exposing only `_peekResource`/`keyFor` for asset resolution. */ function fakeLoader(entries: ReadonlyArray<{ type: Loadable; source: string; resource: object }>): Loader { return { - peek: (type: Loadable, source: string) => entries.find(e => e.type === type && e.source === source)?.resource ?? null, + _peekResource: (type: Loadable, source: string) => entries.find(e => e.type === type && e.source === source)?.resource ?? null, keyFor: (resource: object) => { const entry = entries.find(e => e.resource === resource); diff --git a/test/core/serialization.test.ts b/test/core/serialization.test.ts index 62c6bd52c..775d9425d 100644 --- a/test/core/serialization.test.ts +++ b/test/core/serialization.test.ts @@ -91,8 +91,8 @@ function fakeLoader(entries: ReadonlyArray<{ type: Loadable; source: string; res return hit ? { type: hit.type, source: hit.source } : null; }, - peek(type: Loadable, alias: string) { - const hit = entries.find(entry => entry.type === type && entry.source === alias); + _peekResource(type: Loadable, source: string) { + const hit = entries.find(entry => entry.type === type && entry.source === source); return hit ? hit.resource : null; }, diff --git a/test/extensions/asset-materialisation.test.ts b/test/extensions/asset-materialisation.test.ts index 0b219615b..f489c4c16 100644 --- a/test/extensions/asset-materialisation.test.ts +++ b/test/extensions/asset-materialisation.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AssetBinding, AssetHandler, AssetLoadRequest } from '#extensions/Extension'; import { materializeAssetBindings } from '#extensions/materialize'; import { resetExtensionRegistryForTesting } from '#extensions/testing'; +import { Asset } from '#resources/Asset'; import type { AssetLoaderContext } from '#resources/Loader'; import { Loader } from '#resources/Loader'; @@ -11,6 +12,14 @@ class TypeA {} class TypeB {} class TypeC {} +declare module '#resources/AssetDefinitions' { + interface AssetDefinitions { + withOpts: { resource: unknown; config: { source: string; family?: string; size?: number } }; + noOpts: { resource: unknown; config: { source: string } }; + testType: { resource: unknown; config: { source: string } }; + } +} + function createTestHandler(): AssetHandler { return { load: vi.fn(async (_req: AssetLoadRequest, _ctx: AssetLoaderContext) => ({})), @@ -100,7 +109,7 @@ describe('materializeAssetBindings', () => { const loader = new Loader(); materializeAssetBindings(loader, [binding]); - await loader.load(TypeA as never, 'thing.dat', { family: 'Kenney Future', size: 32 }).catch(() => undefined); + await loader.load(new Asset({ kind: 'withOpts', source: 'thing.dat', family: 'Kenney Future', size: 32 })).catch(() => undefined); expect(seen?.source).toBe('thing.dat'); expect(seen?.options).toEqual({ family: 'Kenney Future', size: 32 }); @@ -119,7 +128,7 @@ describe('materializeAssetBindings', () => { const loader = new Loader(); materializeAssetBindings(loader, [binding]); - await loader.load(TypeA as never, 'thing.dat').catch(() => undefined); + await loader.load(new Asset({ kind: 'noOpts', source: 'thing.dat' })).catch(() => undefined); expect(seen?.source).toBe('thing.dat'); expect(seen?.options).toBeUndefined(); @@ -219,8 +228,8 @@ describe('materializeAssetBindings', () => { const getSpy = vi.spyOn(ExtensionRegistry, 'get'); const hasSpy = vi.spyOn(ExtensionRegistry, 'has'); - // Load via extension path - await loader.load(TypeA as never, 'test.tstx').catch(() => { + // Load via the config (typeName) path + await loader.load(new Asset({ kind: 'testType', source: 'test.tstx' })).catch(() => { // ignore load error (no actual fetch) }); 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-access-integration.test.ts b/test/resources/asset-access-integration.test.ts new file mode 100644 index 000000000..5f0101279 --- /dev/null +++ b/test/resources/asset-access-integration.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; +import { Asset } from '#resources/Asset'; +import { AssetRef } from '#resources/AssetRef'; +import { Assets } from '#resources/Assets'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +/** Loader with all core asset bindings (mirrors createCoreLoader in the sibling loader tests). */ +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +/** One fetch mock serving both a decodable image payload and a JSON payload. */ +const mockFetchMixed = (jsonPayload: unknown): void => { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => jsonPayload, + text: async () => JSON.stringify(jsonPayload), + arrayBuffer: async () => new ArrayBuffer(8), + }) as unknown as Response, + ); +}; + +// End-to-end acceptance gate for the S2 asset-access surface: bare-string +// inference + X.of() descriptors in Assets.from, loader-free usable leaves, the +// status channel, heal-in-place on adopt+load, and bare-path get() for a value +// kind — all together on one loader. +describe('S2 asset-access surface (integration)', () => { + beforeEach(() => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('Assets.from mixes a bare path + a bare value path + an X.of() descriptor, all usable loader-free', () => { + const assets = Assets.from({ + ship: 'sprites/ship.png', // bare → Texture (resource leaf) + config: 'data/config.json', // bare → AssetRef (value leaf) + level: Asset.kind<{ hp: number }>('json', 'levels/1.json'), // .of → AssetRef + }); + + // Constructed in a field-initializer position — NO loader involved yet. + expect(assets.ship).toBeInstanceOf(Texture); + expect(assets.config).toBeInstanceOf(AssetRef); + expect(assets.level).toBeInstanceOf(AssetRef); + + // Status channel present on every leaf, all pending. + expect(assets.ship.state).toBe('idle'); + expect(assets.ship.ready).toBe(false); + expect(assets.ship.error).toBeNull(); + expect(assets.level.state).toBe('idle'); + expect(assets.level.ready).toBe(false); + }); + + test('loader.load(catalog) heals resource + value leaves in place and flips the status channel', async () => { + mockFetchMixed({ hp: 7 }); + const loader = createCoreLoader(); + + const assets = Assets.from({ + ship: 'sprites/ship.png', + level: Asset.kind<{ hp: number }>('json', 'levels/1.json'), + }); + const ship = assets.ship; // capture identity to prove in-place heal + + loader.load(assets); + + await assets.ship.loaded; + await assets.level.loaded; + + // Same object, now ready — healed in place, not replaced. + expect(assets.ship).toBe(ship); + expect(assets.ship.state).toBe('ready'); + expect(assets.ship.ready).toBe(true); + expect(assets.ship.error).toBeNull(); + + expect(assets.level.ready).toBe(true); + expect(assets.level.value).toEqual({ hp: 7 }); + }); + + test('bare-path get() returns a stable AssetRef for a value kind', () => { + mockFetchMixed({}); + const loader = createCoreLoader(); + + expect(loader.get('data/config.json')).toBeInstanceOf(AssetRef); + }); +}); diff --git a/test/resources/asset-container.test.ts b/test/resources/asset-container.test.ts index 197c45dca..a47252901 100644 --- a/test/resources/asset-container.test.ts +++ b/test/resources/asset-container.test.ts @@ -1,8 +1,8 @@ import { materializeAssetBindings } from '#extensions/materialize'; +import { Asset } from '#resources/Asset'; import { CONTAINER_HEADER_SIZE, CONTAINER_MAGIC, type ContainerInput, encodeContainer, parseContainer } from '#resources/AssetContainer'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; -import { BinaryAsset, Json, TextAsset } from '#resources/tokens'; const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text); @@ -200,9 +200,9 @@ describe('Loader.loadContainer', () => { await loader.loadContainer('assets/pack.exoa'); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(loader.get(Json, 'level').value).toEqual({ score: 42 }); - expect(loader.get(TextAsset, 'readme').value).toBe('hello world'); - expect(new Uint8Array(loader.get(BinaryAsset, 'blob').value)).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(loader.get(Asset.kind('json', 'level')).value).toEqual({ score: 42 }); + expect(loader.get(Asset.kind('text', 'readme')).value).toBe('hello world'); + expect(new Uint8Array(loader.get(Asset.kind('binary', 'blob')).value)).toEqual(new Uint8Array([1, 2, 3, 4])); }); test('throws on an unknown asset type and stores nothing', async () => { diff --git a/test/resources/asset-handler-options.test.ts b/test/resources/asset-handler-options.test.ts index 78b1adaa9..750ae45bb 100644 --- a/test/resources/asset-handler-options.test.ts +++ b/test/resources/asset-handler-options.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { AssetBinding, AssetHandler, AssetLoadRequest } from '#extensions/Extension'; import { materializeAssetBindings } from '#extensions/materialize'; import { Asset } from '#resources/Asset'; +import { defineAsset } from '#resources/defineAsset'; import type { AssetLoaderContext } from '#resources/Loader'; import { Loader } from '#resources/Loader'; @@ -247,8 +248,8 @@ describe('declarative bindAsset identity propagation', () => { materializeAssetBindings(loader, [buildExampleBinding(() => loadCount++)]); // Both assets have identical source + options — same identity → single load - const a1 = new Asset({ type: 'example', source: 'file.dat', format: 'example', strict: true }); - const a2 = new Asset({ type: 'example', source: 'file.dat', format: 'example', strict: true }); + const a1 = new Asset({ kind: 'example', source: 'file.dat', format: 'example', strict: true }); + const a2 = new Asset({ kind: 'example', source: 'file.dat', format: 'example', strict: true }); await Promise.all([loader.load(a1), loader.load(a2)]); @@ -260,8 +261,8 @@ describe('declarative bindAsset identity propagation', () => { const calls: ResolvedExampleOptions[] = []; materializeAssetBindings(loader, [buildExampleBinding(o => calls.push(o))]); - const a1 = new Asset({ type: 'example', source: 'world.dat', format: 'example', strict: true }); - const a2 = new Asset({ type: 'example', source: 'world.dat', format: 'example', strict: true }); + const a1 = new Asset({ kind: 'example', source: 'world.dat', format: 'example', strict: true }); + const a2 = new Asset({ kind: 'example', source: 'world.dat', format: 'example', strict: true }); const [r1, r2] = await Promise.all([loader.load(a1), loader.load(a2)]); @@ -275,8 +276,8 @@ describe('declarative bindAsset identity propagation', () => { materializeAssetBindings(loader, [buildExampleBinding(o => calls.push(o.strict))]); // strict: true and strict: false produce different resources - const strict = new Asset({ type: 'example', source: 'data.dat', strict: true }); - const lenient = new Asset({ type: 'example', source: 'data.dat', strict: false }); + const strict = new Asset({ kind: 'example', source: 'data.dat', strict: true }); + const lenient = new Asset({ kind: 'example', source: 'data.dat', strict: false }); const [r1, r2] = await Promise.all([loader.load(strict), loader.load(lenient)]); @@ -291,9 +292,9 @@ describe('declarative bindAsset identity propagation', () => { materializeAssetBindings(loader, [buildExampleBinding(() => loadCount++)]); // load with no options — handler normalizes to { format: 'example', strict: true } - const noOpts = new Asset({ type: 'example', source: 'map.dat' }); + const noOpts = new Asset({ kind: 'example', source: 'map.dat' }); // load with explicit defaults — same normalized result - const explicitDefaults = new Asset({ type: 'example', source: 'map.dat', format: 'example', strict: true }); + const explicitDefaults = new Asset({ kind: 'example', source: 'map.dat', format: 'example', strict: true }); await Promise.all([loader.load(noOpts), loader.load(explicitDefaults)]); @@ -328,8 +329,8 @@ describe('declarative bindAsset identity propagation', () => { materializeAssetBindings(loader, [bindingWithTrace]); - const withTrace = new Asset({ type: 'traceExample', source: 'asset.dat', trace: true }); - const withoutTrace = new Asset({ type: 'traceExample', source: 'asset.dat', trace: false }); + const withTrace = new Asset({ kind: 'traceExample', source: 'asset.dat', trace: true }); + const withoutTrace = new Asset({ kind: 'traceExample', source: 'asset.dat', trace: false }); await Promise.all([loader.load(withTrace), loader.load(withoutTrace)]); @@ -357,8 +358,8 @@ describe('declarative bindAsset identity propagation', () => { materializeAssetBindings(loader, [noIdentityBinding]); - const a1 = new Asset({ type: 'simpleExample', source: 'shared.dat' }); - const a2 = new Asset({ type: 'simpleExample', source: 'shared.dat' }); + const a1 = new Asset({ kind: 'simpleExample', source: 'shared.dat' }); + const a2 = new Asset({ kind: 'simpleExample', source: 'shared.dat' }); await Promise.all([loader.load(a1), loader.load(a2)]); @@ -395,23 +396,22 @@ describe('declarative bindAsset identity propagation', () => { it('handler receives options nested under request.options via declarative path', async () => { let seenRequest: AssetLoadRequest | undefined; - const capturingBinding: AssetBinding = { + const capturingBinding = defineAsset({ type: ExampleAsset, - typeNames: ['captureExample'], + kind: 'captureExample', + isValue: false, - create() { - return { - async load(request) { - seenRequest = request; - return new ExampleAsset(); - }, - }; - }, - }; + create: () => ({ + async load(request) { + seenRequest = request; + return new ExampleAsset(); + }, + }), + }); materializeAssetBindings(loader, [capturingBinding]); - await expect(loader.load(ExampleAsset, 'thing.dat', { format: 'alt', strict: false } as ExampleLoadOptions)).resolves.toBeInstanceOf(ExampleAsset); + await expect(loader.load(new Asset({ kind: 'captureExample', source: 'thing.dat', format: 'alt', strict: false }))).resolves.toBeInstanceOf(ExampleAsset); expect(seenRequest?.source).toBe('thing.dat'); expect(seenRequest?.options).toEqual({ format: 'alt', strict: false }); @@ -421,23 +421,22 @@ describe('declarative bindAsset identity propagation', () => { it('handler receives no options key when none are passed (declarative path)', async () => { let seenRequest: AssetLoadRequest | undefined; - const capturingBinding: AssetBinding = { + const capturingBinding = defineAsset({ type: ExampleAsset, - typeNames: ['captureNoOpts'], + kind: 'captureNoOpts', + isValue: false, - create() { - return { - async load(request) { - seenRequest = request; - return new ExampleAsset(); - }, - }; - }, - }; + create: () => ({ + async load(request) { + seenRequest = request; + return new ExampleAsset(); + }, + }), + }); materializeAssetBindings(loader, [capturingBinding]); - await expect(loader.load(ExampleAsset, 'thing.dat')).resolves.toBeInstanceOf(ExampleAsset); + await expect(loader.load(new Asset({ kind: 'captureNoOpts', source: 'thing.dat' }))).resolves.toBeInstanceOf(ExampleAsset); expect(seenRequest?.source).toBe('thing.dat'); expect(seenRequest?.options).toBeUndefined(); @@ -458,6 +457,14 @@ declare module '#resources/AssetDefinitions' { resource: ExampleAsset; config: { source: string; format?: 'example' | 'alt'; strict?: boolean }; }; + captureExample: { + resource: ExampleAsset; + config: { source: string; format?: 'example' | 'alt'; strict?: boolean; trace?: boolean }; + }; + captureNoOpts: { + resource: ExampleAsset; + config: { source: string }; + }; } } diff --git a/test/resources/asset-kind.test.ts b/test/resources/asset-kind.test.ts new file mode 100644 index 000000000..e113911d9 --- /dev/null +++ b/test/resources/asset-kind.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { Asset } from '#resources/Asset'; + +describe('Asset.kind()', () => { + it('builds a descriptor carrying source + options', () => { + const descriptor = Asset.kind('texture', 'sprites/player.png', { mimeType: 'image/png' }); + + expect(descriptor.source).toBe('sprites/player.png'); + }); + + it('builds a value descriptor', () => { + const descriptor = Asset.kind('json', 'levels/01.json'); + + expect(descriptor.source).toBe('levels/01.json'); + }); +}); diff --git a/test/resources/asset-manifest.test.ts b/test/resources/asset-manifest.test.ts deleted file mode 100644 index da3c84cb4..000000000 --- a/test/resources/asset-manifest.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { AssetManifest } from '#resources/AssetManifest'; -import { BundleLoadError, defineAssetManifest } from '#resources/AssetManifest'; -import { TextAsset } from '#resources/tokens'; - -class DummyAsset {} - -describe('defineAssetManifest', () => { - test('valid manifest passes through unchanged', () => { - const manifest = { - bundles: { - boot: [ - { type: TextAsset, alias: 'intro', path: 'intro.txt' }, - { type: DummyAsset, alias: 'dummy', path: 'dummy.bin', options: { mode: 'fast' } }, - ], - }, - } as const; - - const result = defineAssetManifest(manifest); - - expect(result).toBe(manifest); - }); - - test('empty manifest is allowed', () => { - expect(() => defineAssetManifest({ bundles: {} })).not.toThrow(); - }); - - test('invalid bundle name throws', () => { - const manifest = { - bundles: { - ' ': [], - }, - } as unknown as AssetManifest; - - expect(() => defineAssetManifest(manifest)).toThrow('bundle names'); - }); - - test('invalid or missing entry fields throw', () => { - const missingType = { - bundles: { - boot: [{ alias: 'intro', path: 'intro.txt' }], - }, - } as unknown as AssetManifest; - const emptyAlias = { - bundles: { - boot: [{ type: TextAsset, alias: ' ', path: 'intro.txt' }], - }, - } as unknown as AssetManifest; - const emptyPath = { - bundles: { - boot: [{ type: TextAsset, alias: 'intro', path: '' }], - }, - } as unknown as AssetManifest; - - expect(() => defineAssetManifest(missingType)).toThrow('entry[0]'); - expect(() => defineAssetManifest(emptyAlias)).toThrow('"alias"'); - expect(() => defineAssetManifest(emptyPath)).toThrow('"path"'); - }); - - test('duplicate (type, alias) within a bundle throws', () => { - const manifest = { - bundles: { - boot: [ - { type: TextAsset, alias: 'hero', path: 'hero-a.txt' }, - { type: TextAsset, alias: 'hero', path: 'hero-b.txt' }, - ], - }, - } as unknown as AssetManifest; - - expect(() => defineAssetManifest(manifest)).toThrow('duplicate'); - }); - - test('a non-object manifest throws', () => { - expect(() => defineAssetManifest(null as unknown as AssetManifest)).toThrow('manifest must be an object'); - }); - - test('a non-object "bundles" field throws', () => { - const manifest = { bundles: 'nope' } as unknown as AssetManifest; - - expect(() => defineAssetManifest(manifest)).toThrow('manifest.bundles must be an object'); - }); - - test('a bundle whose value is not an array throws', () => { - const manifest = { bundles: { boot: 'nope' } } as unknown as AssetManifest; - - expect(() => defineAssetManifest(manifest)).toThrow('must be an array of entries'); - }); - - test('a non-object entry throws', () => { - const manifest = { bundles: { boot: [42] } } as unknown as AssetManifest; - - expect(() => defineAssetManifest(manifest)).toThrow('entry[0] must be an object'); - }); - - test('duplicate alias with an anonymous type constructor labels it "(anonymous type)"', () => { - // A class returned from a function expression gets no name via JS's - // named-evaluation rules (unlike `class Foo {}` or `const Foo = class {}`). - const AnonType = (() => class {})(); - const manifest = { - bundles: { - boot: [ - { type: AnonType, alias: 'x', path: 'a.txt' }, - { type: AnonType, alias: 'x', path: 'b.txt' }, - ], - }, - } as unknown as AssetManifest; - - expect(AnonType.name).toBe(''); - expect(() => defineAssetManifest(manifest)).toThrow('(anonymous type)'); - }); -}); - -describe('BundleLoadError', () => { - test('pluralizes the failure count in its message for zero and multiple failures', () => { - const zero = new BundleLoadError('boot', []); - const two = new BundleLoadError('boot', [ - { type: TextAsset, alias: 'a', error: new Error('x') }, - { type: TextAsset, alias: 'b', error: new Error('y') }, - ]); - - expect(zero.message).toBe('Failed to load bundle "boot" (0 failures).'); - expect(two.message).toBe('Failed to load bundle "boot" (2 failures).'); - expect(zero.name).toBe('BundleLoadError'); - expect(two.failures).toHaveLength(2); - }); - - test('does not pluralize the message for exactly one failure', () => { - const one = new BundleLoadError('boot', [{ type: TextAsset, alias: 'a', error: new Error('x') }]); - - expect(one.message).toBe('Failed to load bundle "boot" (1 failure).'); - }); -}); diff --git a/test/resources/asset-ref.test.ts b/test/resources/asset-ref.test.ts index 587160319..7526000ae 100644 --- a/test/resources/asset-ref.test.ts +++ b/test/resources/asset-ref.test.ts @@ -1,10 +1,11 @@ import { expectTypeOf } from 'vitest'; import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; +import { Asset } from '#resources/Asset'; import { AssetRef } from '#resources/AssetRef'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; -import { Json, TextAsset } from '#resources/tokens'; function createCoreLoader(): Loader { const loader = new Loader(); @@ -37,11 +38,11 @@ describe('AssetRef value assets', () => { global.fetch = originalFetch; }); - test('get(Json, src) returns a loading ref whose value throws until ready', async () => { + test('get(Asset.kind(json, src)) returns a loading ref whose value throws until ready', async () => { mockFetchJson({ hp: 3 }); const loader = createCoreLoader(); - const ref = loader.get(Json, 'cfg.json'); + const ref = loader.get(Asset.kind('json', 'cfg.json')); expect(ref).toBeInstanceOf(AssetRef); expect(ref.loadState).toBe('loading'); @@ -56,14 +57,14 @@ describe('AssetRef value assets', () => { mockFetchJson({ a: 1 }); const loader = createCoreLoader(); - const ref = loader.get(Json, 'cfg.json'); + const ref = loader.get('cfg.json'); - expect(loader.get(Json, 'cfg.json')).toBe(ref); + expect(loader.get('cfg.json')).toBe(ref); - const value = await loader.load(Json, 'cfg.json'); + const value = await loader.load('cfg.json'); expect(value).toEqual({ a: 1 }); // load() still resolves the RAW value - expect(loader.get(Json, 'cfg.json')).toBe(ref); + expect(loader.get('cfg.json')).toBe(ref); expect(ref.value).toEqual({ a: 1 }); }); @@ -71,8 +72,8 @@ describe('AssetRef value assets', () => { mockFetchJson({ b: 2 }); const loader = createCoreLoader(); - await loader.load(Json, 'cfg.json'); - const ref = loader.get(Json, 'cfg.json'); + await loader.load(Asset.kind('json', 'cfg.json')); + const ref = loader.get(Asset.kind('json', 'cfg.json')); expect(ref.loadState).toBe('ready'); expect(ref.value).toEqual({ b: 2 }); @@ -82,24 +83,108 @@ describe('AssetRef value assets', () => { mockFetch404(); const loader = createCoreLoader(); - const ref = loader.get(Json, 'flaky.json'); + const ref = loader.get('flaky.json'); await expect(ref.loaded).rejects.toThrow(); expect(ref.loadState).toBe('failed'); expect(() => ref.value).toThrow("'failed'"); mockFetchJson({ ok: true }); - const again = loader.get(Json, 'flaky.json'); + const again = loader.get('flaky.json'); expect(again).toBe(ref); expect(ref.loadState).toBe('loading'); await expect(ref.loaded).resolves.toEqual({ ok: true }); }); +}); + +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 .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('type-level: token overloads', () => { + test('await load() on a bare .json path resolves the parsed value', async () => { + mockFetchJson({ hp: 7 }); const loader = createCoreLoader(); - expectTypeOf(loader.get<{ hp: number }>(Json, 'cfg.json')).toEqualTypeOf>(); - expectTypeOf(loader.get(TextAsset, 'a.txt')).toEqualTypeOf>(); + 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(); + 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..125bfee14 --- /dev/null +++ b/test/resources/asset-status.test.ts @@ -0,0 +1,14 @@ +import { assertType, describe, it } from 'vitest'; + +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', () => { + assertType(new AssetRef()); + assertType(new Texture(null)); + assertType(new Sound(null)); + }); +}); diff --git a/test/resources/asset.test.ts b/test/resources/asset.test.ts new file mode 100644 index 000000000..494f7baff --- /dev/null +++ b/test/resources/asset.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { Asset, AssetImpl } from '#resources/Asset'; + +describe('Asset.kind config shape', () => { + it('builds an AssetImpl with kind + source + spread opts', () => { + const a = Asset.kind('texture', 's.png', { mimeType: 'image/png' }); + expect(a).toBeInstanceOf(AssetImpl); + expect(a._config).toMatchObject({ kind: 'texture', source: 's.png', mimeType: 'image/png' }); + }); + + it('works with no opts', () => { + expect(Asset.kind('json', 'a.json')._config).toEqual({ kind: 'json', source: 'a.json' }); + }); +}); diff --git a/test/resources/assetDefinitions.type.test.ts b/test/resources/assetDefinitions.type.test.ts new file mode 100644 index 000000000..27b20aa2f --- /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(); + }); +}); diff --git a/test/resources/assetKindRegistry.test.ts b/test/resources/assetKindRegistry.test.ts new file mode 100644 index 000000000..b4d3ed8e1 --- /dev/null +++ b/test/resources/assetKindRegistry.test.ts @@ -0,0 +1,53 @@ +import '#resources/coreAssetBindings'; // side-effect: registers core kinds + +import { describe, expect, it } from 'vitest'; + +import { Texture } from '#rendering/texture/Texture'; +import { createLeaf, getAssetKind, registerAssetKind } from '#resources/assetKindRegistry'; +import { _readMeta } from '#resources/assetMeta'; +import { AssetRef } from '#resources/AssetRef'; +import { soundSeamlessAdapter } from '#resources/seamless'; + +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('idle'); + 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 }); + }); + + it('registerAssetKind throws when re-registering a kind with a different adapter', () => { + // texture is already registered by the '#resources/coreAssetBindings' 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/); + }); +}); diff --git a/test/resources/assetMeta.test.ts b/test/resources/assetMeta.test.ts new file mode 100644 index 000000000..2c5634381 --- /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.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(); + }); +}); diff --git a/test/resources/assets-group.test.ts b/test/resources/assets-group.test.ts new file mode 100644 index 000000000..c2d235521 --- /dev/null +++ b/test/resources/assets-group.test.ts @@ -0,0 +1,38 @@ +import '#resources/coreAssetBindings'; + +import { describe, expect, it } from 'vitest'; + +import { Texture } from '#rendering/texture/Texture'; +import { Assets } from '#resources/Assets'; + +describe('Assets.group', () => { + it('spreads same-kind configs into a catalog with shared + per-entry options', () => { + const assets = Assets.from({ + ...Assets.group( + 'texture', + { + player: 'sprites/player.png', + boss: { source: 'sprites/boss.png', mimeType: 'image/webp' }, + }, + { mimeType: 'image/png' }, + ), + ...Assets.group('sound', { + jump: 'audio/jump.wav', + hit: 'audio/hit.wav', + }), + }); + + expect(assets.player).toBeInstanceOf(Texture); + expect(assets.player.state).toBe('idle'); + expect(assets.boss).toBeInstanceOf(Texture); + expect(assets.jump.state).toBe('idle'); + expect(assets.hit.state).toBe('idle'); + }); + + it('stamps the group kind and merges shared under a per-entry override', () => { + const group = Assets.group('texture', { boss: { source: 'b.png', mimeType: 'image/png' } }, { mimeType: 'image/webp' }); + + // per-entry mimeType wins over shared + expect(group.boss).toEqual({ kind: 'texture', source: 'b.png', mimeType: 'image/png' }); + }); +}); diff --git a/test/resources/assets-one.test.ts b/test/resources/assets-one.test.ts new file mode 100644 index 000000000..8f925cc05 --- /dev/null +++ b/test/resources/assets-one.test.ts @@ -0,0 +1,30 @@ +import '#resources/coreAssetBindings'; + +import { describe, expect, it } from 'vitest'; + +import { Texture } from '#rendering/texture/Texture'; +import { Asset } from '#resources/Asset'; +import { AssetRef } from '#resources/AssetRef'; +import { Assets } from '#resources/Assets'; + +describe('Assets.one', () => { + it('builds a single idle value leaf from a config', () => { + const chunk = Assets.one({ kind: 'json', source: 'c.json' }); + + expect(chunk).toBeInstanceOf(AssetRef); + expect(chunk.state).toBe('idle'); + }); + + it('builds a single idle resource leaf from a bare path', () => { + const ship = Assets.one('sprites/ship.png'); + + expect(ship).toBeInstanceOf(Texture); + expect(ship.state).toBe('idle'); + }); + + it('accepts an Asset.kind() descriptor (same descriptor set as a catalog field)', () => { + const cfg = Assets.one(Asset.kind('json', 'c.json')); + + expect(cfg).toBeInstanceOf(AssetRef); + }); +}); diff --git a/test/resources/assets.test.ts b/test/resources/assets.test.ts index c553a29b5..3028d51dc 100644 --- a/test/resources/assets.test.ts +++ b/test/resources/assets.test.ts @@ -1,26 +1,65 @@ +import '#resources/coreAssetBindings'; + +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', () => { - 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' }, + logo: { kind: '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', () => { - const existing = new Asset({ type: 'texture', source: '/shared.png' }); + test('carries extra config fields into the leaf meta opts', () => { + const bag = new Assets({ + logo: { kind: '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({ kind: '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', () => { - expect(() => new Assets({ entries: { type: 'texture', source: '/x.png' } } as never)).toThrow(/reserved/); + expect(() => new Assets({ entries: { kind: '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: { kind: '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..312df27c6 --- /dev/null +++ b/test/resources/assets.type.test.ts @@ -0,0 +1,15 @@ +import '#resources/coreAssetBindings'; + +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>(); + }); +}); diff --git a/test/resources/bm-font-factory.test.ts b/test/resources/bm-font-factory.test.ts index dcb078d49..b086b9c86 100644 --- a/test/resources/bm-font-factory.test.ts +++ b/test/resources/bm-font-factory.test.ts @@ -171,14 +171,16 @@ describe('BmFontLoaderFactory', () => { expect(font.textures).toEqual([texture]); expect(font.fontData.lineHeight).toBe(40); expect(loader.load).toHaveBeenCalledTimes(1); - expect(loader.load).toHaveBeenCalledWith(Texture, 'https://example.com/fonts/test.png'); + expect(loader.load).toHaveBeenCalledWith( + expect.objectContaining({ _config: expect.objectContaining({ kind: 'texture', source: 'https://example.com/fonts/test.png' }) }), + ); }); test('create() resolves page URLs relative to the .fnt descriptor URL, not the page filename alone', async () => { const loadedUrls: string[] = []; const loader = { - load: vi.fn(async (_ctor: unknown, url: string) => { - loadedUrls.push(url); + load: vi.fn(async (asset: unknown) => { + loadedUrls.push((asset as { _config: { source: string } })._config.source); return new Texture(null); }), } as unknown as Loader; @@ -198,8 +200,8 @@ chars count=0 `; const requestedUrls: string[] = []; const loader = { - load: vi.fn(async (_ctor: unknown, url: string) => { - requestedUrls.push(url); + load: vi.fn(async (asset: unknown) => { + requestedUrls.push((asset as { _config: { source: string } })._config.source); return new Texture(null); }), } as unknown as Loader; diff --git a/test/resources/catalog-adopt.test.ts b/test/resources/catalog-adopt.test.ts new file mode 100644 index 000000000..5ee5a0839 --- /dev/null +++ b/test/resources/catalog-adopt.test.ts @@ -0,0 +1,450 @@ +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'; +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). */ +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('idle'); + 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('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'); + const warnSpy = vi.spyOn(logger, 'warn'); + + 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); + // Idempotent re-adopt of the SAME handle must stay a silent no-op. + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + 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'); + + 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('first call'); + + 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 () => { + 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('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('idle'); + 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(); + + const leaf = createLeaf('json', 'cfg.json') as AssetRef; + + expect(leaf.loadState).toBe('idle'); + expect(() => leaf.value).toThrow("'idle'"); + + 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('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('cfg.json'); + + const leaf = createLeaf('json', 'cfg.json') as AssetRef; + expect(leaf.loadState).toBe('idle'); + + 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(); + + 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: { kind: 'texture', source: 'ship.png' }, + logo: { kind: '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('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: { kind: 'texture', source: 'ship.png' }, + logo: { kind: '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: { kind: '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 + }); + + // 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: { kind: 'json', source: 'cfg.json' } }); + const textureCatalog = new Assets({ ship: { kind: '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(); + const a = new Assets({ ship: { kind: 'texture', source: 'ship.png' } }); + const b = new Assets({ ship: { kind: '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 + }); + + // §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'); + const catalog = new Assets({ + a: { kind: 'texture', source: 'x.png' }, + b: { kind: 'texture', source: 'x.png' }, + }); + + loader.get(catalog); + + 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/core-asset-bindings.test.ts b/test/resources/core-asset-bindings.test.ts index a81bd9741..3ea8f6f6c 100644 --- a/test/resources/core-asset-bindings.test.ts +++ b/test/resources/core-asset-bindings.test.ts @@ -1,6 +1,6 @@ import type { AssetBinding, AssetHandler } from '#extensions/Extension'; import { BmFont } from '#rendering/text/BmFont'; -import { Texture } from '#rendering/texture/Texture'; +import { type Texture } from '#rendering/texture/Texture'; import { BinaryFactory } from '#resources/factories/BinaryFactory'; import { SubtitleFactory } from '#resources/factories/SubtitleFactory'; import { XmlFactory } from '#resources/factories/XmlFactory'; @@ -200,7 +200,7 @@ chars count=0 expect(font).toBeInstanceOf(BmFont); expect(font.textures).toEqual([fakeTexture]); - expect(loaderMock.load).toHaveBeenCalledWith(Texture, 'fonts/page0.png'); + expect(loaderMock.load).toHaveBeenCalledWith(expect.objectContaining({ _config: expect.objectContaining({ kind: 'texture', source: 'fonts/page0.png' }) })); }); }); diff --git a/test/resources/defineAsset-import-timing.test.ts b/test/resources/defineAsset-import-timing.test.ts new file mode 100644 index 000000000..f015e7b45 --- /dev/null +++ b/test/resources/defineAsset-import-timing.test.ts @@ -0,0 +1,25 @@ +// Import ONLY the catalog facade and the core-binding module — NO Application, +// no Loader. This proves `defineAsset` runs its global kind/extension +// registrations as an import side effect, so `Assets.from` resolves bare paths +// loader-free (asset-system v2 §5). If core registration were still tied to +// Application construction, `createLeaf` would throw here. +import '#resources/coreAssetBindings'; + +import { describe, expect, it } from 'vitest'; + +import { Texture } from '#rendering/texture/Texture'; +import { AssetRef } from '#resources/AssetRef'; +import { Assets } from '#resources/Assets'; + +describe('defineAsset import-timing regression', () => { + it('resolves a loader-free catalog from a plain import', () => { + const catalog = Assets.from({ ship: 'sprites/ship.png', level: 'level.json' }); + + // Resource kind → a real placeholder handle. + expect(catalog.ship).toBeInstanceOf(Texture); + expect((catalog.ship as Texture).loadState).toBe('idle'); + + // Value kind → a deferred AssetRef. + expect(catalog.level).toBeInstanceOf(AssetRef); + }); +}); diff --git a/test/resources/defineAsset.test.ts b/test/resources/defineAsset.test.ts new file mode 100644 index 000000000..9be225da5 --- /dev/null +++ b/test/resources/defineAsset.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import type { AssetHandler } from '#extensions/Extension'; +import { BmFont } from '#rendering/text/BmFont'; +import { Texture } from '#rendering/texture/Texture'; +import { getAssetKind } from '#resources/assetKindRegistry'; +import { defineAsset } from '#resources/defineAsset'; +import { resolveKindByPath } from '#resources/extensionKindRegistry'; +import { textureSeamlessAdapter } from '#resources/seamless'; +import { Json } from '#resources/tokens'; + +// This file does NOT import coreAssetBindings, so the module-level kind/extension +// registries start empty (vitest isolates modules per file). Each defineAsset +// call is the sole registrant of its kind here. + +const noopHandler = (): AssetHandler => ({ + load: () => Promise.resolve(undefined), +}); + +describe('defineAsset', () => { + it('returns a binding and registers a resource (seamless) kind + its extensions', () => { + const binding = defineAsset({ + type: Texture, + kind: 'texture', + extensions: ['png', 'jpg'], + seamless: textureSeamlessAdapter, + create: noopHandler, + }); + + expect(binding.type).toBe(Texture); + expect(binding.kind).toBe('texture'); + expect(binding.typeNames).toEqual(['texture']); + expect(binding.extensions).toEqual(['png', 'jpg']); + expect(binding.seamless).toBe(textureSeamlessAdapter); + + expect(getAssetKind('texture')).toEqual({ adapter: textureSeamlessAdapter, isValue: false }); + expect(resolveKindByPath('a/b.png')).toBe('texture'); + expect(resolveKindByPath('a/b.jpg')).toBe('texture'); + }); + + it('defaults typeNames to [kind] and isValue to true for a value kind', () => { + const binding = defineAsset({ + type: Json as never, + kind: 'json', + extensions: ['json'], + create: noopHandler, + }); + + expect(binding.typeNames).toEqual(['json']); + expect(binding.seamless).toBeUndefined(); + expect(getAssetKind('json')).toEqual({ isValue: true }); + expect(resolveKindByPath('level.json')).toBe('json'); + }); + + it('honours an explicit typeNames list', () => { + const binding = defineAsset({ + type: Json as never, + kind: 'vtt', + typeNames: ['vtt', 'srt'], + extensions: ['vtt'], + create: noopHandler, + }); + + expect(binding.typeNames).toEqual(['vtt', 'srt']); + expect(resolveKindByPath('subs.vtt')).toBe('vtt'); + }); + + it('does NOT globally register a non-leaf resource kind (isValue:false, no adapter)', () => { + const binding = defineAsset({ + type: BmFont, + kind: 'bmFont', + extensions: ['fnt'], + isValue: false, + create: noopHandler, + }); + + // The binding still carries its extension for the per-loader materialize path… + expect(binding.extensions).toEqual(['fnt']); + // …but the GLOBAL kind/extension registries stay untouched: a non-leaf kind + // has no placeholder strategy, so bare-path inference must not resolve it. + expect(getAssetKind('bmFont')).toBeUndefined(); + expect(resolveKindByPath('font.fnt')).toBeUndefined(); + }); +}); diff --git a/test/resources/extensionKindRegistry.core.test.ts b/test/resources/extensionKindRegistry.core.test.ts new file mode 100644 index 000000000..55500a732 --- /dev/null +++ b/test/resources/extensionKindRegistry.core.test.ts @@ -0,0 +1,35 @@ +import '#resources/coreAssetBindings'; // 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.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'], + ])('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(); + }); +}); diff --git a/test/resources/extensionKindRegistry.test.ts b/test/resources/extensionKindRegistry.test.ts new file mode 100644 index 000000000..f46b73950 --- /dev/null +++ b/test/resources/extensionKindRegistry.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { _resetExtensionKindsForTest, registerExtensionKind, resolveKindByPath } 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\(\)/); + }); +}); diff --git a/test/resources/get-asset-descriptor.test.ts b/test/resources/get-asset-descriptor.test.ts new file mode 100644 index 000000000..25f311fce --- /dev/null +++ b/test/resources/get-asset-descriptor.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from 'vitest'; + +import { materializeAssetBindings } from '#extensions/materialize'; +import { Texture } from '#rendering/texture/Texture'; +import { Asset } from '#resources/Asset'; +import { AssetRef } from '#resources/AssetRef'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +/** Loader with all core asset bindings (mirrors the sibling asset-access tests). */ +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +const mockFetch = (jsonPayload: unknown): void => { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => jsonPayload, + text: async () => JSON.stringify(jsonPayload), + arrayBuffer: async () => new ArrayBuffer(8), + }) as unknown as Response, + ); +}; + +// G1 (S3 Phase 4.5): `get(Asset.kind())` is the replacement for the removed +// `get(Type, dynamicSource)` form — a raw `X.of()` descriptor passed to `get()` +// must build and adopt its handle-hybrid leaf, not fall through to the legacy +// alias-lookup branch. +describe('get(Asset.kind()) descriptor access', () => { + beforeEach(() => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('get(Asset.kind(json, ...)) returns a stable AssetRef that fills on load', async () => { + mockFetch({ n: 7 }); + const loader = createCoreLoader(); + + const ref = loader.get(Asset.kind<{ n: number }>('json', 'levels/1.json')); + expect(ref).toBeInstanceOf(AssetRef); + expect(ref.ready).toBe(false); + + await ref.loaded; + expect(ref.value).toEqual({ n: 7 }); + }); + + test('get(Asset.kind(text, ...)) returns an AssetRef for a primitive value kind', () => { + mockFetch('hello'); + const loader = createCoreLoader(); + + expect(loader.get(Asset.kind('text', 'greeting.txt'))).toBeInstanceOf(AssetRef); + }); + + test('get(Asset.kind(texture, ...)) returns a placeholder Texture that heals in place on load', async () => { + mockFetch({}); + const loader = createCoreLoader(); + + const texture = loader.get(Asset.kind('texture', 'sprites/ship.png')); + expect(texture).toBeInstanceOf(Texture); + expect(texture.state).toBe('loading'); + + await texture.loaded; + expect(texture.state).toBe('ready'); + }); + + test('get(dynamic Asset.kind(texture, path)) works — the removed get(Texture, dynamicSource) replacement', () => { + mockFetch({}); + const loader = createCoreLoader(); + + const dynamicPath = ['sprites', 'dyn.png'].join('/'); + expect(loader.get(Asset.kind('texture', dynamicPath))).toBeInstanceOf(Texture); + }); + + test('get(Asset.kind()) on a non-leaf resource kind throws with guidance to use load()', () => { + const loader = createCoreLoader(); + + // bmFont is a non-leaf resource kind (no seamless adapter, not a value kind). + expect(() => loader.get(Asset.kind('bmFont', 'font.fnt'))).toThrow(/get\(\) is for seamless\/value assets/); + }); + + test('type: get(Asset.kind(json)) is AssetRef, get(Asset.kind(texture)) is Texture', () => { + const loader = createCoreLoader(); + + // Value descriptor with a primitive payload → AssetRef. + expectTypeOf(loader.get(Asset.kind('json', 'n.json'))).toEqualTypeOf>(); + // Resource descriptor → its heal-in-place handle. + expectTypeOf(loader.get(Asset.kind('texture', 'x.png'))).toEqualTypeOf(); + }); +}); diff --git a/test/resources/idle-state.test.ts b/test/resources/idle-state.test.ts new file mode 100644 index 000000000..b85c8d5fa --- /dev/null +++ b/test/resources/idle-state.test.ts @@ -0,0 +1,57 @@ +import '#resources/coreAssetBindings'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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'; + +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +describe('idle load state', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('unadopted catalog leaves are idle', () => { + const assets = Assets.from({ + ship: 'sprites/ship.png', // resource leaf + level: { kind: 'json', source: 'l.json' }, // value leaf + }); + + expect(assets.ship.state).toBe('idle'); + expect(assets.level.state).toBe('idle'); + }); + + it('a manually constructed runtime resource is never idle', () => { + expect(Texture.fromColor(0xff0000).state).toBe('ready'); + }); + + it('an idle resource leaf is still a usable placeholder', () => { + const assets = Assets.from({ ship: 'sprites/ship.png' }); + + // idle does not mean unusable — the placeholder is a real, renderable Texture. + expect(assets.ship).toBeInstanceOf(Texture); + }); + + it('adopting an idle leaf transitions it idle -> loading', () => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + const loader = createCoreLoader(); + loader.setConcurrency(0); // park the background queue so the transition is observable + + const assets = Assets.from({ ship: { kind: 'texture', source: 'ship.png' } }); + expect(assets.ship.state).toBe('idle'); + + loader.load(assets, { background: true }); + expect(assets.ship.state).toBe('loading'); + }); +}); diff --git a/test/resources/load-background-option.test.ts b/test/resources/load-background-option.test.ts new file mode 100644 index 000000000..b2cee31cd --- /dev/null +++ b/test/resources/load-background-option.test.ts @@ -0,0 +1,172 @@ +import { materializeAssetBindings } from '#extensions/materialize'; +import { Assets } from '#resources/Assets'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +/** Loader with all core asset bindings (mirrors createCoreLoader in the sibling adopt/background specs). */ +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +const mockFetchImage = (): ReturnType => { + const fetchMock = vi.fn( + async (): Promise => ({ ok: true, status: 200, statusText: 'OK', arrayBuffer: async () => new ArrayBuffer(8) }) as unknown as Response, + ); + + global.fetch = fetchMock as typeof fetch; + + return fetchMock; +}; + +const mockFetchJson = (payload: unknown): ReturnType => { + const fetchMock = 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, + ); + + global.fetch = fetchMock as typeof fetch; + + return fetchMock; +}; + +/** Alias-keyed queue probe — avoids coupling the assertions to token identity. */ +const isQueued = (loader: Loader, alias: string): boolean => + (loader as unknown as { _backgroundQueue: Array<{ alias: string }> })._backgroundQueue.some(e => e.alias === alias); + +describe('load(target, { background: true })', () => { + beforeEach(() => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + test('claims + queues each catalog leaf instead of fetching immediately, then heals in place on drain', async () => { + const fetchMock = mockFetchImage(); + const loader = createCoreLoader(); + + loader.setConcurrency(0); // park the queue so the divert is observable + + const catalog = new Assets({ ship: { kind: 'texture', source: 'ship.png' } }); + loader.load(catalog, { background: true }); + + // Adopted (registered + claimed) but NOT fetched — the leaf sits in the background queue. + expect(catalog.ship.loadState).toBe('loading'); + expect(isQueued(loader, 'ship.png')).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + + loader.setConcurrency(6); + await loader.awaitBackground(); + + expect(catalog.ship.loadState).toBe('ready'); + expect(catalog.ship.width).toBe(4); + expect(fetchMock).toHaveBeenCalledTimes(1); + // The loader's own get() for the same source resolves to the adopted, healed leaf. + expect(loader.get('ship.png')).toBe(catalog.ship); + }); + + test('get() on a background-queued source boosts it past the parked queue and heals the SAME leaf', async () => { + const fetchMock = mockFetchImage(); + const loader = createCoreLoader(); + + loader.setConcurrency(0); // keep the queue parked so boost is observable + + const catalog = new Assets({ + ship: { kind: 'texture', source: 'ship.png' }, + logo: { kind: 'texture', source: 'logo.png' }, + }); + loader.load(catalog, { background: true }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(isQueued(loader, 'ship.png')).toBe(true); + + const ship = loader.get('ship.png'); // boosts ship past the parked queue + + await expect(ship.loaded).resolves.toBe(ship); + expect(ship).toBe(catalog.ship); // the adopted handle healed, not a new instance + expect(ship.loadState).toBe('ready'); + expect(fetchMock).toHaveBeenCalledTimes(1); // only the boosted source fetched so far + expect(isQueued(loader, 'ship.png')).toBe(false); // dequeued by the boost + expect(isQueued(loader, 'logo.png')).toBe(true); // logo stays parked + }); + + test('foreground load(catalog) (no option) still fetches immediately — unchanged', async () => { + const fetchMock = mockFetchImage(); + const loader = createCoreLoader(); + + loader.setConcurrency(0); // background parked, but the foreground path ignores concurrency + + const catalog = new Assets({ ship: { kind: 'texture', source: 'ship.png' } }); + loader.load(catalog); + + expect(isQueued(loader, 'ship.png')).toBe(false); + + await catalog.ship.loaded; // resolves despite concurrency 0 → it never touched the queue + expect(catalog.ship.loadState).toBe('ready'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('onProgress reports across a background catalog load', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + const ticks: Array<[number, number]> = []; + + loader.onProgress.add((loaded, total) => ticks.push([loaded, total])); + loader.load(Assets.from({ ship: 'ship.png', logo: 'logo.png' }), { background: true }); + await loader.awaitBackground(); + + expect(ticks.at(-1)).toEqual([2, 2]); + }); + + test('a value leaf (json) in a background catalog also defers then fills in place', async () => { + const fetchMock = mockFetchJson({ hp: 9 }); + const loader = createCoreLoader(); + + loader.setConcurrency(0); + + const catalog = new Assets({ config: { kind: 'json', source: 'cfg.json' } }); + loader.load(catalog, { background: true }); + + expect(catalog.config.loadState).toBe('loading'); + expect(isQueued(loader, 'cfg.json')).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + + loader.setConcurrency(6); + await loader.awaitBackground(); + + expect(catalog.config.loadState).toBe('ready'); + expect(catalog.config.value).toEqual({ hp: 9 }); + }); + + test('releasing a background-queued catalog at refcount 0 drops the entry from the queue', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + loader.setConcurrency(0); + + const catalog = new Assets({ ship: { kind: 'texture', source: 'ship.png' } }); + loader.load(catalog, { background: true }); + + expect(isQueued(loader, 'ship.png')).toBe(true); + + loader.release(catalog.ship); // last (root) claim released → refcount 0 → drop + + expect(isQueued(loader, 'ship.png')).toBe(false); + }); +}); diff --git a/test/resources/load-catalog-return.test.ts b/test/resources/load-catalog-return.test.ts new file mode 100644 index 000000000..b7cc7f44c --- /dev/null +++ b/test/resources/load-catalog-return.test.ts @@ -0,0 +1,62 @@ +import '#resources/coreAssetBindings'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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'; + +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +function mockFetch(json: unknown): void { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => json, + text: async () => JSON.stringify(json), + arrayBuffer: async () => new ArrayBuffer(8), + }) as unknown as Response, + ) as typeof fetch; +} + +describe('load(catalog) return value', () => { + afterEach(() => { + vi.unstubAllGlobals(); + global.fetch = originalFetch; + }); + + it('returns a resolved value map — value leaves unwrapped to their value, resource leaves as the resource', async () => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ width: 4, height: 4 })), + ); + mockFetch({ hp: 3 }); + const loader = createCoreLoader(); + + const assets = Assets.from({ + ship: { kind: 'texture', source: 'ship.png' }, + config: { kind: 'json', source: 'cfg.json' }, + }); + + const loaded = await loader.load(assets); + + // value leaf unwrapped to its decoded value (NOT an AssetRef) + expect(loaded.config).toEqual({ hp: 3 }); + // resource leaf resolved to the resource itself + expect(loaded.ship).toBeInstanceOf(Texture); + + // the catalog property still exposes the ref form for value leaves + expect(assets.config.value).toEqual({ hp: 3 }); + }); +}); diff --git a/test/resources/loader-background.test.ts b/test/resources/loader-background.test.ts deleted file mode 100644 index 806239fc5..000000000 --- a/test/resources/loader-background.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { materializeAssetBindings } from '#extensions/materialize'; -import { Texture } from '#rendering/texture/Texture'; -import { coreAssetBindings } from '#resources/coreAssetBindings'; -import { Loader } from '#resources/Loader'; - -function createCoreLoader(): Loader { - const loader = new Loader(); - materializeAssetBindings(loader, coreAssetBindings); - return loader; -} - -const originalFetch = global.fetch; - -describe('backgroundLoad(Type, srcs)', () => { - beforeEach(() => { - vi.stubGlobal( - 'createImageBitmap', - vi.fn(async () => ({ width: 16, height: 16 })), - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - global.fetch = originalFetch; - }); - - const mockFetchImage = (): ReturnType => { - const fetchMock = vi.fn( - async (): Promise => ({ ok: true, status: 200, statusText: 'OK', arrayBuffer: async () => new ArrayBuffer(8) }) as unknown as Response, - ); - - global.fetch = fetchMock as typeof fetch; - - return fetchMock; - }; - - test('declares and queues sources in one step; loadAll drains them', async () => { - const fetchMock = mockFetchImage(); - const loader = createCoreLoader(); - - loader.backgroundLoad(Texture, ['a.png', 'b.png']); - await loader.loadAll(); - - expect(loader.has(Texture, 'a.png')).toBe(true); - expect(loader.has(Texture, 'b.png')).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test('single-source form and re-declared sources are deduped', async () => { - const fetchMock = mockFetchImage(); - const loader = createCoreLoader(); - - loader.backgroundLoad(Texture, 'a.png'); - loader.backgroundLoad(Texture, 'a.png'); - await loader.loadAll(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test('get() on a background-queued source boosts it and converges on one instance', async () => { - const fetchMock = mockFetchImage(); - const loader = createCoreLoader(); - - loader.setConcurrency(0); // keep the queue parked so boost is observable - loader.backgroundLoad(Texture, ['a.png', 'b.png']); - - const handle = loader.get(Texture, 'b.png'); // boosts b past the parked queue - - await expect(handle.loaded).resolves.toBe(handle); - expect(fetchMock).toHaveBeenCalledTimes(1); // only the boosted source fetched so far - expect(loader.get(Texture, 'b.png')).toBe(handle); - }); - - test('onProgress reports across the declared batch', async () => { - mockFetchImage(); - const loader = createCoreLoader(); - const ticks: Array<[number, number]> = []; - - loader.onProgress.add((loaded, total) => ticks.push([loaded, total])); - loader.backgroundLoad(Texture, ['a.png', 'b.png']); - await loader.loadAll(); - - expect(ticks.at(-1)).toEqual([2, 2]); - }); -}); diff --git a/test/resources/loader-claims.test.ts b/test/resources/loader-claims.test.ts index 68b3b2062..4885e745d 100644 --- a/test/resources/loader-claims.test.ts +++ b/test/resources/loader-claims.test.ts @@ -2,7 +2,8 @@ import { expectTypeOf } from 'vitest'; import { Sound } from '#audio/Sound'; import { materializeAssetBindings } from '#extensions/materialize'; -import { Texture } from '#rendering/texture/Texture'; +import { Asset } from '#resources/Asset'; +import { Assets } from '#resources/Assets'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader, type LoaderOptions } from '#resources/Loader'; @@ -48,11 +49,11 @@ describe('seamless Sound', () => { mockFetchAudio(); const loader = createCoreLoader(); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); expect(handle).toBeInstanceOf(Sound); expect(handle.loadState).toBe('loading'); - expect(loader.get(Sound, 'boom.ogg')).toBe(handle); // deduped identity + expect(loader.get('boom.ogg')).toBe(handle); // deduped identity await handle.loaded; @@ -87,7 +88,7 @@ describe('refcount / claims', () => { test('app.loader.get claims under app lifetime; release() at refcount 0 evicts the payload', async () => { mockFetchAudio(); const loader = createCoreLoader(); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); await handle.loaded; expect(handle.audioBuffer).not.toBeNull(); @@ -99,24 +100,27 @@ describe('refcount / claims', () => { test('claiming again re-fetches into the SAME handle (in-place heal)', async () => { mockFetchAudio(); const loader = createCoreLoader(); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); await handle.loaded; loader.release(handle); expect(handle.audioBuffer).toBeNull(); - const again = loader.get(Sound, 'boom.ogg'); + const again = loader.get('boom.ogg'); expect(again).toBe(handle); // identity preserved await handle.loaded; expect(handle.audioBuffer).not.toBeNull(); }); test('a not-yet-started background entry is dropped from the queue at refcount 0', () => { - mockFetchAudio(); + // Hanging fetch: 'a.ogg' stays in flight (never settles), so 'b'/'c' remain + // queued behind the cap — no background fetch settles past the synchronous + // assertions (avoids an unhandled rejection after the test tears the mock down). + global.fetch = vi.fn((): Promise => new Promise(() => {})) as unknown as typeof fetch; // Concurrency 1: only 'a.ogg' goes in flight; 'b'/'c' stay queued so the // eviction queue-drop splice is actually exercised (at the default cap of 6 // all three drain synchronously and the queue is already empty). const loader = createCoreLoader({ concurrency: 1 }); - loader.backgroundLoad(Sound, ['a.ogg', 'b.ogg', 'c.ogg']); + loader.load(Assets.from({ a: 'a.ogg', b: 'b.ogg', c: 'c.ogg' }), { background: true }); expect(loader['_isQueuedInBackground'](Sound, 'c.ogg')).toBe(true); // still queued behind the cap loader.release(Sound, 'c.ogg'); @@ -128,8 +132,8 @@ describe('refcount / claims', () => { const loader = createCoreLoader(); const scopeA = Symbol('A'); const scopeB = Symbol('B'); - const handle = loader._getClaimed(scopeA, Sound, 'boom.ogg') as Sound; - loader._getClaimed(scopeB, Sound, 'boom.ogg'); + const handle = loader._getClaimed(scopeA, Asset.kind('sound', 'boom.ogg')) as Sound; + loader._getClaimed(scopeB, Asset.kind('sound', 'boom.ogg')); await handle.loaded; loader._release(loader['_key'](Sound, 'boom.ogg'), scopeA); @@ -143,7 +147,7 @@ describe('refcount / claims', () => { const loader = createCoreLoader(); const key = loader['_key'](Sound, 'boom.ogg'); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); // Fetch is in flight: the handle is still deferred, not yet in _resources. expect(loader['_deferred'].has(key)).toBe(true); expect(handle.loadState).toBe('loading'); @@ -172,7 +176,7 @@ describe('refcount / claims', () => { const loader = createCoreLoader(); const key = loader['_key'](Sound, 'boom.ogg'); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); await handle.loaded; expect(loader['_resources'].get(Sound as never)?.get('boom.ogg')).toBe(handle); @@ -186,10 +190,10 @@ describe('refcount / claims', () => { // reclaim's live entry, so the concurrent load() below is no longer deduped // and re-enters _loadSingle → a second _dispatchFetch whose raw donor // overwrites the handle in _resources. - const again = loader.get(Sound, 'boom.ogg'); + const again = loader.get('boom.ogg'); expect(again).toBe(handle); await Promise.resolve(); - const concurrent = loader.load(Sound, 'boom.ogg'); + const concurrent = loader.load('boom.ogg'); await handle.loaded; await concurrent; @@ -203,7 +207,7 @@ describe('refcount / claims', () => { mockFetchAudio(); const loader = createCoreLoader(); - const handle = loader.get(Sound, 'boom.ogg'); + const handle = loader.get('boom.ogg'); // Capture the pending .loaded before releasing: this is the promise the fill // settles, so the awaiter observes the completed asset even after eviction. const captured = handle.loaded; @@ -226,7 +230,7 @@ describe('refcount / claims', () => { mockFetchAudio(); // arrayBuffer response feeds the stubbed createImageBitmap const loader = createCoreLoader(); - const handle = loader.get(Texture, 'x.png'); + const handle = loader.get('x.png'); await handle.loaded; expect(handle.source).not.toBeNull(); @@ -234,7 +238,7 @@ describe('refcount / claims', () => { expect(handle.source).toBeNull(); expect(handle.loadState).toBe('loading'); - const again = loader.get(Texture, 'x.png'); + const again = loader.get('x.png'); expect(again).toBe(handle); // identity preserved across the heal await handle.loaded; expect(handle.source).not.toBeNull(); diff --git a/test/resources/loader-extension-matching.test.ts b/test/resources/loader-extension-matching.test.ts index 7dd4e540b..e846ea786 100644 --- a/test/resources/loader-extension-matching.test.ts +++ b/test/resources/loader-extension-matching.test.ts @@ -3,8 +3,8 @@ import { expectTypeOf } from 'vitest'; import { materializeAssetBindings } from '#extensions/materialize'; import type { BmFont } from '#rendering/text/BmFont'; import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { registerExtensionKind } from '#resources/extensionKindRegistry'; import { type LoadByPath, Loader, type PathExtension } from '#resources/Loader'; -import { Json } from '#resources/tokens'; // Test-only compound registration (type level). declare module '#resources/Loader' { @@ -39,7 +39,7 @@ describe('compound extension matching (#14)', () => { const loader = createCoreLoader(); const seen: string[] = []; - loader.registerExtension('mock.json', Json); // Json's factory is bound via coreAssetBindings + registerExtensionKind('mock.json', 'json'); // compound suffix → the json value kind (bound via coreAssetBindings) global.fetch = vi.fn(async (url: string | URL | Request): Promise => { seen.push(String(url)); return { diff --git a/test/resources/loader-seamless.test.ts b/test/resources/loader-seamless.test.ts index af61ab8b2..c939be81d 100644 --- a/test/resources/loader-seamless.test.ts +++ b/test/resources/loader-seamless.test.ts @@ -3,6 +3,8 @@ 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 { Assets } from '#resources/Assets'; import { coreAssetBindings } from '#resources/coreAssetBindings'; import { Loader } from '#resources/Loader'; import { textureSeamlessAdapter } from '#resources/seamless'; @@ -57,7 +59,7 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'ship.png'); + const handle = loader.get('ship.png'); expect(handle).toBeInstanceOf(Texture); expect(handle.loadState).toBe('loading'); @@ -68,7 +70,7 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'ship.png'); + const handle = loader.get('ship.png'); const versionBefore = handle.version; await expect(handle.loaded).resolves.toBe(handle); @@ -81,23 +83,23 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const first = loader.get(Texture, 'ship.png'); - const second = loader.get(Texture, 'ship.png'); + const first = loader.get('ship.png'); + const second = loader.get('ship.png'); expect(second).toBe(first); await first.loaded; - expect(loader.get(Texture, 'ship.png')).toBe(first); - expect(loader.has(Texture, 'ship.png')).toBe(true); + expect(loader.get('ship.png')).toBe(first); + expect(loader._peekResource(Texture, 'ship.png')).not.toBeNull(); }); test('load() after get() resolves to the SAME handle instance', async () => { mockFetchImage(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'ship.png'); - const loaded = await loader.load(Texture, 'ship.png'); + const handle = loader.get('ship.png'); + const loaded = await loader.load('ship.png'); expect(loaded).toBe(handle); expect(handle.loadState).toBe('ready'); @@ -107,8 +109,8 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const loaded = await loader.load(Texture, 'ship.png'); - const handle = loader.get(Texture, 'ship.png'); + const loaded = await loader.load('ship.png'); + const handle = loader.get('ship.png'); expect(handle).toBe(loaded); expect(handle.loadState).toBe('ready'); @@ -121,7 +123,7 @@ describe('Loader seamless get (Texture)', () => { loader.onLoaded.add((_type, _alias, resource) => seen.push(resource)); - const handle = loader.get(Texture, 'ship.png'); + const handle = loader.get('ship.png'); await handle.loaded; expect(seen).toEqual([handle]); @@ -137,40 +139,32 @@ describe('Loader seamless get (Texture)', () => { expect(() => loader.get(Adapterless, 'never-loaded')).toThrow('Missing resource'); }); - test('array form returns deferred handles in input order and dedups', async () => { - mockFetchImage(); - const loader = createCoreLoader(); - - const [a, b, aAgain] = loader.get(Texture, ['a.png', 'b.png', 'a.png']); - - expect(a).toBeInstanceOf(Texture); - expect(b).not.toBe(a); - expect(aAgain).toBe(a); - expect(a).toBe(loader.get(Texture, 'a.png')); - - await Promise.all([a.loaded, b.loaded]); - expect(a.loadState).toBe('ready'); - expect(b.loadState).toBe('ready'); - }); - - test('record form returns handles under the input keys', async () => { + test('conflicting FETCH options (mimeType) warn once and the first call wins', async () => { mockFetchImage(); const loader = createCoreLoader(); + const warnings: string[] = []; + const removeSink = logger.addSink(entry => { + if (entry.severity === LogSeverity.Warning) warnings.push(entry.message); + }); - const { ship, gradient } = loader.get(Texture, { ship: 'ship.png', gradient: 'gradient.png' }); + try { + const handle = loader.get('ship.png', { mimeType: 'image/png' }); - expect(ship).toBeInstanceOf(Texture); - expect(gradient).not.toBe(ship); - expect(ship).toBe(loader.get(Texture, 'ship.png')); + // A different mimeType for one source cannot share the source-keyed decode. + loader.get('ship.png', { mimeType: 'image/webp' }); + loader.get('ship.png', { mimeType: 'image/webp' }); - await ship.loaded; - expect(ship.loadState).toBe('ready'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('first call'); - await gradient.loaded; - expect(gradient.loadState).toBe('ready'); + await handle.loaded; + expect(handle.loadState).toBe('ready'); + } finally { + removeSink(); + } }); - test('conflicting options warn once and the first call wins', async () => { + 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[] = []; @@ -179,21 +173,47 @@ describe('Loader seamless get (Texture)', () => { }); try { - const handle = loader.get(Texture, 'ship.png', { samplerOptions: { flipY: true } }); - - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: false } }); - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: false } }); + // 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('ship.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain('first call'); + expect(handle).toBe(loader.get('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.flipY).toBe(true); // first options reached the factory; fill transplanted them + expect(handle.scaleMode).toBe(ScaleModes.Nearest); // fill transplanted source only — sampler kept } finally { removeSink(); } }); + test('samplerOptions on a background-adopted catalog leaf survive a later bare get()', async () => { + mockFetchImage(); + const loader = createCoreLoader(); + + const catalog = new Assets({ ship: { kind: 'texture', source: 'ship.png', samplerOptions: { scaleMode: ScaleModes.Nearest } } }); + loader.load(catalog, { background: true }); + + // A bare get() for the same source returns the adopted leaf, whose sampler + // options were baked at createPlaceholder — not just applied at fetch time. + const handle = loader.get('ship.png'); + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + + await handle.loaded; + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + }); + + test('inline get() options are baked into the placeholder', () => { + mockFetchImage(); + const loader = createCoreLoader(); + + const handle = loader.get('ship.png', { samplerOptions: { scaleMode: ScaleModes.Nearest } }); + + expect(handle.scaleMode).toBe(ScaleModes.Nearest); + }); + test('same options (deep-equal) do not warn', () => { mockFetchImage(); const loader = createCoreLoader(); @@ -203,8 +223,8 @@ describe('Loader seamless get (Texture)', () => { }); try { - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: true } }); - loader.get(Texture, 'ship.png', { samplerOptions: { flipY: true } }); + loader.get('ship.png', { samplerOptions: { flipY: true } }); + loader.get('ship.png', { samplerOptions: { flipY: true } }); expect(warnings).toHaveLength(0); } finally { @@ -216,7 +236,7 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'ship.png'); + const handle = loader.get('ship.png'); loader.unload(Texture, 'ship.png'); @@ -224,7 +244,7 @@ describe('Loader seamless get (Texture)', () => { expect(handle.loadState).toBe('failed'); // Fetch mock is still OK — a later get() must retry and heal the SAME handle. - const again = loader.get(Texture, 'ship.png'); + const again = loader.get('ship.png'); expect(again).toBe(handle); expect(handle.loadState).toBe('loading'); @@ -237,25 +257,25 @@ describe('Loader seamless get (Texture)', () => { mockFetch404(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'gone.png'); + const handle = loader.get('gone.png'); await expect(handle.loaded).rejects.toThrow('Failed to load'); expect(handle.loadState).toBe('failed'); expect(handle.source).toBe(Texture.missing.source); - expect(loader.has(Texture, 'gone.png')).toBe(false); + expect(loader._peekResource(Texture, 'gone.png')).toBeNull(); }); test('get() on a failed source retries and heals the SAME handle in place', async () => { mockFetch404(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'flaky.png'); + const handle = loader.get('flaky.png'); await expect(handle.loaded).rejects.toThrow(); const rejectedPromise = handle.loaded; mockFetchImage(); - const retried = loader.get(Texture, 'flaky.png'); + const retried = loader.get('flaky.png'); expect(retried).toBe(handle); expect(handle.loadState).toBe('loading'); @@ -268,7 +288,7 @@ describe('Loader seamless get (Texture)', () => { expect(handle.loadState).toBe('ready'); expect(handle.width).toBe(16); expect(handle.source).not.toBe(Texture.missing.source); - expect(loader.has(Texture, 'flaky.png')).toBe(true); + expect(loader._peekResource(Texture, 'flaky.png')).not.toBeNull(); await expect(rejectedPromise).rejects.toThrow(); // the old promise stays rejected }); @@ -277,12 +297,12 @@ describe('Loader seamless get (Texture)', () => { mockFetch404(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'gone.png'); + const handle = loader.get('gone.png'); await expect(handle.loaded).rejects.toThrow(); mockFetch404(); - const again = loader.get(Texture, 'gone.png'); + const again = loader.get('gone.png'); expect(again).toBe(handle); expect(again.loadState).toBe('loading'); @@ -294,13 +314,13 @@ describe('Loader seamless get (Texture)', () => { mockFetch404(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'healme.png'); + const handle = loader.get('healme.png'); await expect(handle.loaded).rejects.toThrow(); const rejectedPromise = handle.loaded; mockFetchImage(); - const loaded = await loader.load(Texture, 'healme.png'); + const loaded = await loader.load('healme.png'); expect(loaded).toBe(handle); expect(handle.loadState).toBe('ready'); @@ -317,7 +337,7 @@ describe('Loader seamless get (Texture)', () => { loader.onError.add((_type, alias) => errors.push(alias)); - const handle = loader.get(Texture, 'gone.png'); + const handle = loader.get('gone.png'); await expect(handle.loaded).rejects.toThrow(); expect(errors).toEqual(['gone.png']); @@ -328,10 +348,13 @@ describe('Loader seamless get (Texture)', () => { const loader = createCoreLoader(); const errors: string[] = []; + loader.setConcurrency(0); // park the queue so the boosting get() owns (and awaits) the single fetch loader.onError.add((_type, alias) => errors.push(alias)); - loader.backgroundLoad(Texture, ['gone.png']); + // The background catalog queue rejects when its leaf 404s; the failure is + // asserted below via `handle.loaded`, so swallow the queue's own rejection. + loader.load(Assets.from({ gone: 'gone.png' }), { background: true }).catch(() => {}); - const handle = loader.get(Texture, 'gone.png'); + const handle = loader.get('gone.png'); await expect(handle.loaded).rejects.toThrow(); expect(errors).toEqual(['gone.png']); @@ -344,7 +367,7 @@ describe('Loader seamless get (Texture)', () => { loader.onError.add((_type, alias) => errors.push(alias)); - await expect(loader.load(Texture, 'gone.png')).rejects.toThrow(); + await expect(loader.load('gone.png')).rejects.toThrow(); expect(errors).toEqual([]); }); @@ -352,21 +375,19 @@ describe('Loader seamless get (Texture)', () => { mockFetch404(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'gone.png'); + const handle = loader.get('gone.png'); await expect(handle.loaded).rejects.toThrow(); global.fetch = vi.fn(async (): Promise => ({ ok: false, status: 500, statusText: 'Server Error' }) as Response); - await expect(loader.load(Texture, 'gone.png')).rejects.toThrow(); + await expect(loader.load('gone.png')).rejects.toThrow(); await expect(handle.loaded).rejects.toThrow('500'); // fresh error, fresh promise }); test('type-level: seamless get forms', () => { const loader = createCoreLoader(); - expectTypeOf(loader.get(Texture, 'a.png')).toEqualTypeOf(); - expectTypeOf(loader.get(Texture, ['a.png', 'b.png'])).toEqualTypeOf(); - expectTypeOf(loader.get(Texture, { a: 'a.png', b: 'b.png' })).toEqualTypeOf>(); + expectTypeOf(loader.get('a.png')).toEqualTypeOf(); expectTypeOf(new Texture(null).loaded).toEqualTypeOf>(); }); @@ -378,7 +399,7 @@ describe('Loader seamless get (Texture)', () => { expect(handle).toBeInstanceOf(Texture); expect(handle.loadState).toBe('loading'); - expect(loader.get(Texture, 'ship.png')).toBe(handle); + expect(loader.get('ship.png')).toBe(handle); await expect(handle.loaded).resolves.toBe(handle); expect(handle.width).toBe(16); @@ -401,7 +422,7 @@ describe('Loader seamless get (Texture)', () => { mockFetchImage(); const loader = createCoreLoader(); - const handle = loader.get(Texture, 'ship.png', { width: 16, height: 16 }); + const handle = loader.get('ship.png', { width: 16, height: 16 }); expect(handle.width).toBe(16); // reserved immediately, while loading await handle.loaded; diff --git a/test/resources/loader.test.ts b/test/resources/loader.test.ts index 68dc11131..9fd745707 100644 --- a/test/resources/loader.test.ts +++ b/test/resources/loader.test.ts @@ -1,14 +1,15 @@ 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'; -import { BundleLoadError, defineAssetManifest } from '#resources/AssetManifest'; import { Assets } from '#resources/Assets'; import type { CacheStore } from '#resources/CacheStore'; import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { defineAsset } from '#resources/defineAsset'; import { Loader } from '#resources/Loader'; -import { BinaryAsset, FontAsset, Json, TextAsset } from '#resources/tokens'; +import { TextAsset } from '#resources/tokens'; /** Create a Loader with all core asset bindings pre-installed. */ function createCoreLoader(options?: ConstructorParameters[0]): Loader { @@ -20,9 +21,12 @@ function createCoreLoader(options?: ConstructorParameters[0]): Lo // Declaration merges for test-only asset types declare module '#resources/AssetDefinitions' { interface AssetDefinitions { - mockAsset: { resource: string; config: { source: string } }; + mockAsset: { resource: string; config: { source: string; format?: string; scale?: number; locale?: string } }; richAsset: { resource: string; config: { source: string; format: string } }; boundAsset: { resource: unknown; config: { source: string; scale?: number } }; + dummyAsset: { resource: DummyAsset; config: { source: string } }; + firstType: { resource: unknown; config: { source: string } }; + secondType: { resource: unknown; config: { source: string } }; } } @@ -46,18 +50,6 @@ class DummyFactory implements AssetFactory { public destroy(): void {} } -class InstanceFactory implements AssetFactory { - public readonly storageName = 'instance'; - public readonly process = vi.fn(async (_response: Response): Promise => 'raw'); - public readonly create: (source: string) => Promise; - - public constructor(resource: T) { - this.create = vi.fn(async (_source: string): Promise => resource); - } - - public destroy(): void {} -} - interface Deferred { readonly promise: Promise; resolve(value: T | PromiseLike): void; @@ -123,7 +115,7 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - const result = await loader.load(TextAsset, 'demo.txt'); + const result = await loader.load('demo.txt'); expect(result).toBe('resource:fresh-source'); }); @@ -134,7 +126,7 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); expect(global.fetch).toHaveBeenCalledWith('/assets/demo.txt', expect.anything()); }); @@ -146,38 +138,11 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); expect(global.fetch).toHaveBeenCalledWith('/demo.txt', fetchOptions); }); - test('load(Type, [paths]) returns an array of resources', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - mockFetch(); - - const results = await loader.load(TextAsset, ['a.txt', 'b.txt']); - - expect(results).toHaveLength(2); - expect(results[0]).toBe('resource:fresh-source'); - expect(results[1]).toBe('resource:fresh-source'); - }); - - test('load(Type, { alias: path }) returns a record', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - mockFetch(); - - const result = await loader.load(TextAsset, { greeting: 'hello.txt', farewell: 'bye.txt' }); - - expect(result.greeting).toBe('resource:fresh-source'); - expect(result.farewell).toBe('resource:fresh-source'); - }); - test('load() deduplicates concurrent requests for the same alias', async () => { const factory = new MockTextFactory(); const loader = new Loader({ basePath: '/' }); @@ -185,7 +150,7 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - const [a, b] = await Promise.all([loader.load(TextAsset, 'same.txt'), loader.load(TextAsset, 'same.txt')]); + const [a, b] = await Promise.all([loader.load('same.txt'), loader.load('same.txt')]); expect(a).toBe(b); expect(factory.process).toHaveBeenCalledTimes(1); @@ -198,7 +163,7 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch404(); - await expect(loader.load(TextAsset, 'missing.txt')).rejects.toThrow('404 Not Found'); + await expect(loader.load('missing.txt')).rejects.toThrow('404 Not Found'); }); test('load() continues independently per item (fail-tolerant via Promise.allSettled pattern)', async () => { @@ -213,33 +178,33 @@ describe('Loader', () => { throw new Error('broken'); }); - const good = loader.load(TextAsset, 'good.txt'); - const bad = loader.load(TextAsset, 'bad.txt'); + const good = loader.load('good.txt'); + const bad = loader.load('bad.txt'); await expect(good).resolves.toBe('ok'); await expect(bad).rejects.toThrow('broken'); }); - test('get() retrieves loaded resource, peek() returns null for missing', async () => { + test('get() retrieves a loaded value asset', async () => { const factory = new MockTextFactory(); const loader = new Loader({ basePath: '/' }); loader.register(TextAsset, factory as AssetFactory); mockFetch(); - expect(loader.peek(TextAsset, 'demo.txt')).toBeNull(); - expect(loader.has(TextAsset, 'demo.txt')).toBe(false); + expect(loader._peekResource(TextAsset, 'demo.txt')).toBeNull(); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); - expect(loader.has(TextAsset, 'demo.txt')).toBe(true); - expect(loader.get(TextAsset, 'demo.txt').value).toBe('resource:fresh-source'); + expect(loader.get('demo.txt').value).toBe('resource:fresh-source'); }); test('get() returns a loading ref whose value throws for a never-loaded value asset', () => { - const loader = new Loader({ basePath: '/' }); + const loader = createCoreLoader({ basePath: '/' }); + // A fetch that never settles keeps the adopted ref in its 'loading' state. + global.fetch = vi.fn((): Promise => new Promise(() => {})); - const ref = loader.get(TextAsset, 'nope'); + const ref = loader.get(Asset.kind('text', 'nope')); expect(ref.loadState).toBe('loading'); expect(() => ref.value).toThrow("'loading'"); @@ -252,25 +217,36 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - await loader.load(TextAsset, { a: 'a.txt', b: 'b.txt' }); + await loader.load('a.txt'); + await loader.load('b.txt'); - expect(loader.has(TextAsset, 'a')).toBe(true); - loader.unload(TextAsset, 'a'); - expect(loader.has(TextAsset, 'a')).toBe(false); - expect(loader.has(TextAsset, 'b')).toBe(true); + expect(loader._peekResource(TextAsset, 'a.txt')).not.toBeNull(); + loader.unload(TextAsset, 'a.txt'); + expect(loader._peekResource(TextAsset, 'a.txt')).toBeNull(); + expect(loader._peekResource(TextAsset, 'b.txt')).not.toBeNull(); loader.unloadAll(TextAsset); - expect(loader.has(TextAsset, 'b')).toBe(false); + expect(loader._peekResource(TextAsset, 'b.txt')).toBeNull(); }); - test('custom factory via register() with user-defined class', async () => { - const factory = new DummyFactory(); + test('custom asset via defineAsset() with user-defined class', async () => { const loader = new Loader({ basePath: '/' }); - loader.register(DummyAsset, factory); - mockFetch(); + materializeAssetBindings(loader, [ + defineAsset({ + type: DummyAsset, + kind: 'dummyAsset', + isValue: false, + create: () => ({ + async load(request, ctx) { + return new DummyAsset(await ctx.fetchText(request.source)); + }, + }), + }), + ]); + mockFetch('raw'); - const result = await loader.load(DummyAsset, 'thing.dat'); + const result = await loader.load(new Asset({ kind: 'dummyAsset', source: 'thing.dat' })); expect(result).toBeInstanceOf(DummyAsset); expect(result.value).toBe('raw'); @@ -288,7 +264,7 @@ describe('Loader', () => { throw new Error('Unexpected network fetch on cache hit.'); }); - const result = await loader.load(TextAsset, 'cached.txt'); + const result = await loader.load('cached.txt'); expect(result).toBe('resource:cached-source'); expect(cacheStore.load).toHaveBeenCalledWith('text', 'cached.txt'); @@ -305,7 +281,7 @@ describe('Loader', () => { loader.register(TextAsset, factory as AssetFactory); mockFetch(); - const result = await loader.load(TextAsset, 'miss.txt'); + const result = await loader.load('miss.txt'); expect(result).toBe('resource:fresh-source'); expect(cacheStore.load).toHaveBeenCalledWith('text', 'miss.txt'); @@ -326,7 +302,7 @@ describe('Loader', () => { throw new Error('corrupt-cache'); }); - const result = await loader.load(TextAsset, 'corrupt.txt'); + const result = await loader.load('corrupt.txt'); expect(result).toBe('resource:fresh-source'); expect(cacheStore.delete).toHaveBeenCalledWith('text', 'corrupt.txt'); @@ -347,108 +323,11 @@ describe('Loader', () => { }) as unknown as Response, ); - const result = await loader.load(Json, 'data.json'); + const result = await loader.load('data.json'); expect(result).toBe(42); }); - test('backgroundLoad() + load() priority boost', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - - loader.register(TextAsset, factory as AssetFactory); - - let fetchCount = 0; - global.fetch = vi.fn(async (): Promise => { - fetchCount++; - return { ok: true, status: 200, statusText: 'OK' } as Response; - }); - - loader.backgroundLoad(TextAsset, ['a.txt', 'b.txt', 'c.txt']); - - // Priority boost: load 'c.txt' should resolve even though it was last in queue - const cResult = await loader.load(TextAsset, 'c.txt'); - - expect(cResult).toBe('resource:fresh-source'); - }); - - test('background load dispatches onLoaded exactly once per successful resource', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - const onLoaded = vi.fn(); - - loader.register(TextAsset, factory as AssetFactory); - loader.onLoaded.add(onLoaded); - mockFetch(); - - loader.backgroundLoad(TextAsset, 'queued.txt'); - await loader.loadAll(); - - expect(onLoaded).toHaveBeenCalledTimes(1); - expect(onLoaded).toHaveBeenCalledWith(TextAsset, 'queued.txt', 'resource:fresh-source'); - }); - - test('boosted background items keep loadAll pending and report full progress', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - const firstFetch = createDeferred(); - const boostedFetch = createDeferred(); - const progress: Array<[number, number]> = []; - - loader.register(TextAsset, factory as AssetFactory); - loader.onProgress.add((loaded, total) => { - progress.push([loaded, total]); - }); - - global.fetch = vi.fn((input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/first.txt')) { - return firstFetch.promise; - } - - if (url.endsWith('/boosted.txt')) { - return boostedFetch.promise; - } - - throw new Error(`Unexpected fetch url: ${url}`); - }); - - loader.backgroundLoad(TextAsset, ['first.txt', 'boosted.txt']); - - const loadAllPromise = loader.loadAll(); - let loadAllResolved = false; - - loadAllPromise.then(() => { - loadAllResolved = true; - }); - - const boostedPromise = loader.load(TextAsset, 'boosted.txt'); - - firstFetch.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response); - - await loader.load(TextAsset, 'first.txt'); - await Promise.resolve(); - - expect(loadAllResolved).toBe(false); - - boostedFetch.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response); - - await expect(boostedPromise).resolves.toBe('resource:fresh-source'); - await expect(loadAllPromise).resolves.toBeUndefined(); - - expect(progress).toContainEqual([1, 2]); - expect(progress[progress.length - 1]).toEqual([2, 2]); - }); - test('does not reinsert a resource when unload() is called during in-flight fetch', async () => { const factory = new MockTextFactory(); const loader = new Loader({ basePath: '/' }); @@ -458,7 +337,7 @@ describe('Loader', () => { global.fetch = vi.fn((_input: RequestInfo | URL): Promise => deferredFetch.promise); - const loadPromise = loader.load(TextAsset, 'inflight.txt'); + const loadPromise = loader.load('inflight.txt'); loader.unload(TextAsset, 'inflight.txt'); @@ -469,8 +348,7 @@ describe('Loader', () => { } as Response); await expect(loadPromise).resolves.toBe('resource:fresh-source'); - expect(loader.has(TextAsset, 'inflight.txt')).toBe(false); - expect(loader.peek(TextAsset, 'inflight.txt')).toBeNull(); + expect(loader._peekResource(TextAsset, 'inflight.txt')).toBeNull(); }); test('uses per-type internal keys instead of constructor names', async () => { @@ -480,673 +358,270 @@ describe('Loader', () => { Object.defineProperty(FirstType, 'name', { value: 'MinifiedType' }); Object.defineProperty(SecondType, 'name', { value: 'MinifiedType' }); - const firstFactory = new InstanceFactory(new FirstType()); - const secondFactory = new InstanceFactory(new SecondType()); const loader = new Loader({ basePath: '/' }); - loader.register(FirstType, firstFactory as AssetFactory); - loader.register(SecondType, secondFactory as AssetFactory); + materializeAssetBindings(loader, [ + defineAsset({ + type: FirstType, + kind: 'firstType', + isValue: false, + create: () => ({ + async load(request, ctx) { + await ctx.fetchText(request.source); + return new FirstType(); + }, + }), + }), + defineAsset({ + type: SecondType, + kind: 'secondType', + isValue: false, + create: () => ({ + async load(request, ctx) { + await ctx.fetchText(request.source); + return new SecondType(); + }, + }), + }), + ]); mockFetch(); - const [first, second] = await Promise.all([loader.load(FirstType, 'shared.asset'), loader.load(SecondType, 'shared.asset')]); + const [first, second] = await Promise.all([ + loader.load(new Asset({ kind: 'firstType', source: 'shared.asset' })), + loader.load(new Asset({ kind: 'secondType', source: 'shared.asset' })), + ]); expect(first).toBeInstanceOf(FirstType); expect(second).toBeInstanceOf(SecondType); expect(global.fetch).toHaveBeenCalledTimes(2); }); +}); - test('registerManifest() registers one manifest without loading', () => { - const loader = new Loader({ basePath: '/' }); - const manifest = defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'intro', path: 'intro.txt' }], - }, - }); +// ───────────────────────────────────────────────────────────────────────────── +// New Asset / Assets / LoadingQueue stabilisation tests +// ───────────────────────────────────────────────────────────────────────────── - loader.registerManifest(manifest); +class MockAssetType {} - expect(loader.hasBundle('boot')).toBe(false); - expect(loader.peek(TextAsset, 'intro')).toBeNull(); - }); +describe('LoadingQueue progress tracking', () => { + test('progress is updated to failed when asset type is unknown', async () => { + const loader = new Loader(); + // 'mockAsset' is in AssetDefinitions (via declaration merge above) but we + // deliberately do NOT call loader.registerAssetType() so that the runtime + // has no constructor registered for it → "no constructor" rejection path. + const asset = new Asset({ kind: 'mockAsset', source: 'test.dat' }); - test('registerManifest() throws when bundle name is already registered', () => { - const loader = new Loader({ basePath: '/' }); - const firstManifest = defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'intro', path: 'intro.txt' }], - }, - }); - const secondManifest = defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'menu', path: 'menu.txt' }], - }, - }); + const queue = loader.load(asset); + let lastProgress = queue.progress; - loader.registerManifest(firstManifest); + queue.onProgress.add(p => { + lastProgress = p; + }); - expect(() => loader.registerManifest(secondManifest)).toThrow('already registered'); + await expect(queue).rejects.toThrow('No constructor registered'); + // Progress must have settled — pending must be 0 + expect(lastProgress.pending).toBe(0); + expect(lastProgress.failed).toBe(1); + expect(lastProgress.loaded).toBe(0); }); - test('registerManifest() throws on conflicting (type, alias) across bundles', () => { + test('progress counts both successful and failed items in a map load', async () => { const loader = new Loader({ basePath: '/' }); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'shared', path: 'shared-a.txt' }], + // A handler that fails for the 'bad.dat' source (replaces the removed + // registerAssetType form; failure is driven by source rather than a factory + // mock). + loader.bindAsset( + { type: MockAssetType, typeNames: ['mockAsset'] }, + { + load: async request => { + if (request.source === 'bad.dat') { + throw new Error('bad'); + } + + return 'ok'; }, - }), + }, ); + mockFetch(); - expect(() => - loader.registerManifest( - defineAssetManifest({ - bundles: { - gameplay: [{ type: TextAsset, alias: 'shared', path: 'shared-b.txt' }], - }, - }), - ), - ).toThrow('Conflicting asset definition'); - }); + const goodAsset = new Asset({ kind: 'mockAsset', source: 'good.dat' }); + const badAsset = new Asset({ kind: 'mockAsset', source: 'bad.dat' }); - test('registerManifest() allows equivalent (type, alias) definitions across bundles', () => { - const loader = new Loader({ basePath: '/' }); + const queue = loader.load({ good: goodAsset, bad: badAsset }); + let lastProgress = queue.progress; - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { - type: TextAsset, - alias: 'shared', - path: 'shared.txt', - options: { locale: 'en', retries: [1, 2, 3] }, - }, - ], - }, - }), - ); + queue.onProgress.add(p => { + lastProgress = p; + }); - expect(() => - loader.registerManifest( - defineAssetManifest({ - bundles: { - gameplay: [ - { - type: TextAsset, - alias: 'shared', - path: 'shared.txt', - options: { locale: 'en', retries: [1, 2, 3] }, - }, - ], - }, - }), - ), - ).not.toThrow(); + await expect(queue).rejects.toThrow(); + expect(lastProgress.total).toBe(2); + expect(lastProgress.pending).toBe(0); + expect(lastProgress.loaded + lastProgress.failed).toBe(2); }); - test('registerManifest() throws on conflict with prior manual backgroundLoad()', () => { - const loader = new Loader({ basePath: '/' }); + // Shared mockFetch helper (redeclare locally in scope) + function mockFetch(): void { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(0), + }) as unknown as Response, + ); + } - loader.backgroundLoad(TextAsset, ['hero-v1.txt']); + afterEach(() => { + global.fetch = vi.fn(); + }); +}); - expect(() => - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'hero-v1.txt', path: 'hero-v2.txt' }], - }, - }), - ), - ).toThrow('Conflicting asset definition'); +describe('Asset / Assets identity and alias semantics', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; }); - test('loadBundle() loads a known bundle successfully', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); + function mockFetch(): void { + global.fetch = vi.fn( + async (): Promise => + ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => 'raw', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(0), + }) as unknown as Response, + ); + } - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'hero', path: 'hero.txt' }, - { type: TextAsset, alias: 'menu', path: 'menu.txt' }, - ], + // Binds MockAssetType as a handler type (replacing the removed + // registerAssetType(name, ctor, factory) form). The handler fetches through + // the context so cross-alias network dedup stays observable. + function bindMockAsset(loader: Loader): void { + loader.bindAsset( + { type: MockAssetType, typeNames: ['mockAsset'] }, + { + load: async (request, ctx) => { + await ctx.fetchText(request.source); + return `loaded:${request.source}`; }, - }), + }, ); + } + + test('same Asset under two aliases shares a single network fetch', async () => { + const loader = new Loader({ basePath: '/' }); + + bindMockAsset(loader); mockFetch(); - await expect(loader.loadBundle('boot')).resolves.toBeUndefined(); - expect(loader.get(TextAsset, 'hero').value).toBe('resource:fresh-source'); - expect(loader.get(TextAsset, 'menu').value).toBe('resource:fresh-source'); - }); + const hero = new Asset({ kind: 'mockAsset', source: 'images/hero.dat' }); - test('loadBundle() rejects clearly for unknown bundle name', async () => { - const loader = new Loader({ basePath: '/' }); + await loader.load({ heroA: hero, heroB: hero }); - await expect(loader.loadBundle('missing')).rejects.toThrow('Unknown bundle'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(loader._peekResource(MockAssetType, 'heroA')).not.toBeNull(); + expect(loader._peekResource(MockAssetType, 'heroB')).not.toBeNull(); }); - test('repeated loadBundle() calls are safe and do not refetch cached assets', async () => { - const factory = new MockTextFactory(); + test('get() resolves both aliases after multi-alias load', async () => { const loader = new Loader({ basePath: '/' }); - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'hero', path: 'hero.txt' }], - }, - }), - ); + bindMockAsset(loader); mockFetch(); - await loader.loadBundle('boot'); - await loader.loadBundle('boot'); + const hero = new Asset({ kind: 'mockAsset', source: 'images/hero.dat' }); - expect(global.fetch).toHaveBeenCalledTimes(1); + await loader.load({ heroA: hero, heroB: hero }); + + expect(loader.get(MockAssetType, 'heroA')).toBe(loader.get(MockAssetType, 'heroB')); }); - test('overlapping bundle loads deduplicate shared assets', async () => { - const factory = new MockTextFactory(); + test('unload(asset) removes asset loaded by source-as-alias (single Asset load)', async () => { const loader = new Loader({ basePath: '/' }); - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'shared', path: 'shared.txt' }], - gameplay: [ - { type: TextAsset, alias: 'shared', path: 'shared.txt' }, - { type: TextAsset, alias: 'level', path: 'level.txt' }, - ], - }, - }), - ); + bindMockAsset(loader); mockFetch(); - await Promise.all([loader.loadBundle('boot'), loader.loadBundle('gameplay')]); + const hero = new Asset({ kind: 'mockAsset', source: 'images/hero.dat' }); - expect(global.fetch).toHaveBeenCalledTimes(2); + await loader.load(hero); + + expect(loader._peekResource(MockAssetType, 'images/hero.dat')).not.toBeNull(); + + loader.unload(hero); + + expect(loader._peekResource(MockAssetType, 'images/hero.dat')).toBeNull(); }); - test('loadBundle() rejects with BundleLoadError on partial failure', async () => { - const factory = new MockTextFactory(); + test('unload(asset) removes all aliases after keyed-map load', async () => { const loader = new Loader({ basePath: '/' }); - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'ok', path: 'ok.txt' }, - { type: TextAsset, alias: 'missing', path: 'missing.txt' }, - ], - }, - }), - ); + bindMockAsset(loader); + mockFetch(); - global.fetch = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const hero = new Asset({ kind: 'mockAsset', source: 'images/hero.dat' }); - if (url.endsWith('/missing.txt')) { - return { - ok: false, - status: 404, - statusText: 'Not Found', - } as Response; - } - - return { - ok: true, - status: 200, - statusText: 'OK', - } as Response; - }); + await loader.load({ heroA: hero, heroB: hero }); - let thrown: unknown; + expect(loader._peekResource(MockAssetType, 'heroA')).not.toBeNull(); + expect(loader._peekResource(MockAssetType, 'heroB')).not.toBeNull(); - try { - await loader.loadBundle('boot'); - } catch (error: unknown) { - thrown = error; - } + loader.unload(hero); - expect(thrown).toBeInstanceOf(BundleLoadError); + expect(loader._peekResource(MockAssetType, 'heroA')).toBeNull(); + expect(loader._peekResource(MockAssetType, 'heroB')).toBeNull(); + }); - const bundleError = thrown as BundleLoadError; + 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, + ); - expect(bundleError.bundle).toBe('boot'); - expect(bundleError.failures).toHaveLength(1); - expect(bundleError.failures[0].alias).toBe('missing'); - expect(bundleError.failures[0].type).toBe(TextAsset); - expect(bundleError.failures[0].error).toBeInstanceOf(Error); - }); + const container = new Assets({ + hero: { kind: 'texture', source: 'hero.png' }, + logo: { kind: 'texture', source: 'logo.png' }, + }); - test('successful assets remain cached after partial bundle failure', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); + await loader.load(container); - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'ok', path: 'ok.txt' }, - { type: TextAsset, alias: 'missing', path: 'missing.txt' }, - ], - }, - }), - ); + expect(loader._peekResource(Texture, 'hero.png')).not.toBeNull(); + expect(loader._peekResource(Texture, 'logo.png')).not.toBeNull(); + expect((container.hero as Texture).loadState).toBe('ready'); - global.fetch = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + loader.unload(container); - if (url.endsWith('/missing.txt')) { - return { - ok: false, - status: 500, - statusText: 'Server Error', - } as Response; - } - - return { - ok: true, - status: 200, - statusText: 'OK', - } as Response; - }); + // Last claim released → payload evicted; the leaves heal back to 'loading'. + expect(loader._peekResource(Texture, 'hero.png')).toBeNull(); + expect(loader._peekResource(Texture, 'logo.png')).toBeNull(); + expect((container.hero as Texture).loadState).toBe('loading'); - await expect(loader.loadBundle('boot')).rejects.toBeInstanceOf(BundleLoadError); - expect(loader.has(TextAsset, 'ok')).toBe(true); - expect(loader.has(TextAsset, 'missing')).toBe(false); + vi.unstubAllGlobals(); }); - test('bundle progress callback and signal report expected totals and final completion', async () => { - const factory = new MockTextFactory(); + test('aliases are cleared from tracking when underlying asset unloads', async () => { const loader = new Loader({ basePath: '/' }); - const callbackProgress: Array<[number, number]> = []; - const signalProgress: Array<[string, number, number]> = []; - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'cached', path: 'cached.txt' }, - { type: TextAsset, alias: 'fresh', path: 'fresh.txt' }, - ], - }, - }), - ); - loader.onBundleProgress.add((name, loaded, total) => { - signalProgress.push([name, loaded, total]); - }); - mockFetch(); - - await loader.load(TextAsset, { cached: 'cached.txt' }); - await loader.loadBundle('boot', { - onProgress: (loaded, total) => { - callbackProgress.push([loaded, total]); - }, - }); - - expect(callbackProgress).toContainEqual([1, 2]); - expect(callbackProgress[callbackProgress.length - 1]).toEqual([2, 2]); - expect(signalProgress).toContainEqual(['boot', 1, 2]); - expect(signalProgress[signalProgress.length - 1]).toEqual(['boot', 2, 2]); - }); - - test('background bundle load works', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'a', path: 'a.txt' }, - { type: TextAsset, alias: 'b', path: 'b.txt' }, - ], - }, - }), - ); - mockFetch(); - - await expect(loader.loadBundle('boot', { background: true })).resolves.toBeUndefined(); - expect(loader.has(TextAsset, 'a')).toBe(true); - expect(loader.has(TextAsset, 'b')).toBe(true); - }); - - test('foreground load after background bundle queue uses normal priority boost behavior', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - const firstFetch = createDeferred(); - const boostedFetch = createDeferred(); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'first', path: 'first.txt' }, - { type: TextAsset, alias: 'boosted', path: 'boosted.txt' }, - ], - }, - }), - ); - - global.fetch = vi.fn((input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/first.txt')) { - return firstFetch.promise; - } - - if (url.endsWith('/boosted.txt')) { - return boostedFetch.promise; - } - - throw new Error(`Unexpected fetch url: ${url}`); - }); - - const bundlePromise = loader.loadBundle('boot', { background: true }); - let bundleResolved = false; - - bundlePromise.then(() => { - bundleResolved = true; - }); - - const boostedPromise = loader.load(TextAsset, 'boosted'); - - boostedFetch.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response); - - await expect(boostedPromise).resolves.toBe('resource:fresh-source'); - await Promise.resolve(); - expect(bundleResolved).toBe(false); - - firstFetch.resolve({ - ok: true, - status: 200, - statusText: 'OK', - } as Response); - - await expect(bundlePromise).resolves.toBeUndefined(); - }); - - test('hasBundle() is false for unknown bundle and true after full successful load', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [{ type: TextAsset, alias: 'hero', path: 'hero.txt' }], - }, - }), - ); - mockFetch(); - - expect(loader.hasBundle('missing')).toBe(false); - expect(loader.hasBundle('boot')).toBe(false); - - await loader.loadBundle('boot'); - - expect(loader.hasBundle('boot')).toBe(true); - }); - - test('hasBundle() stays false after partial bundle failure', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'ok', path: 'ok.txt' }, - { type: TextAsset, alias: 'bad', path: 'bad.txt' }, - ], - }, - }), - ); - - global.fetch = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/bad.txt')) { - return { - ok: false, - status: 404, - statusText: 'Not Found', - } as Response; - } - - return { - ok: true, - status: 200, - statusText: 'OK', - } as Response; - }); - - await expect(loader.loadBundle('boot')).rejects.toBeInstanceOf(BundleLoadError); - expect(loader.hasBundle('boot')).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// New Asset / Assets / LoadingQueue stabilisation tests -// ───────────────────────────────────────────────────────────────────────────── - -class MockAssetFactory implements AssetFactory { - public readonly storageName = 'mockAsset'; - public readonly process = vi.fn(async (_response: Response): Promise => 'raw'); - public readonly create = vi.fn(async (source: string): Promise => `loaded:${source}`); - public destroy(): void {} -} - -class MockAssetType {} - -describe('LoadingQueue progress tracking', () => { - test('progress is updated to failed when asset type is unknown', async () => { - const loader = new Loader(); - // 'mockAsset' is in AssetDefinitions (via declaration merge above) but we - // deliberately do NOT call loader.registerAssetType() so that the runtime - // has no constructor registered for it → "no constructor" rejection path. - const asset = new Asset({ type: 'mockAsset', source: 'test.dat' }); - - const queue = loader.load(asset); - let lastProgress = queue.progress; - - queue.onProgress.add(p => { - lastProgress = p; - }); - - await expect(queue).rejects.toThrow('No constructor registered'); - // Progress must have settled — pending must be 0 - expect(lastProgress.pending).toBe(0); - expect(lastProgress.failed).toBe(1); - expect(lastProgress.loaded).toBe(0); - }); - - test('progress counts both successful and failed items in a map load', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); + bindMockAsset(loader); mockFetch(); - const goodAsset = new Asset({ type: 'mockAsset', source: 'good.dat' }); - // force the factory to fail on the second call - factory.create.mockImplementationOnce(async () => 'ok'); - factory.create.mockImplementationOnce(async () => { - throw new Error('bad'); - }); - - const badAsset = new Asset({ type: 'mockAsset', source: 'bad.dat' }); - - const queue = loader.load({ good: goodAsset, bad: badAsset }); - let lastProgress = queue.progress; - - queue.onProgress.add(p => { - lastProgress = p; - }); - - await expect(queue).rejects.toThrow(); - expect(lastProgress.total).toBe(2); - expect(lastProgress.pending).toBe(0); - expect(lastProgress.loaded + lastProgress.failed).toBe(2); - }); - - // Shared mockFetch helper (redeclare locally in scope) - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => '', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - afterEach(() => { - global.fetch = vi.fn(); - }); -}); - -describe('Asset / Assets identity and alias semantics', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'raw', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - test('same Asset under two aliases shares a single network fetch', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); - - const hero = new Asset({ type: 'mockAsset', source: 'images/hero.dat' }); - - await loader.load({ heroA: hero, heroB: hero }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(loader.has(MockAssetType, 'heroA')).toBe(true); - expect(loader.has(MockAssetType, 'heroB')).toBe(true); - }); - - test('get() resolves both aliases after multi-alias load', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); - - const hero = new Asset({ type: 'mockAsset', source: 'images/hero.dat' }); - - await loader.load({ heroA: hero, heroB: hero }); - - expect(loader.get(MockAssetType, 'heroA')).toBe(loader.get(MockAssetType, 'heroB')); - }); - - test('unload(asset) removes asset loaded by source-as-alias (single Asset load)', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); - - const hero = new Asset({ type: 'mockAsset', source: 'images/hero.dat' }); - - await loader.load(hero); - - expect(loader.has(MockAssetType, 'images/hero.dat')).toBe(true); - - loader.unload(hero); - - expect(loader.has(MockAssetType, 'images/hero.dat')).toBe(false); - }); - - test('unload(asset) removes all aliases after keyed-map load', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); - - const hero = new Asset({ type: 'mockAsset', source: 'images/hero.dat' }); - - await loader.load({ heroA: hero, heroB: hero }); - - expect(loader.has(MockAssetType, 'heroA')).toBe(true); - expect(loader.has(MockAssetType, 'heroB')).toBe(true); - - loader.unload(hero); - - expect(loader.has(MockAssetType, 'heroA')).toBe(false); - 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(); - - const container = new Assets({ - hero: { type: 'mockAsset', source: 'hero.dat' }, - logo: { type: 'mockAsset', source: 'logo.dat' }, - }); - - await loader.load(container); - - expect(loader.has(MockAssetType, 'hero')).toBe(true); - expect(loader.has(MockAssetType, 'logo')).toBe(true); - - loader.unload(container); - - expect(loader.has(MockAssetType, 'hero')).toBe(false); - expect(loader.has(MockAssetType, 'logo')).toBe(false); - }); - - test('aliases are cleared from tracking when underlying asset unloads', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - mockFetch(); - - const hero = new Asset({ type: 'mockAsset', source: 'hero.dat' }); + const hero = new Asset({ kind: 'mockAsset', source: 'hero.dat' }); await loader.load({ a: hero, b: hero, c: hero }); @@ -1155,8 +630,8 @@ describe('Asset / Assets identity and alias semantics', () => { // Re-load under a single alias — should work cleanly after unload await loader.load({ a: hero }); - expect(loader.has(MockAssetType, 'a')).toBe(true); - expect(loader.has(MockAssetType, 'b')).toBe(false); + expect(loader._peekResource(MockAssetType, 'a')).not.toBeNull(); + expect(loader._peekResource(MockAssetType, 'b')).toBeNull(); }); }); @@ -1172,54 +647,15 @@ describe('Assets reserved "entries" key', () => { test('does not throw for a normal asset name', () => { expect(() => { new Assets({ - logo: { type: 'mockAsset', source: '/logo.dat' }, + logo: { kind: 'texture', source: '/logo.png' }, }); }).not.toThrow(); }); }); -describe('registerAssetType() handler form — full config forwarding', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - test('handler receives config.source and extra config fields', async () => { - const loader = new Loader({ basePath: '/' }); - const receivedConfigs: unknown[] = []; - - loader.registerAssetType('richAsset', { - load: async config => { - receivedConfigs.push(config); - return `${config.source}:${config.format}`; - }, - }); - - const asset = new Asset({ type: 'richAsset', source: 'level.json', format: 'tiled' }); - const result = await loader.load(asset); - - expect(receivedConfigs).toHaveLength(1); - expect(receivedConfigs[0]).toMatchObject({ source: 'level.json', format: 'tiled' }); - expect(result).toBe('level.json:tiled'); - }); - - test('handler result is stored and retrievable via the alias', async () => { - const loader = new Loader({ basePath: '/' }); - - loader.registerAssetType('richAsset', { - load: async config => `parsed:${config.source}`, - }); - - await loader.load({ map: new Asset({ type: 'richAsset', source: 'level.json', format: 'tiled' }) }); - - // The asset is stored under the alias 'map', not under 'level.json' - const stored = loader.get(loader['_assetTypeMap'].get('richAsset')!, 'map'); - expect(stored).toBe('parsed:level.json'); - }); -}); +describe('bindAsset() handler — cache-aware AssetLoaderContext', () => { + class RichAsset {} -describe('registerAssetType() handler form — cache-aware context', () => { const originalFetch = global.fetch; afterEach(() => { @@ -1245,14 +681,17 @@ describe('registerAssetType() handler form — cache-aware context', () => { const loader = new Loader({ basePath: '/' }); let capturedKey = ''; - loader.registerAssetType('richAsset', { - load: async (_config, ctx) => { - capturedKey = ctx.identityKey; - return 'ok'; + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async (_request, ctx) => { + capturedKey = ctx.identityKey; + return 'ok'; + }, }, - }); + ); - await loader.load(new Asset({ type: 'richAsset', source: 'a.json', format: 'x' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'a.json', format: 'x' })); expect(capturedKey).toMatch(/^id:\d+:/); }); @@ -1260,11 +699,9 @@ describe('registerAssetType() handler form — cache-aware context', () => { mockFetchText('hello world'); const loader = new Loader({ basePath: '/assets/' }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => ctx.fetchText(config.source), - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async (request, ctx) => ctx.fetchText(request.source) }); - const result = await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + const result = await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); expect(result).toBe('hello world'); expect(global.fetch).toHaveBeenCalledWith('/assets/file.txt', expect.anything()); }); @@ -1273,16 +710,14 @@ describe('registerAssetType() handler form — cache-aware context', () => { mockFetchText('cached content'); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => ctx.fetchText(config.source), - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async (request, ctx) => ctx.fetchText(request.source) }); // First load — populates in-memory result - await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); // Reset the mock so we can check if it was called during the second load (global.fetch as MockInstance).mockClear(); // Second load — same asset, should be served from _resources (no new fetch call) - await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); expect(global.fetch).not.toHaveBeenCalled(); }); @@ -1290,14 +725,17 @@ describe('registerAssetType() handler form — cache-aware context', () => { mockFetchText('{"value":42}'); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => { - const data = await ctx.fetchJson<{ value: number }>(config.source); - return String(data.value); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async (request, ctx) => { + const data = await ctx.fetchJson<{ value: number }>(request.source); + return String(data.value); + }, }, - }); + ); - const result = await loader.load(new Asset({ type: 'richAsset', source: 'data.json', format: 'json' })); + const result = await loader.load(new Asset({ kind: 'richAsset', source: 'data.json', format: 'json' })); expect(result).toBe('42'); }); @@ -1305,14 +743,17 @@ describe('registerAssetType() handler form — cache-aware context', () => { mockFetchText('binary'); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => { - const buf = await ctx.fetchArrayBuffer(config.source); - return String(buf.byteLength); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async (request, ctx) => { + const buf = await ctx.fetchArrayBuffer(request.source); + return String(buf.byteLength); + }, }, - }); + ); - const result = await loader.load(new Asset({ type: 'richAsset', source: 'data.bin', format: 'bin' })); + const result = await loader.load(new Asset({ kind: 'richAsset', source: 'data.bin', format: 'bin' })); expect(Number(result)).toBeGreaterThan(0); }); @@ -1320,16 +761,19 @@ describe('registerAssetType() handler form — cache-aware context', () => { const loader = new Loader({ basePath: '/' }); const loadOrder: string[] = []; - loader.registerAssetType('richAsset', { - getIdentityKey: config => `${config.source}:${config.format}`, - load: async config => { - loadOrder.push(config.format); - return `result:${config.format}`; + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + getIdentityKey: request => `${request.source}:${request.options?.format}`, + load: async request => { + loadOrder.push(request.options!.format); + return `result:${request.options!.format}`; + }, }, - }); + ); - const tmx = new Asset({ type: 'richAsset', source: 'map.tmx', format: 'tmx' }); - const json = new Asset({ type: 'richAsset', source: 'map.tmx', format: 'tiled-json' }); + const tmx = new Asset({ kind: 'richAsset', source: 'map.tmx', format: 'tmx' }); + const json = new Asset({ kind: 'richAsset', source: 'map.tmx', format: 'tiled-json' }); const [resTmx, resJson] = await Promise.all([loader.load(tmx), loader.load(json)]); @@ -1344,15 +788,18 @@ describe('registerAssetType() handler form — cache-aware context', () => { let callCount = 0; const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - load: async config => { - callCount++; - return `ok:${config.source}`; + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async request => { + callCount++; + return `ok:${request.source}`; + }, }, - }); + ); - const a1 = new Asset({ type: 'richAsset', source: 'shared.dat', format: 'x' }); - const a2 = new Asset({ type: 'richAsset', source: 'shared.dat', format: 'x' }); + const a1 = new Asset({ kind: 'richAsset', source: 'shared.dat', format: 'x' }); + const a2 = new Asset({ kind: 'richAsset', source: 'shared.dat', format: 'x' }); const [r1, r2] = await Promise.all([loader.load(a1), loader.load(a2)]); @@ -1362,67 +809,9 @@ describe('registerAssetType() handler form — cache-aware context', () => { }); }); -describe('load(Type, { alias: BatchValue }) — extended legacy batch API', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'raw', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - test('accepts a config object value with source and extra fields', async () => { - const factory = new MockAssetFactory(); - const receivedOptions: unknown[] = []; - - factory.create.mockImplementation(async (source: string, options?: unknown) => { - receivedOptions.push(options); - return `loaded:${source}`; - }); - - const loader = new Loader({ basePath: '/' }); - - loader.register(MockAssetType, factory as AssetFactory); - mockFetch(); - - const result = await loader.load(MockAssetType, { - heroA: 'images/hero.png', - heroB: { source: 'images/hero-alt.png', scale: 2, format: 'png' }, - }); - - expect(result.heroA).toBe('loaded:raw'); - expect(result.heroB).toBe('loaded:raw'); - // heroB's extra fields must be forwarded to factory.create as options - expect(receivedOptions).toContainEqual(expect.objectContaining({ source: 'images/hero-alt.png', scale: 2, format: 'png' })); - }); - - test('string values in batch continue to work unchanged', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(MockAssetType, factory as AssetFactory); - mockFetch(); - - const result = await loader.load(MockAssetType, { heroA: 'images/hero.png' }); - - expect(result.heroA).toBe('loaded:raw'); - expect(global.fetch).toHaveBeenCalledWith('/images/hero.png', expect.anything()); - }); -}); - describe('handler context.fetch* — IDB store names (Fix 1 regression)', () => { + class RichAsset {} + const originalFetch = global.fetch; afterEach(() => { @@ -1463,11 +852,9 @@ describe('handler context.fetch* — IDB store names (Fix 1 regression)', () => const { store, saves } = makeMockStore(); const loader = new Loader({ basePath: '/', cache: store }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => ctx.fetchText(config.source), - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async (request, ctx) => ctx.fetchText(request.source) }); - await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); expect(saves).toContainEqual({ storageName: '__ctx_text', key: 'file.txt' }); }); @@ -1477,14 +864,17 @@ describe('handler context.fetch* — IDB store names (Fix 1 regression)', () => const { store, saves } = makeMockStore(); const loader = new Loader({ basePath: '/', cache: store }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => { - const data = await ctx.fetchJson<{ n: number }>(config.source); - return String(data.n); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async (request, ctx) => { + const data = await ctx.fetchJson<{ n: number }>(request.source); + return String(data.n); + }, }, - }); + ); - await loader.load(new Asset({ type: 'richAsset', source: 'data.json', format: 'json' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'data.json', format: 'json' })); expect(saves).toContainEqual({ storageName: '__ctx_json', key: 'data.json' }); }); @@ -1494,14 +884,17 @@ describe('handler context.fetch* — IDB store names (Fix 1 regression)', () => const { store, saves } = makeMockStore(); const loader = new Loader({ basePath: '/', cache: store }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => { - const buf = await ctx.fetchArrayBuffer(config.source); - return String(buf.byteLength); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async (request, ctx) => { + const buf = await ctx.fetchArrayBuffer(request.source); + return String(buf.byteLength); + }, }, - }); + ); - await loader.load(new Asset({ type: 'richAsset', source: 'data.bin', format: 'bin' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'data.bin', format: 'bin' })); expect(saves).toContainEqual({ storageName: '__ctx_binary', key: 'data.bin' }); }); @@ -1525,78 +918,82 @@ describe('handler context.fetch* — IDB store names (Fix 1 regression)', () => }; const loader = new Loader({ basePath: '/', cache: store }); - loader.registerAssetType('richAsset', { - load: async (config, ctx) => ctx.fetchText(config.source), - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async (request, ctx) => ctx.fetchText(request.source) }); // First load — populates _resources; context.fetchText goes to network, store has no entry yet - await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); // Second load — served from _resources, handler not called, store not consulted (global.fetch as MockInstance).mockClear(); - await loader.load(new Asset({ type: 'richAsset', source: 'file.txt', format: 'txt' })); + await loader.load(new Asset({ kind: 'richAsset', source: 'file.txt', format: 'txt' })); expect(global.fetch).not.toHaveBeenCalled(); }); }); describe('unload(asset) + getIdentityKey — identity discrimination (Fix 2 regression)', () => { + class RichAsset {} + test('unload(asset) removes only aliases for the matching getIdentityKey identity', async () => { const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - getIdentityKey: config => `${config.source}:${config.format}`, - load: async config => `result:${config.format}`, - }); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + getIdentityKey: request => `${request.source}:${request.options?.format}`, + load: async request => `result:${request.options!.format}`, + }, + ); - const tmxMap = new Asset({ type: 'richAsset', source: 'map.dat', format: 'tmx' }); - const rpgMap = new Asset({ type: 'richAsset', source: 'map.dat', format: 'rpg-maker' }); + const tmxMap = new Asset({ kind: 'richAsset', source: 'map.dat', format: 'tmx' }); + const rpgMap = new Asset({ kind: 'richAsset', source: 'map.dat', format: 'rpg-maker' }); await loader.load({ tmxA: tmxMap, tmxB: tmxMap, rpgA: rpgMap }); const ctor = loader['_assetTypeMap'].get('richAsset')!; - expect(loader.has(ctor, 'tmxA')).toBe(true); - expect(loader.has(ctor, 'tmxB')).toBe(true); - expect(loader.has(ctor, 'rpgA')).toBe(true); + expect(loader._peekResource(ctor, 'tmxA')).not.toBeNull(); + expect(loader._peekResource(ctor, 'tmxB')).not.toBeNull(); + expect(loader._peekResource(ctor, 'rpgA')).not.toBeNull(); loader.unload(tmxMap); - expect(loader.has(ctor, 'tmxA')).toBe(false); - expect(loader.has(ctor, 'tmxB')).toBe(false); - expect(loader.has(ctor, 'rpgA')).toBe(true); // unaffected — different identity + expect(loader._peekResource(ctor, 'tmxA')).toBeNull(); + expect(loader._peekResource(ctor, 'tmxB')).toBeNull(); + expect(loader._peekResource(ctor, 'rpgA')).not.toBeNull(); // unaffected — different identity }); test('unload(asset) without getIdentityKey still removes all source-based aliases', async () => { const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - load: async config => `result:${config.source}`, - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async request => `result:${request.source}` }); - const asset = new Asset({ type: 'richAsset', source: 'shared.dat', format: 'x' }); + const asset = new Asset({ kind: 'richAsset', source: 'shared.dat', format: 'x' }); await loader.load({ a: asset, b: asset }); const ctor = loader['_assetTypeMap'].get('richAsset')!; - expect(loader.has(ctor, 'a')).toBe(true); - expect(loader.has(ctor, 'b')).toBe(true); + expect(loader._peekResource(ctor, 'a')).not.toBeNull(); + expect(loader._peekResource(ctor, 'b')).not.toBeNull(); loader.unload(asset); - expect(loader.has(ctor, 'a')).toBe(false); - expect(loader.has(ctor, 'b')).toBe(false); + expect(loader._peekResource(ctor, 'a')).toBeNull(); + expect(loader._peekResource(ctor, 'b')).toBeNull(); }); test('unload(asset) with getIdentityKey does not affect a different format identity', async () => { const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', { - getIdentityKey: config => `${config.source}:${config.format}`, - load: async config => `result:${config.format}`, - }); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + getIdentityKey: request => `${request.source}:${request.options?.format}`, + load: async request => `result:${request.options!.format}`, + }, + ); - const tmxMap = new Asset({ type: 'richAsset', source: 'map.dat', format: 'tmx' }); - const rpgMap = new Asset({ type: 'richAsset', source: 'map.dat', format: 'rpg-maker' }); + const tmxMap = new Asset({ kind: 'richAsset', source: 'map.dat', format: 'tmx' }); + const rpgMap = new Asset({ kind: 'richAsset', source: 'map.dat', format: 'rpg-maker' }); await loader.load({ tmxA: tmxMap, rpgA: rpgMap }); @@ -1604,259 +1001,34 @@ describe('unload(asset) + getIdentityKey — identity discrimination (Fix 2 regr loader.unload(rpgMap); - expect(loader.has(ctor, 'tmxA')).toBe(true); // untouched - expect(loader.has(ctor, 'rpgA')).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — registerExtension() + extension-based load('path.ext') -// ───────────────────────────────────────────────────────────────────────────── - -describe('registerExtension() + extension-based load(path)', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'raw', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - test('load(path) infers the type from a registered extension', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerExtension('txt', TextAsset); - mockFetch(); - - const result = await loader.load('notes.txt'); - - expect(result).toBe('resource:fresh-source'); - }); - - test('registerExtension() normalizes a leading dot and casing', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerExtension('.TXT', TextAsset); - mockFetch(); - - await expect(loader.load('notes.txt')).resolves.toBe('resource:fresh-source'); - }); - - test('FontAsset infers the "family" option from the filename when loaded by path', async () => { - const receivedOptions: unknown[] = []; - const factory: AssetFactory = { - storageName: 'font', - process: vi.fn(async () => 'raw'), - create: vi.fn(async (_source: unknown, options?: unknown) => { - receivedOptions.push(options); - return {} as FontFace; - }), - destroy: vi.fn(), - }; - const loader = new Loader({ basePath: '/' }); - - loader.register(FontAsset, factory); - loader.registerExtension('woff2', FontAsset); - mockFetch(); - - await loader.load('fonts/Roboto.woff2'); - - expect(receivedOptions[0]).toMatchObject({ family: 'Roboto' }); - }); - - test('throws a clear error for an unregistered extension', () => { - const loader = new Loader({ basePath: '/' }); - - expect(() => loader.load('mystery.foo')).toThrow(/no type registered for any extension of "mystery.foo"/); - }); - - test('throws a clear error for a path with no extension at all', () => { - const loader = new Loader({ basePath: '/' }); - - expect(() => loader.load('no-extension-here')).toThrow(/no type registered for any extension of "no-extension-here"/); - }); - - test('rejects when the extension-inferred load fails', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerExtension('txt', TextAsset); - mockFetch(); - factory.create.mockImplementationOnce(async () => { - throw new Error('extension-load-broken'); - }); - - await expect(loader.load('broken.txt')).rejects.toThrow('extension-load-broken'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — legacy batch load() failure branches (array / record forms) -// ───────────────────────────────────────────────────────────────────────────── - -describe('load(Type, ...) legacy batch forms — per-item failure branches', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'raw', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - test('load(Type, [paths]) rejects when one array item fails', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - mockFetch(); - factory.create.mockImplementationOnce(async () => 'ok'); - factory.create.mockImplementationOnce(async () => { - throw new Error('array-item-broken'); - }); - - await expect(loader.load(TextAsset, ['good-array.txt', 'bad-array.txt'])).rejects.toThrow('array-item-broken'); - }); - - test('load(Type, { alias: BatchValue }) rejects when one record item fails', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - mockFetch(); - factory.create.mockImplementationOnce(async () => 'ok'); - factory.create.mockImplementationOnce(async () => { - throw new Error('record-item-broken'); - }); - - await expect(loader.load(TextAsset, { good: 'good-rec.txt', bad: 'bad-rec.txt' })).rejects.toThrow('record-item-broken'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — backgroundLoad() re-scan skip branches, loadAll() early exit, -// setConcurrency() -// ───────────────────────────────────────────────────────────────────────────── - -describe('backgroundLoad() re-scan skip branches', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn( - async (): Promise => - ({ - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'raw', - json: async () => ({}), - arrayBuffer: async () => new ArrayBuffer(0), - }) as unknown as Response, - ); - } - - test('skips manifest entries that are already resolved', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest(defineAssetManifest({ bundles: { seed: [{ type: TextAsset, alias: 'a', path: 'a.txt' }] } })); - mockFetch(); - - await loader.load(TextAsset, 'a'); - (global.fetch as MockInstance).mockClear(); - - loader.backgroundLoad(); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - test('skips manifest entries that are already in flight', () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - const deferred = createDeferred(); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest(defineAssetManifest({ bundles: { seed: [{ type: TextAsset, alias: 'a', path: 'a.txt' }] } })); - global.fetch = vi.fn((): Promise => deferred.promise); - - void loader.load(TextAsset, 'a'); - loader.backgroundLoad(); - - expect(global.fetch).toHaveBeenCalledTimes(1); - - deferred.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); + expect(loader._peekResource(ctor, 'tmxA')).not.toBeNull(); // untouched + expect(loader._peekResource(ctor, 'rpgA')).toBeNull(); }); }); -describe('loadAll() early exit', () => { +describe('awaitBackground() early exit', () => { test('resolves immediately when nothing is queued', async () => { const loader = new Loader({ basePath: '/' }); const fetchSpy = vi.fn(); global.fetch = fetchSpy as unknown as typeof fetch; - await expect(loader.loadAll()).resolves.toBeUndefined(); + await expect(loader.awaitBackground()).resolves.toBeUndefined(); expect(fetchSpy).not.toHaveBeenCalled(); }); }); -describe('setConcurrency()', () => { - test('is chainable and limits how many background fetches start immediately', () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 6 }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - seed: [ - { type: TextAsset, alias: 'a', path: 'a.txt' }, - { type: TextAsset, alias: 'b', path: 'b.txt' }, - { type: TextAsset, alias: 'c', path: 'c.txt' }, - ], - }, - }), - ); - - expect(loader.setConcurrency(1)).toBe(loader); +describe('setConcurrency()', () => { + test('is chainable and limits how many background fetches start immediately', () => { + const loader = createCoreLoader({ basePath: '/', concurrency: 6 }); const deferred = createDeferred(); global.fetch = vi.fn((): Promise => deferred.promise); - loader.backgroundLoad(); + expect(loader.setConcurrency(1)).toBe(loader); + + // Three background-adopted sources, but concurrency 1 → only one fetch starts. + loader.load(Assets.from({ a: 'a.png', b: 'b.png', c: 'c.png' }), { background: true }); expect(global.fetch).toHaveBeenCalledTimes(1); }); @@ -1878,13 +1050,23 @@ describe('keyFor()', () => { } test('returns the type + first alias for a loaded object resource', async () => { - const factory = new DummyFactory(); const loader = new Loader({ basePath: '/' }); - loader.register(DummyAsset, factory); + materializeAssetBindings(loader, [ + defineAsset({ + type: DummyAsset, + kind: 'dummyAsset', + isValue: false, + create: () => ({ + async load(request, ctx) { + return new DummyAsset(await ctx.fetchText(request.source)); + }, + }), + }), + ]); mockFetch(); - const result = await loader.load(DummyAsset, 'thing.dat'); + const result = await loader.load(new Asset({ kind: 'dummyAsset', source: 'thing.dat' })); expect(loader.keyFor(result)).toEqual({ type: DummyAsset, source: 'thing.dat' }); }); @@ -1903,39 +1085,39 @@ describe('keyFor()', () => { describe('unload() edge cases', () => { test('unload(asset) is a no-op when the asset type was never registered', () => { const loader = new Loader({ basePath: '/' }); - const orphan = new Asset({ type: 'mockAsset', source: 'x.dat' }); + const orphan = new Asset({ kind: 'mockAsset', source: 'x.dat' }); expect(() => loader.unload(orphan)).not.toThrow(); }); test('unload(asset) falls back to source-as-alias when the asset was never loaded', () => { - const factory = new MockAssetFactory(); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); + loader.bindAsset({ type: MockAssetType, typeNames: ['mockAsset'] }, { load: async request => `loaded:${request.source}` }); - const neverLoaded = new Asset({ type: 'mockAsset', source: 'never.dat' }); + const neverLoaded = new Asset({ kind: 'mockAsset', source: 'never.dat' }); expect(() => loader.unload(neverLoaded)).not.toThrow(); - expect(loader.has(MockAssetType, 'never.dat')).toBe(false); + expect(loader._peekResource(MockAssetType, 'never.dat')).toBeNull(); }); - 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: { kind: '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: { kind: 'texture', source: 'never.png' } }); expect(() => loader.unload(container)).not.toThrow(); + expect(loader._peekResource(Texture, 'never.png')).toBeNull(); }); }); @@ -1956,7 +1138,7 @@ describe('basePath / fetchOptions property accessors', () => { loader.register(TextAsset, factory as AssetFactory); global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); expect(global.fetch).toHaveBeenCalledWith('/b/demo.txt', expect.anything()); }); @@ -1973,7 +1155,7 @@ describe('basePath / fetchOptions property accessors', () => { loader.register(TextAsset, factory as AssetFactory); global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); expect(global.fetch).toHaveBeenCalledWith('/demo.txt', { mode: 'no-cors' }); }); @@ -1991,7 +1173,7 @@ describe('absolute URL passthrough', () => { loader.register(TextAsset, factory as AssetFactory); global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - await loader.load(TextAsset, 'https://cdn.example.com/x.txt'); + await loader.load('https://cdn.example.com/x.txt'); expect(global.fetch).toHaveBeenCalledWith('https://cdn.example.com/x.txt', expect.anything()); }); @@ -2011,23 +1193,19 @@ describe('hasLoadable() / hasAssetType() / hasExtension()', () => { expect(loader.hasLoadable(ProbeAsset)).toBe(true); expect(loader.hasAssetType('probeType')).toBe(false); - loader.registerAssetType('probeType', ProbeAsset); - expect(loader.hasAssetType('probeType')).toBe(true); - expect(loader.hasExtension('probe')).toBe(false); - loader.registerExtension('.PROBE', ProbeAsset); + loader.bindAsset({ type: ProbeAsset, typeNames: ['probeType'], extensions: ['PROBE'] }, { load: async () => new ProbeAsset() }); + expect(loader.hasAssetType('probeType')).toBe(true); expect(loader.hasExtension('probe')).toBe(true); expect(loader.hasExtension('.probe')).toBe(true); }); - test('hasLoadable() is true for a handler-based registerAssetType() registration', () => { + test('hasLoadable() is true for a bindAsset() handler registration', () => { const loader = new Loader({ basePath: '/' }); class HandlerAsset {} expect(loader.hasLoadable(HandlerAsset)).toBe(false); - loader.registerAssetType('handlerType', { - load: async () => 'ok', - }); + loader.bindAsset({ type: HandlerAsset, typeNames: ['handlerType'] }, { load: async () => 'ok' }); const ctor = loader['_assetTypeMap'].get('handlerType')!; expect(loader.hasLoadable(ctor)).toBe(true); @@ -2050,32 +1228,43 @@ describe('bindAsset() — direct handler binding', () => { global.fetch = originalFetch; }); - test('binds by type token: load(Type, path) resolves via the handler', async () => { + test('binds by kind: load(Asset) resolves via the handler', async () => { const loader = new Loader({ basePath: '/' }); - loader.bindAsset({ type: BoundAsset }, { load: async request => new BoundAsset(request.source) }); + materializeAssetBindings(loader, [ + defineAsset({ + type: BoundAsset, + kind: 'boundAsset', + isValue: false, + create: () => ({ load: async request => new BoundAsset(request.source) }), + }), + ]); - const result = await loader.load(BoundAsset, 'thing.bin'); + const result = (await loader.load(new Asset({ kind: 'boundAsset', source: 'thing.bin' }))) as BoundAsset; expect(result).toBeInstanceOf(BoundAsset); expect(result.value).toBe('thing.bin'); }); - test('load(Type, path, options) forwards an options object into the handler request', async () => { + test('extra config fields are forwarded as an options object into the handler request', async () => { const loader = new Loader({ basePath: '/' }); let receivedConfig: unknown; - loader.bindAsset( - { type: BoundAsset }, - { - load: async request => { - receivedConfig = request; - return new BoundAsset(request.source); - }, - }, - ); + materializeAssetBindings(loader, [ + defineAsset({ + type: BoundAsset, + kind: 'boundAsset', + isValue: false, + create: () => ({ + load: async request => { + receivedConfig = request; + return new BoundAsset(request.source); + }, + }), + }), + ]); - await loader.load(BoundAsset, 'thing.bin', { scale: 3 }); + await loader.load(new Asset({ kind: 'boundAsset', source: 'thing.bin', scale: 3 })); expect(receivedConfig).toMatchObject({ source: 'thing.bin', options: { scale: 3 } }); }); @@ -2085,7 +1274,7 @@ describe('bindAsset() — direct handler binding', () => { loader.bindAsset({ type: BoundAsset, typeNames: ['boundAsset'] }, { load: async request => new BoundAsset(request.source) }); - const result = await loader.load(new Asset({ type: 'boundAsset', source: 'level.dat' })); + const result = await loader.load(new Asset({ kind: 'boundAsset', source: 'level.dat' })); expect(result).toBeInstanceOf(BoundAsset); }); @@ -2115,8 +1304,8 @@ describe('bindAsset() — direct handler binding', () => { }, ); - const a = new Asset({ type: 'boundAsset', source: 'shared.dat', scale: 2 }); - const b = new Asset({ type: 'boundAsset', source: 'shared.dat', scale: 2 }); + const a = new Asset({ kind: 'boundAsset', source: 'shared.dat', scale: 2 }); + const b = new Asset({ kind: 'boundAsset', source: 'shared.dat', scale: 2 }); await Promise.all([loader.load(a), loader.load(b)]); @@ -2219,9 +1408,9 @@ describe('loadContainer()', () => { const loader = createCoreLoaderLocal(); await loader.loadContainer('assets/pack.exoa'); - expect(loader.get(Json, 'level').value).toEqual({ score: 42 }); - expect(loader.get(TextAsset, 'readme').value).toBe('hello world'); - expect(new Uint8Array(loader.get(BinaryAsset, 'blob').value)).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(loader.get(Asset.kind('json', 'level')).value).toEqual({ score: 42 }); + expect(loader.get(Asset.kind('text', 'readme')).value).toBe('hello world'); + expect(new Uint8Array(loader.get(Asset.kind('binary', 'blob')).value)).toEqual(new Uint8Array([1, 2, 3, 4])); }); test('throws on an unknown asset type and stores nothing', async () => { @@ -2233,11 +1422,14 @@ describe('loadContainer()', () => { await expect(loader.loadContainer('x.exoa')).rejects.toThrow(/unknown asset type "nonsense"/); }); - test('uses a register()/registerAssetType-based factory when no createFromBytes handler is bound', async () => { + test('uses a register()-based factory when the bound handler has no createFromBytes', async () => { const factory = new DummyFactory(); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('dummy', DummyAsset, factory); + loader.register(DummyAsset, factory); + // A handler binds the 'dummy' type name but supplies no createFromBytes, so + // container injection falls back to the registered factory. + loader.bindAsset({ type: DummyAsset, typeNames: ['dummy'] }, { load: async request => new DummyAsset(request.source) }); const container = encodeContainer([{ alias: 'x', type: 'dummy', bytes: new TextEncoder().encode('raw-bytes') }]); mockContainerFetch(container); @@ -2251,7 +1443,7 @@ describe('loadContainer()', () => { class BareAsset {} const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('bare', BareAsset); + loader.bindAsset({ type: BareAsset, typeNames: ['bare'] }, { load: async () => new BareAsset() }); const container = encodeContainer([{ alias: 'x', type: 'bare', bytes: new Uint8Array([1]) }]); mockContainerFetch(container); @@ -2311,16 +1503,21 @@ describe('destroy()', () => { // ───────────────────────────────────────────────────────────────────────────── describe('handler load() rejection is wrapped with url + cause', () => { - test('wraps a thrown Error from a registerAssetType() handler', async () => { + class RichAsset {} + + test('wraps a thrown Error from a bindAsset() handler', async () => { const loader = new Loader({ basePath: '/assets/' }); - loader.registerAssetType('richAsset', { - load: async () => { - throw new Error('handler exploded'); + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async () => { + throw new Error('handler exploded'); + }, }, - }); + ); - const asset = new Asset({ type: 'richAsset', source: 'x.json', format: 'x' }); + const asset = new Asset({ kind: 'richAsset', source: 'x.json', format: 'x' }); const error: Error = await loader.load(asset).catch((e: unknown) => e as Error); expect(error.message).toMatch(/Failed to load "x\.json" from "\/assets\/x\.json": handler exploded/); @@ -2329,388 +1526,37 @@ describe('handler load() rejection is wrapped with url + cause', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — _loadSingleAsset non-handler branch: extra config fields -// forwarded as fetch options for a plain register()/registerAssetType() factory +// Coverage sweep — Asset config: extra config fields surface as handler options // ───────────────────────────────────────────────────────────────────────────── -describe('Asset-based load() without a handler — extra config fields as options', () => { +describe('Asset-based load() — extra config fields as handler options', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; }); - test('extra config fields are forwarded to factory.create() as options', async () => { - const factory = new MockAssetFactory(); + test('extra config fields are forwarded to the handler request options', async () => { const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('richAsset', MockAssetType as never, factory as AssetFactory); - global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - const receivedOptions: unknown[] = []; - factory.create.mockImplementation(async (source: string, options?: unknown) => { - receivedOptions.push(options); - return `loaded:${source}`; - }); - - const asset = new Asset({ type: 'richAsset', source: 'extra.dat', format: 'tiled' }); - await loader.load(asset); - - expect(receivedOptions[0]).toMatchObject({ format: 'tiled' }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — _describeType() anonymous-constructor fallback -// ───────────────────────────────────────────────────────────────────────────── - -describe('registerManifest() conflict message — anonymous constructor', () => { - test('falls back to "(anonymous type)" for an unnamed constructor', () => { - const loader = new Loader({ basePath: '/' }); - const Anon = class {}; - - Object.defineProperty(Anon, 'name', { value: '' }); - - loader.registerManifest( - defineAssetManifest({ - bundles: { boot: [{ type: Anon, alias: 'x', path: 'a.txt' }] }, - }), - ); - - expect(() => - loader.registerManifest( - defineAssetManifest({ - bundles: { other: [{ type: Anon, alias: 'x', path: 'b.txt' }] }, - }), - ), - ).toThrow('(anonymous type)'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — _areOptionsEquivalent() full branch matrix -// ───────────────────────────────────────────────────────────────────────────── - -describe('registerManifest() option-equivalence branch matrix', () => { - function registerTwice(firstOptions: unknown, secondOptions: unknown): () => void { - const loader = new Loader({ basePath: '/' }); - - loader.registerManifest( - defineAssetManifest({ - bundles: { boot: [{ type: TextAsset, alias: 'x', path: 'a.txt', options: firstOptions }] }, - }), - ); - - return () => - loader.registerManifest( - defineAssetManifest({ - bundles: { other: [{ type: TextAsset, alias: 'x', path: 'a.txt', options: secondOptions }] }, + materializeAssetBindings(loader, [ + defineAsset({ + type: MockAssetType, + kind: 'mockAsset', + isValue: false, + create: () => ({ + async load(request) { + receivedOptions.push(request.options); + return `loaded:${request.source}`; + }, }), - ); - } - - test('rejects when one side has options and the other has none (typeof mismatch)', () => { - expect(registerTwice(undefined, {})).toThrow('Conflicting asset definition'); - }); - - test('rejects when one options value is null and the other a non-null object', () => { - expect(registerTwice(null, {})).toThrow('Conflicting asset definition'); - }); - - test('rejects when both options are non-object primitives with different values', () => { - expect(registerTwice(1, 2)).toThrow('Conflicting asset definition'); - }); - - test('allows deeply-equal nested plain-object and array options', () => { - expect(registerTwice({ nested: { a: [1, 2, { b: 3 }] } }, { nested: { a: [1, 2, { b: 3 }] } })).not.toThrow(); - }); - - test('rejects arrays of different lengths', () => { - expect(registerTwice({ list: [1, 2] }, { list: [1] })).toThrow('Conflicting asset definition'); - }); - - test('rejects options objects with a different number of keys', () => { - expect(registerTwice({ a: 1 }, { a: 1, b: 2 })).toThrow('Conflicting asset definition'); - }); - - test('rejects options objects with the same key count but different key names', () => { - expect(registerTwice({ a: 1 }, { b: 1 })).toThrow('Conflicting asset definition'); - }); - - test('rejects options with matching keys but a differing nested value', () => { - expect(registerTwice({ a: { b: 1 } }, { a: { b: 2 } })).toThrow('Conflicting asset definition'); - }); - - test('rejects when options prototypes differ (plain object vs. class instance)', () => { - class OptionsBox { - public constructor(public readonly value: number) {} - } - - expect(registerTwice(new OptionsBox(1), { value: 1 })).toThrow('Conflicting asset definition'); - }); - - test('accepts two structurally-identical same-class option instances as equivalent', () => { - class OptionsBox { - public constructor(public readonly value: number) {} - } - - expect(registerTwice(new OptionsBox(1), new OptionsBox(1))).not.toThrow(); - }); - - test('rejects two same-class option instances with differing fields', () => { - class OptionsBox { - public constructor(public readonly value: number) {} - } - - expect(registerTwice(new OptionsBox(1), new OptionsBox(2))).toThrow('Conflicting asset definition'); - }); - - test('compares Date options by timestamp', () => { - expect(registerTwice({ since: new Date(1000) }, { since: new Date(1000) })).not.toThrow(); - expect(registerTwice({ since: new Date(1000) }, { since: new Date(2000) })).toThrow('Conflicting asset definition'); - }); - - test('exotic containers stay reference-only (two similar Maps still conflict)', () => { - expect( - registerTwice( - { lookup: new Map([['a', 1]]) }, - { - lookup: new Map([['a', 1]]), - }, - ), - ).toThrow('Conflicting asset definition'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Coverage sweep — background bundle loading: cache-hit / in-flight shortcuts in -// _loadSingleBackground, _isQueuedInBackground dedup, and _waitForBackgroundEntry's -// onLoaded/onError mismatch-ignore branches -// ───────────────────────────────────────────────────────────────────────────── - -describe('background bundle loading — _loadSingleBackground shortcuts', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function mockFetch(): void { - global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - } - - test('background bundle load resolves instantly for an alias already in memory', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { boot: [{ type: TextAsset, alias: 'preloaded', path: 'preloaded.txt' }] }, - }), - ); - mockFetch(); - - await loader.load(TextAsset, 'preloaded'); - (global.fetch as MockInstance).mockClear(); - - await loader.loadBundle('boot', { background: true }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - test('background bundle load attaches to an already-in-flight foreground fetch', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/' }); - const deferred = createDeferred(); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { boot: [{ type: TextAsset, alias: 'shared', path: 'shared.txt' }] }, - }), - ); - - global.fetch = vi.fn((): Promise => deferred.promise); - - const foreground = loader.load(TextAsset, 'shared'); - const bundlePromise = loader.loadBundle('boot', { background: true }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - - deferred.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); - - await foreground; - await bundlePromise; - - expect(loader.has(TextAsset, 'shared')).toBe(true); - }); - - test('two overlapping background bundle loads for the same not-yet-started alias dedupe via _isQueuedInBackground', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - const blocker = createDeferred(); - const shared = createDeferred(); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - first: [ - { type: TextAsset, alias: 'blocker', path: 'blocker.txt' }, - { type: TextAsset, alias: 'shared', path: 'shared2.txt' }, - ], - second: [{ type: TextAsset, alias: 'shared', path: 'shared2.txt' }], - }, - }), - ); - - global.fetch = vi.fn((input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/blocker.txt')) return blocker.promise; - if (url.endsWith('/shared2.txt')) return shared.promise; - throw new Error(`Unexpected fetch url: ${url}`); - }); - - const firstBundle = loader.loadBundle('first', { background: true }); - const secondBundle = loader.loadBundle('second', { background: true }); - - blocker.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); - shared.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); - - await Promise.all([firstBundle, secondBundle]); - - expect(global.fetch).toHaveBeenCalledTimes(2); // blocker.txt + shared2.txt only — no duplicate fetch for "shared" - }); - - test('_waitForBackgroundEntry ignores onLoaded/onError events for other aliases while waiting for its own', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 2 }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'first', path: 'first.txt' }, - { type: TextAsset, alias: 'second', path: 'second.txt' }, - { type: TextAsset, alias: 'third', path: 'third.txt' }, - ], - }, - }), - ); - - global.fetch = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/second.txt')) { - return { ok: false, status: 404, statusText: 'Not Found' } as Response; - } - - return { ok: true, status: 200, statusText: 'OK' } as Response; - }); - - await expect(loader.loadBundle('boot', { background: true })).rejects.toBeInstanceOf(BundleLoadError); - - expect(loader.has(TextAsset, 'first')).toBe(true); - expect(loader.has(TextAsset, 'third')).toBe(true); - expect(loader.has(TextAsset, 'second')).toBe(false); - }); - - test('_waitForBackgroundEntry rejects when the entry it is waiting for itself fails', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - boot: [ - { type: TextAsset, alias: 'first', path: 'first.txt' }, - { type: TextAsset, alias: 'waiting', path: 'waiting.txt' }, - ], - }, - }), - ); - - global.fetch = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/waiting.txt')) { - return { ok: false, status: 404, statusText: 'Not Found' } as Response; - } - - return { ok: true, status: 200, statusText: 'OK' } as Response; - }); - - // concurrency: 1 -> 'first' starts immediately, 'waiting' sits in the queue and - // is only observed via `_waitForBackgroundEntry`'s onLoaded/onError listeners. - await expect(loader.loadBundle('boot', { background: true })).rejects.toBeInstanceOf(BundleLoadError); - - expect(loader.has(TextAsset, 'first')).toBe(true); - expect(loader.has(TextAsset, 'waiting')).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// backgroundLoad() re-entrancy — queued entries must not be duplicated -// ───────────────────────────────────────────────────────────────────────────── - -describe('backgroundLoad() re-entrancy', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - test('calling backgroundLoad() again before the queue drains does not double-queue entries or corrupt onProgress', async () => { - const factory = new MockTextFactory(); - const loader = new Loader({ basePath: '/', concurrency: 1 }); - const deferredA = createDeferred(); - const deferredB = createDeferred(); - const progress: Array<[number, number]> = []; - - loader.register(TextAsset, factory as AssetFactory); - loader.registerManifest( - defineAssetManifest({ - bundles: { - seed: [ - { type: TextAsset, alias: 'a', path: 'a.txt' }, - { type: TextAsset, alias: 'b', path: 'b.txt' }, - ], - }, }), - ); - - const done = new Promise(resolve => { - loader.onProgress.add((loaded, total) => { - progress.push([loaded, total]); - if (loaded === total) resolve(); - }); - }); - - global.fetch = vi.fn((input: RequestInfo | URL): Promise => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - - if (url.endsWith('/a.txt')) return deferredA.promise; - if (url.endsWith('/b.txt')) return deferredB.promise; - throw new Error(`Unexpected fetch url: ${url}`); - }); - - loader.backgroundLoad(); // queue=[a,b] -> starts 'a' (active=1), 'b' stays queued - loader.backgroundLoad(); // 'a' in-flight -> skipped; 'b' already queued -> skipped too - - deferredA.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); - deferredB.resolve({ ok: true, status: 200, statusText: 'OK' } as Response); + ]); - await done; + await loader.load(new Asset({ kind: 'mockAsset', source: 'extra.dat', format: 'tiled' })); - expect(global.fetch).toHaveBeenCalledTimes(2); // a.txt + b.txt fetched once each - expect(progress).toEqual([ - [1, 2], - [2, 2], - ]); // loaded never exceeds total + expect(receivedOptions[0]).toMatchObject({ format: 'tiled' }); }); }); @@ -2728,7 +1574,7 @@ describe('Loader constructor — cache option as an array of stores', () => { loader.register(TextAsset, factory as AssetFactory); global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - await loader.load(TextAsset, 'demo.txt'); + await loader.load('demo.txt'); expect(storeA.load).toHaveBeenCalledWith('text', 'demo.txt'); expect(storeB.load).toHaveBeenCalledWith('text', 'demo.txt'); @@ -2736,15 +1582,15 @@ describe('Loader constructor — cache option as an array of stores', () => { }); describe('unload()-during-in-flight identity cleanup on rejection', () => { + class RichAsset {} + test('does not throw when the identity tracking was already cleared before the fetch rejects', async () => { const loader = new Loader({ basePath: '/' }); const deferred = createDeferred(); - loader.registerAssetType('richAsset', { - load: async () => deferred.promise, - }); + loader.bindAsset({ type: RichAsset, typeNames: ['richAsset'] }, { load: async () => deferred.promise }); - const asset = new Asset({ type: 'richAsset', source: 'x.dat', format: 'x' }); + const asset = new Asset({ kind: 'richAsset', source: 'x.dat', format: 'x' }); const pending = loader.load(asset); // Unload while still in flight: this clears `_identityKeyToAliases` for this @@ -2757,89 +1603,54 @@ describe('unload()-during-in-flight identity cleanup on rejection', () => { }); }); -describe('loadBundle() with an empty bundle', () => { - test('resolves immediately and reports zero/zero progress', async () => { - const loader = new Loader({ basePath: '/' }); - const signalProgress: Array<[string, number, number]> = []; - - loader.registerManifest(defineAssetManifest({ bundles: { empty: [] } })); - loader.onBundleProgress.add((name, loaded, total) => { - signalProgress.push([name, loaded, total]); - }); - - const callbackProgress: Array<[number, number]> = []; - - await expect( - loader.loadBundle('empty', { - onProgress: (loaded, total) => { - callbackProgress.push([loaded, total]); - }, - }), - ).resolves.toBeUndefined(); - - expect(signalProgress).toContainEqual(['empty', 0, 0]); - expect(callbackProgress).toContainEqual([0, 0]); - }); -}); - describe('unloadAll() with no type argument', () => { test('clears every loaded type', async () => { const textFactory = new MockTextFactory(); - const dummyFactory = new DummyFactory(); const loader = new Loader({ basePath: '/' }); loader.register(TextAsset, textFactory as AssetFactory); - loader.register(DummyAsset, dummyFactory); + materializeAssetBindings(loader, [ + defineAsset({ + type: DummyAsset, + kind: 'dummyAsset', + isValue: false, + create: () => ({ + async load(request, ctx) { + return new DummyAsset(await ctx.fetchText(request.source)); + }, + }), + }), + ]); global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - await loader.load(TextAsset, 'a.txt'); - await loader.load(DummyAsset, 'b.dat'); + await loader.load('a.txt'); + await loader.load(new Asset({ kind: 'dummyAsset', source: 'b.dat' })); - expect(loader.has(TextAsset, 'a.txt')).toBe(true); - expect(loader.has(DummyAsset, 'b.dat')).toBe(true); + expect(loader._peekResource(TextAsset, 'a.txt')).not.toBeNull(); + expect(loader._peekResource(DummyAsset, 'b.dat')).not.toBeNull(); loader.unloadAll(); - expect(loader.has(TextAsset, 'a.txt')).toBe(false); - expect(loader.has(DummyAsset, 'b.dat')).toBe(false); - }); -}); - -describe('legacy Record — third-argument options merge', () => { - test('merges third-argument options into an object-shaped BatchValue', async () => { - const factory = new MockAssetFactory(); - const loader = new Loader({ basePath: '/' }); - - loader.register(MockAssetType, factory as AssetFactory); - global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); - - const receivedOptions: unknown[] = []; - factory.create.mockImplementation(async (source: string, options?: unknown) => { - receivedOptions.push(options); - return `loaded:${source}`; - }); - - await loader.load(MockAssetType, { hero: { source: 'images/hero.png', scale: 2 } }, { locale: 'en' }); - - expect(receivedOptions[0]).toMatchObject({ source: 'images/hero.png', scale: 2, locale: 'en' }); + expect(loader._peekResource(TextAsset, 'a.txt')).toBeNull(); + expect(loader._peekResource(DummyAsset, 'b.dat')).toBeNull(); }); }); describe('load({ alias: config }) — plain object values are auto-wrapped in an Asset', () => { test('a plain (non-Asset) config object value loads correctly', async () => { - const factory = new MockAssetFactory(); const loader = new Loader({ basePath: '/' }); - loader.registerAssetType('mockAsset', MockAssetType as never, factory as AssetFactory); - global.fetch = vi.fn(async (): Promise => ({ ok: true, status: 200, statusText: 'OK', text: async () => 'raw' }) as unknown as Response); + loader.bindAsset({ type: MockAssetType, typeNames: ['mockAsset'] }, { load: async request => `loaded:${request.source}` }); - await loader.load({ hero: { type: 'mockAsset', source: 'hero.dat' } }); + await loader.load({ hero: { kind: 'mockAsset', source: 'hero.dat' } }); - expect(loader.has(MockAssetType, 'hero')).toBe(true); + expect(loader._peekResource(MockAssetType, 'hero')).not.toBeNull(); }); }); describe('non-Error throws are stringified when wrapping fetch/handler failures', () => { + class RichAsset {} + const originalFetch = global.fetch; afterEach(() => { @@ -2849,14 +1660,17 @@ describe('non-Error throws are stringified when wrapping fetch/handler failures' test('_fetchWithHandler wraps a thrown non-Error value from a handler', async () => { const loader = new Loader({ basePath: '/assets/' }); - loader.registerAssetType('richAsset', { - load: async () => { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 'plain string failure'; + loader.bindAsset( + { type: RichAsset, typeNames: ['richAsset'] }, + { + load: async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'plain string failure'; + }, }, - }); + ); - const asset = new Asset({ type: 'richAsset', source: 'y.json', format: 'y' }); + const asset = new Asset({ kind: 'richAsset', source: 'y.json', format: 'y' }); await expect(loader.load(asset)).rejects.toThrow(/Failed to load "y\.json" from "\/assets\/y\.json": plain string failure/); }); @@ -2872,26 +1686,6 @@ describe('non-Error throws are stringified when wrapping fetch/handler failures' throw 'raw string boom'; }); - await expect(loader.load(TextAsset, 'boom.txt')).rejects.toThrow(/raw string boom/); - }); -}); - -describe('registerManifest() option-equivalence — array element mismatch (same length)', () => { - test('rejects same-length arrays with a differing element', () => { - const loader = new Loader({ basePath: '/' }); - - loader.registerManifest( - defineAssetManifest({ - bundles: { boot: [{ type: TextAsset, alias: 'x', path: 'a.txt', options: { list: [1, 2, 3] } }] }, - }), - ); - - expect(() => - loader.registerManifest( - defineAssetManifest({ - bundles: { other: [{ type: TextAsset, alias: 'x', path: 'a.txt', options: { list: [1, 2, 4] } }] }, - }), - ), - ).toThrow('Conflicting asset definition'); + await expect(loader.load('boom.txt')).rejects.toThrow(/raw string boom/); }); }); diff --git a/test/resources/parse.test.ts b/test/resources/parse.test.ts new file mode 100644 index 000000000..bf61fe23f --- /dev/null +++ b/test/resources/parse.test.ts @@ -0,0 +1,82 @@ +import '#resources/coreAssetBindings'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { materializeAssetBindings } from '#extensions/materialize'; +import { Assets } from '#resources/Assets'; +import { coreAssetBindings } from '#resources/coreAssetBindings'; +import { Loader } from '#resources/Loader'; + +function createCoreLoader(): Loader { + const loader = new Loader(); + materializeAssetBindings(loader, coreAssetBindings); + return loader; +} + +const originalFetch = global.fetch; + +function mockJson(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(8), + }) as unknown as Response, + ) as typeof fetch; +} + +interface Config { + readonly hp: number; + readonly label: string; +} + +describe('parse post-load transform', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + it('applies parse to the raw JSON on fill', async () => { + mockJson({ hp: 3 }); + const loader = createCoreLoader(); + + const assets = Assets.from({ + config: { + kind: 'json', + source: 'c.json', + parse: (raw): Config => ({ hp: (raw as { hp: number }).hp, label: `hp:${(raw as { hp: number }).hp}` }), + }, + }); + + const loaded = await loader.load(assets); + + expect(loaded.config).toEqual({ hp: 3, label: 'hp:3' }); + expect(assets.config.value).toEqual({ hp: 3, label: 'hp:3' }); + }); + + it('a throwing parse fails only its own ref — a sibling sharing the source stays ready', async () => { + mockJson({ hp: 3 }); + const loader = createCoreLoader(); + + const assets = Assets.from({ + good: { kind: 'json', source: 'c.json', parse: (raw): Config => ({ hp: (raw as { hp: number }).hp, label: 'ok' }) }, + bad: { + kind: 'json', + source: 'c.json', + parse: (): Config => { + throw new Error('bad parse'); + }, + }, + }); + + await loader.load(assets).catch(() => undefined); // `bad` rejects; `good` resolves + + expect(assets.good.state).toBe('ready'); + expect(assets.good.value).toEqual({ hp: 3, label: 'ok' }); + expect(assets.bad.state).toBe('failed'); + expect(assets.bad.error?.message).toBe('bad parse'); + }); +}); 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); }); diff --git a/test/type-tests/asset-kind.type-test.ts b/test/type-tests/asset-kind.type-test.ts new file mode 100644 index 000000000..a9d9b5c77 --- /dev/null +++ b/test/type-tests/asset-kind.type-test.ts @@ -0,0 +1,48 @@ +// Type contract for the `Asset.kind()` descriptor builder (asset-system v2 +// delta §3). Compiled by `tsconfig.type-tests.json` (strict:false example +// project) via `pnpm typecheck:type-tests`, NOT collected by vitest (no +// `.test.ts` suffix). This is the hard type-level guarantee that `Asset.kind` +// is a strongly typed builder, not a `string`-keyed helper — without it, +// `Asset.kind` would be a regression over the `.of()` statics it replaces. + +import { Asset, type Texture, type ValueAsset } from '@codexo/exojs'; + +// Compile-time exact-type assertion, independent of vitest/expectTypeOf so a bare +// `tsc --noEmit` validates it (mirrors assets-strict-false.type-test.ts). +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +interface LevelData { + readonly width: number; + readonly height: number; +} + +// (1) resource inference from kind — no , resource type comes from the kind. +const shipDesc = Asset.kind('texture', 'p.png'); +type _ShipIsTexture = Expect>>; + +// (2) value kind: annotates the decoded value — branded ValueAsset. +const levelDesc = Asset.kind('json', 'l.json'); +type _LevelIsTyped = Expect>>; + +// (3) value kind without stays unknown — still branded. +const rawJson = Asset.kind('json', 'l.json'); +type _RawJsonUnknown = Expect>>; + +// (4) kind-specific options are accepted. +const withOpts = Asset.kind('texture', 'p.png', { mimeType: 'image/png' }); +void withOpts; + +// ── Negatives — each MUST fail to compile ───────────────────────────────────── + +// @ts-expect-error — is not allowed on a resource kind (type fixed by kind). +Asset.kind('texture', 'p.png'); + +// @ts-expect-error — an unregistered kind is not widened to string. +Asset.kind('nope', 'x.bin'); + +// @ts-expect-error — a wrong-kind option is rejected. +Asset.kind('texture', 'p.png', { delimiter: ',' }); + +export type { _LevelIsTyped, _RawJsonUnknown, _ShipIsTexture }; +void Asset; diff --git a/test/type-tests/assets-group.type-test.ts b/test/type-tests/assets-group.type-test.ts new file mode 100644 index 000000000..a6d2a56ad --- /dev/null +++ b/test/type-tests/assets-group.type-test.ts @@ -0,0 +1,16 @@ +// Type contract for `Assets.group()` spread into `Assets.from` (asset-system v2 +// §6). Compiled by `tsconfig.type-tests.json` via `pnpm typecheck:type-tests`. + +import { Assets, type Texture } from '@codexo/exojs'; + +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +const assets = Assets.from({ + ...Assets.group('texture', { player: 'player.png', enemy: 'enemy.png' }, { mimeType: 'image/png' }), +}); + +type _PlayerIsTexture = Expect>; +type _EnemyIsTexture = Expect>; + +export type { _EnemyIsTexture, _PlayerIsTexture }; diff --git a/test/type-tests/assets-one.type-test.ts b/test/type-tests/assets-one.type-test.ts new file mode 100644 index 000000000..9a9e4d421 --- /dev/null +++ b/test/type-tests/assets-one.type-test.ts @@ -0,0 +1,23 @@ +// Type contract for `Assets.one()` — a single leaf whose type matches the same +// descriptor-set inference as a catalog field (asset-system v2 §5). Compiled by +// `tsconfig.type-tests.json` via `pnpm typecheck:type-tests`. + +import { Asset, AssetRef, Assets, type Texture } from '@codexo/exojs'; + +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +// bare path, resource kind → the resource leaf +const ship = Assets.one('sprites/ship.png'); +type _ShipIsTexture = Expect>; + +// bare path, value kind → deferred AssetRef +const level = Assets.one('level.json'); +type _LevelIsRef = Expect>>; + +// resource-kind descriptor → the resource leaf +const tex = Assets.one(Asset.kind('texture', 'x.png')); +type _TexIsTexture = Expect>; + +export type { _LevelIsRef, _ShipIsTexture, _TexIsTexture }; +void AssetRef; diff --git a/test/type-tests/assets-strict-false.type-test.ts b/test/type-tests/assets-strict-false.type-test.ts new file mode 100644 index 000000000..70d7c47d9 --- /dev/null +++ b/test/type-tests/assets-strict-false.type-test.ts @@ -0,0 +1,30 @@ +// Strict:false regression guard for the `Assets.from()` catalog inference +// (S3 Phase 4.5, G2). Compiled by `tsconfig.type-tests.json`, which inherits the +// `strict: false` / `strictNullChecks: false` example project settings — the exact +// configuration under which `Assets.from({ ship: 'ship.png' }).ship` degraded to +// `{}` before the `const` type parameter on `Assets.from` was added. +// +// This file is NOT a vitest test (no `.test.ts` suffix, so vitest never collects +// it) and is not synced into the example catalog. It exists purely so `tsc` +// validates the leaf types under a non-strict project. + +import { AssetRef, Assets, type Texture } from '@codexo/exojs'; + +// Compile-time exact-type assertion, independent of vitest/expectTypeOf so it can +// be validated by a bare `tsc --noEmit`. +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +const catalog = Assets.from({ + ship: 'ship.png', // resource kind (texture) → heal-in-place Texture + level: 'level.json', // value kind (json) → deferred AssetRef +}); + +// Before the fix these both degraded to `{}` under strict:false. The literal path +// strings must survive inference so their file suffix classifies the leaf. +type _ShipIsTexture = Expect>; +type _LevelIsAssetRef = Expect>>; + +// Reference the aliases so `noUnusedLocals`-style checks and eslint stay quiet. +export type { _LevelIsAssetRef, _ShipIsTexture }; +void AssetRef; diff --git a/test/type-tests/discriminator-kind.type-test.ts b/test/type-tests/discriminator-kind.type-test.ts new file mode 100644 index 000000000..b31bae920 --- /dev/null +++ b/test/type-tests/discriminator-kind.type-test.ts @@ -0,0 +1,14 @@ +// Clean-break guard for the config discriminator rename `type` -> `kind` +// (asset-system v2 delta §2). Compiled by `tsconfig.type-tests.json` via +// `pnpm typecheck:type-tests`. Pre-1.0: there is NO `type` compatibility — an +// explicit config uses `kind`, and the old `{ type, source }` shape is a type +// error, not a silently-accepted alias. + +import { Assets } from '@codexo/exojs'; + +// The `kind` discriminator is the only accepted explicit-config form. +const ok = Assets.from({ config: { kind: 'json', source: 'c.json' } }); +void ok.config; + +// @ts-expect-error — the legacy `type` discriminator is removed (clean break). +Assets.from({ bad: { type: 'json', source: 'c.json' } }); diff --git a/test/type-tests/parse.type-test.ts b/test/type-tests/parse.type-test.ts new file mode 100644 index 000000000..f8c84a5a3 --- /dev/null +++ b/test/type-tests/parse.type-test.ts @@ -0,0 +1,31 @@ +// The object-based `parse` form (asset-system v2 delta §4/§5): a value config +// with `parse` classifies as `AssetRef` (R = parse's return type) in a +// catalog, and the resolved map from `load(catalog)` unwraps to R. Compiled by +// `tsconfig.type-tests.json` via `pnpm typecheck:type-tests`. + +import { type AssetRef, Assets, type Loader } from '@codexo/exojs'; + +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +interface Config { + readonly hp: number; +} + +const catalog = Assets.from({ + config: { kind: 'json', source: 'config.json', parse: (raw: unknown): Config => raw as Config }, +}); + +// value config with `parse` → AssetRef +type _ConfigIsRef = Expect>>; +type _ConfigValue = Expect>; + +// resolved map from load(catalog) unwraps to the parsed value +declare const loader: Loader; +function loadIt() { + return loader.load(catalog); +} +type LoadedMap = Awaited>; +type _ConfigResolved = Expect>; + +export type { _ConfigIsRef, _ConfigResolved, _ConfigValue }; diff --git a/test/type-tests/value-brand-catalog.type-test.ts b/test/type-tests/value-brand-catalog.type-test.ts new file mode 100644 index 000000000..481e38a3c --- /dev/null +++ b/test/type-tests/value-brand-catalog.type-test.ts @@ -0,0 +1,50 @@ +// The value-brand fix (asset-system v2 delta §4): `Asset.kind('json', …)` +// must classify as `AssetRef` inside a catalog (not `Config`), and the +// resolved map from `load(catalog)` unwraps it back to `Config`. Compiled by +// `tsconfig.type-tests.json` via `pnpm typecheck:type-tests`. + +import { Asset, type AssetRef, Assets, type Loader, type Texture } from '@codexo/exojs'; + +type Equal = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; +type Expect = T; + +interface Config { + readonly hp: number; +} + +const catalog = Assets.from({ + ship: Asset.kind('texture', 'ship.png'), + config: Asset.kind('json', 'config.json'), +}); + +// resource-kind descriptor → the resource leaf +type _ShipIsTexture = Expect>; + +// value-kind descriptor with object value → AssetRef (NOT Config) +type _ConfigIsRef = Expect>>; + +// `.value` is well-typed on the ref leaf +type _ConfigValue = Expect>; + +// resolved map from load(catalog) unwraps the ref to its value +declare const loader: Loader; +function loadIt() { + return loader.load(catalog); +} +type LoadedMap = Awaited>; +type _ConfigResolved = Expect>; +type _ShipResolved = Expect>; + +// direct get(asset) honors the same brand — value → AssetRef, resource → T +// (regression guard: the brand-blind `T extends object` overload typed object +// value kinds as the resource while runtime returned an AssetRef). +function getConfig() { + return loader.get(Asset.kind('json', 'config.json')); +} +function getShip() { + return loader.get(Asset.kind('texture', 'ship.png')); +} +type _GetConfigIsRef = Expect, AssetRef>>; +type _GetShipIsTexture = Expect, Texture>>; + +export type { _ConfigIsRef, _ConfigResolved, _ConfigValue, _GetConfigIsRef, _GetShipIsTexture, _ShipIsTexture, _ShipResolved }; diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 000000000..cd4d1086a --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,8 @@ +{ + // Strict:false type-level regression guards (S3 Phase 4.5, G2). Inherits the + // example project's `strict: false` / `strictNullChecks: false` settings and + // module resolution, but compiles only the dedicated type-test sources — not + // the whole example catalog — so it stays fast. Wired into `verify:quick`. + "extends": "./tsconfig.examples.json", + "include": ["test/type-tests/**/*.ts", "src/typings.d.ts"] +}