From 45fc8278071dbfe2390f06ed1bc92cce6435d929 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Wed, 23 Sep 2026 22:15:52 +0200 Subject: [PATCH 1/9] feat(playground): integrate curated example overhaul --- .../cached-assets-offline.js | 143 ++ .../cached-assets-offline.ts | 154 ++ examples/application-scenes/loading-screen.js | 44 +- examples/application-scenes/loading-screen.ts | 45 +- .../multi-view-split-screen.js | 42 +- .../multi-view-split-screen.ts | 42 +- .../application-scenes/multiple-scenes.js | 102 +- .../application-scenes/multiple-scenes.ts | 112 +- .../world-vs-screen-coords.js | 109 +- .../world-vs-screen-coords.ts | 127 +- examples/assets/json/maps/harbor-plaza.tmj | 15 + .../assets/json/maps/ownership-workshop.tmj | 291 +++ examples/assets/json/maps/physics-room.tmj | 331 ++++ examples/audio-basics/audio-buses.js | 165 +- examples/audio-basics/audio-buses.ts | 188 +- examples/audio-basics/crossfade-tracks.js | 67 +- examples/audio-basics/crossfade-tracks.ts | 73 +- examples/audio-basics/sound-pool.js | 17 +- examples/audio-basics/sound-pool.ts | 18 +- examples/audio-fx/compressor.js | 178 +- examples/audio-fx/compressor.ts | 209 +- examples/audio-fx/effect-chains.js | 411 ++-- examples/audio-fx/effect-chains.ts | 464 ++--- examples/audio-fx/reverb-and-delay.js | 174 +- examples/audio-fx/reverb-and-delay.ts | 200 +- examples/audio-fx/vocoder.js | 71 +- examples/audio-fx/vocoder.ts | 75 +- examples/beat-detection/beat-sync-pulse.js | 14 +- examples/beat-detection/beat-sync-pulse.ts | 15 +- examples/debug-layer/asset-browser.js | 11 +- examples/debug-layer/asset-browser.ts | 12 +- examples/debug-layer/pointer-and-hittest.js | 31 +- examples/debug-layer/pointer-and-hittest.ts | 32 +- examples/examples.json | 1707 ++++++----------- examples/filters/blur-filter.js | 119 +- examples/filters/blur-filter.ts | 124 +- examples/filters/color-matrix-filter.js | 155 +- examples/filters/color-matrix-filter.ts | 170 +- examples/filters/crt-scanlines.js | 142 +- examples/filters/crt-scanlines.ts | 158 +- examples/filters/custom-fragment-shader.js | 44 +- examples/filters/custom-fragment-shader.ts | 46 +- examples/filters/metaballs.js | 81 +- examples/filters/metaballs.ts | 84 +- .../geometry-graphics/graphics-gradient.js | 7 +- .../geometry-graphics/graphics-gradient.ts | 8 +- .../geometry-graphics/mesh-textured-quad.js | 28 +- .../geometry-graphics/mesh-textured-quad.ts | 28 +- examples/getting-started/hello-world.js | 14 +- examples/getting-started/hello-world.ts | 17 +- examples/input/action-mapping.js | 98 +- examples/input/action-mapping.ts | 96 +- examples/input/gamepad.js | 288 +-- examples/input/gamepad.ts | 359 +--- examples/input/mouse-and-pointer.js | 3 +- examples/input/mouse-and-pointer.ts | 3 +- examples/input/pointer-to-world.js | 7 +- examples/input/pointer-to-world.ts | 8 +- examples/lighting/many-lights.js | 37 +- examples/lighting/many-lights.ts | 39 +- examples/particles/bonfire.js | 67 +- examples/particles/bonfire.ts | 70 +- examples/particles/gpu-particles.js | 26 +- examples/particles/gpu-particles.ts | 27 +- examples/pathfinding/tilemap-navigation.js | 61 +- examples/pathfinding/tilemap-navigation.ts | 68 +- examples/performance/backend-comparison.js | 173 +- examples/performance/backend-comparison.ts | 191 +- examples/physics/joint-driven-shadows.js | 117 ++ examples/physics/joint-driven-shadows.ts | 128 ++ examples/physics/sprite-follows-body.js | 77 +- examples/physics/sprite-follows-body.ts | 83 +- examples/physics/tiled-map-physics-actor.js | 197 +- examples/physics/tiled-map-physics-actor.ts | 220 +-- examples/render-targets/mini-map.js | 46 +- examples/render-targets/mini-map.ts | 47 +- examples/render-targets/render-pipeline.js | 38 +- examples/render-targets/render-pipeline.ts | 40 +- examples/render-targets/render-to-texture.js | 102 +- examples/render-targets/render-to-texture.ts | 127 +- examples/render-targets/trail-feedback.js | 15 +- examples/render-targets/trail-feedback.ts | 15 +- examples/render-targets/water-mirror.js | 128 +- examples/render-targets/water-mirror.ts | 134 +- examples/scene-graph/nested-transforms.js | 20 +- examples/scene-graph/nested-transforms.ts | 20 +- examples/scene-graph/parallax-starfield.js | 18 +- examples/scene-graph/parallax-starfield.ts | 19 +- examples/scene-graph/pivot-and-anchor.js | 45 +- examples/scene-graph/pivot-and-anchor.ts | 46 +- examples/showcase/audio-visualisation.js | 30 +- examples/showcase/audio-visualisation.ts | 32 +- examples/showcase/boss-intro-cinematic.js | 82 +- examples/showcase/boss-intro-cinematic.ts | 85 +- examples/showcase/dialog-system.js | 17 +- examples/showcase/dialog-system.ts | 18 +- examples/showcase/gamepad-spaceship.js | 55 +- examples/showcase/gamepad-spaceship.ts | 58 +- examples/showcase/low-band-camera-shake.js | 10 +- examples/showcase/low-band-camera-shake.ts | 12 +- examples/showcase/orb-dodge.js | 85 +- examples/showcase/orb-dodge.ts | 78 +- examples/showcase/pause-blur.js | 46 +- examples/showcase/pause-blur.ts | 47 +- .../showcase/screen-shake-on-explosion.js | 28 +- .../showcase/screen-shake-on-explosion.ts | 29 +- examples/spatial-audio/listener-and-source.js | 62 +- examples/spatial-audio/listener-and-source.ts | 67 +- examples/sprites-textures/asset-catalogs.js | 43 +- examples/sprites-textures/asset-catalogs.ts | 46 +- examples/sprites-textures/texture-sampling.js | 106 + examples/sprites-textures/texture-sampling.ts | 130 ++ examples/text-fonts/typographic-styling.js | 121 +- examples/text-fonts/typographic-styling.ts | 126 +- examples/tilemap/editor-objects-gameplay.js | 92 + examples/tilemap/editor-objects-gameplay.ts | 103 + .../tilemap/level-loading-and-ownership.js | 140 ++ .../tilemap/level-loading-and-ownership.ts | 152 ++ examples/tilemap/paint-autotiled-room.js | 125 ++ examples/tilemap/paint-autotiled-room.ts | 135 ++ examples/tilemap/tiled-map-import.js | 143 +- examples/tilemap/tiled-map-import.ts | 163 +- examples/tilemap/worker-streamed-terrain.js | 37 +- examples/tilemap/worker-streamed-terrain.ts | 38 +- examples/tweens-animation/easing-curves.js | 109 +- examples/tweens-animation/easing-curves.ts | 125 +- examples/tweens-animation/frame-animation.js | 68 +- examples/tweens-animation/frame-animation.ts | 69 +- examples/tweens-animation/tween-basics.js | 81 +- examples/tweens-animation/tween-basics.ts | 85 +- examples/tweens-animation/tween-chains.js | 101 +- examples/tweens-animation/tween-chains.ts | 104 +- examples/ui/settings-menu.js | 127 ++ examples/ui/settings-menu.ts | 147 ++ examples/ui/text-entry-and-scrolling.js | 114 ++ examples/ui/text-entry-and-scrolling.ts | 123 ++ site/public/preview.html | 2 +- site/src/components/BottomSheet.tsx | 4 +- .../src/components/ExampleBrowser.module.scss | 2 +- site/src/components/ExoHeader.module.scss | 3 +- site/src/components/Navigation.tsx | 40 +- .../components/NavigationSection.module.scss | 56 +- site/src/components/NavigationSection.tsx | 23 +- .../pages/AssetBrowserToolPage.astro | 69 + site/src/components/pages/HomePage.astro | 36 +- .../pages/PlaygroundExamplePage.astro | 7 +- .../guide/assets/loading-and-resources.mdx | 6 +- .../audio/audio-reactive-visualization.mdx | 2 +- .../content/guide/audio/beat-detection.mdx | 6 +- .../src/content/guide/audio/spatial-audio.mdx | 6 +- .../guide/debugging/backend-comparison.mdx | 4 +- .../guide/debugging/custom-renderers.mdx | 4 +- .../debugging/debugging-and-inspection.mdx | 10 +- .../content/guide/debugging/performance.mdx | 12 +- site/src/content/guide/effects/filters.mdx | 10 +- site/src/content/guide/effects/particles.mdx | 2 +- .../content/guide/effects/post-processing.mdx | 10 +- site/src/content/guide/input/gamepad.mdx | 6 +- .../content/guide/input/mouse-and-pointer.mdx | 2 +- .../guide/recipes/audio-reactive-scene.mdx | 5 - .../content/guide/recipes/build-orb-dodge.mdx | 12 +- .../recipes/camera-follow-and-parallax.mdx | 5 - site/src/content/guide/recipes/game-feel.mdx | 8 +- .../src/content/guide/recipes/hud-overlay.mdx | 4 +- .../content/guide/recipes/split-screen.mdx | 2 +- .../src/content/guide/recipes/ui-patterns.mdx | 6 +- .../src/content/guide/rendering/animation.mdx | 4 +- site/src/content/guide/rendering/graphics.mdx | 6 +- .../content/guide/rendering/infinite-maps.mdx | 7 +- .../guide/rendering/pixel-snapping.mdx | 6 + .../guide/rendering/render-targets.mdx | 4 +- site/src/content/guide/rendering/sprites.mdx | 6 +- site/src/content/guide/rendering/text.mdx | 10 +- .../guide/runtime/coordinates-and-views.mdx | 8 +- .../src/content/guide/runtime/scene-graph.mdx | 4 +- .../guide/runtime/scenes-and-lifecycle.mdx | 14 +- .../content/guide/runtime/ui-and-widgets.mdx | 8 +- .../guide/shipping/troubleshooting.mdx | 2 +- site/src/layouts/DocsLayout.astro | 25 +- site/src/lib/example-aliases.ts | 60 + site/src/lib/example-store.ts | 10 +- site/src/lib/examples-catalog.ts | 5 +- site/src/lib/guide-structure.ts | 117 +- site/src/lib/playground-categories.ts | 23 + site/src/lib/playground-nav.ts | 22 +- .../de/playground/examples/[...path].astro | 8 +- site/src/pages/de/playground/index.astro | 5 + .../pages/de/tools/asset-browser/index.astro | 5 + .../en/playground/examples/[...path].astro | 8 +- site/src/pages/en/playground/index.astro | 5 + .../pages/en/tools/asset-browser/index.astro | 5 + test/site/examples-catalog.test.ts | 19 +- test/site/guide-prose-links.test.ts | 12 +- test/site/guide-structure.test.ts | 12 +- test/site/playground-nav.test.ts | 23 +- 195 files changed, 8770 insertions(+), 6048 deletions(-) create mode 100644 examples/application-scenes/cached-assets-offline.js create mode 100644 examples/application-scenes/cached-assets-offline.ts create mode 100644 examples/assets/json/maps/ownership-workshop.tmj create mode 100644 examples/assets/json/maps/physics-room.tmj create mode 100644 examples/physics/joint-driven-shadows.js create mode 100644 examples/physics/joint-driven-shadows.ts create mode 100644 examples/sprites-textures/texture-sampling.js create mode 100644 examples/sprites-textures/texture-sampling.ts create mode 100644 examples/tilemap/editor-objects-gameplay.js create mode 100644 examples/tilemap/editor-objects-gameplay.ts create mode 100644 examples/tilemap/level-loading-and-ownership.js create mode 100644 examples/tilemap/level-loading-and-ownership.ts create mode 100644 examples/tilemap/paint-autotiled-room.js create mode 100644 examples/tilemap/paint-autotiled-room.ts create mode 100644 examples/ui/settings-menu.js create mode 100644 examples/ui/settings-menu.ts create mode 100644 examples/ui/text-entry-and-scrolling.js create mode 100644 examples/ui/text-entry-and-scrolling.ts create mode 100644 site/src/components/pages/AssetBrowserToolPage.astro create mode 100644 site/src/lib/example-aliases.ts create mode 100644 site/src/lib/playground-categories.ts create mode 100644 site/src/pages/de/tools/asset-browser/index.astro create mode 100644 site/src/pages/en/tools/asset-browser/index.astro diff --git a/examples/application-scenes/cached-assets-offline.js b/examples/application-scenes/cached-assets-offline.js new file mode 100644 index 000000000..77016871b --- /dev/null +++ b/examples/application-scenes/cached-assets-offline.js @@ -0,0 +1,143 @@ +// Auto-generated from cached-assets-offline.ts - edit the .ts source, not this file. +import { + Application, + Asset, + AssetCache, + AssetCacheMissError, + Color, + ConnectivityPolicyResolver, + FixedResolutionCanvasSizing, + Graphics, + IndexedDbStore, + Scene, + Text, +} from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +const cache = new AssetCache({ + stores: new IndexedDbStore('exojs-playground-offline-demo'), + policy: new ConnectivityPolicyResolver(), +}); +const warmAsset = Asset.type('json', assets.demo.inputPrompts.generic.data); +const coldAsset = Asset.type('json', assets.demo.inputPrompts.keyboardMouse.data); +class CachedAssetsScene extends Scene { + backdrop = new Graphics(); + title = new Text('Offline Asset Lab', { fillColor: Color.white, fontSize: 44 }); + mode = new Text('Network allowed', { fillColor: new Color(106, 229, 171), fontSize: 28 }); + warmState = new Text('Generic prompts: not warmed', { fillColor: Color.white, fontSize: 25 }); + result = new Text('Choose an action in the panel.', { fillColor: Color.white, fontSize: 25 }); + hud; + panel; + busy = false; + init() { + this.backdrop.fillColor = new Color(24, 37, 55); + this.backdrop.drawRoundedRectangle(150, 130, 980, 450, 24); + this.backdrop.lineWidth = 2; + this.backdrop.lineColor = new Color(94, 127, 158); + this.backdrop.drawRoundedRectangle(150, 130, 980, 450, 24); + this.title.position.set(215, 195); + this.mode.position.set(215, 285); + this.warmState.position.set(215, 380); + this.result.position.set(215, 465); + this.hud = mountControls({ + title: 'Cached Assets Offline', + controls: [{ keys: 'Panel', action: 'warm, request, clear, and change network policy' }], + status: 'Online policy. Warm the first JSON source, then switch offline.', + hint: 'Offline mode changes loader policy; it does not disable your browser connection.', + }); + this.panel = mountControlPanel({ title: 'Cache actions' }); + this.panel.addButton({ + label: 'Warm generic prompts', + onClick: () => { + void this.run('Warm', () => this.app.loader.cacheSource(warmAsset)); + }, + }); + this.panel.addToggle({ + label: 'Offline policy', + value: false, + onChange: offline => { + this.app.connectivity.mode = offline ? 'offline' : 'online'; + this.mode.text = offline ? 'Cache only: network forbidden' : 'Network allowed'; + this.mode.style.fillColor = offline ? new Color(255, 186, 95) : new Color(106, 229, 171); + this.hud.setStatus(offline ? 'Offline policy: requests must be in the cache.' : 'Online policy: requests may use the network.'); + }, + }); + this.panel.addButton({ + label: 'Request warmed source', + onClick: () => { + void this.request(warmAsset, 'Generic prompts'); + }, + }); + this.panel.addButton({ + label: 'Request unwarmed source', + onClick: () => { + void this.request(coldAsset, 'Keyboard prompts'); + }, + }); + this.panel.addButton({ + label: 'Clear demo cache', + onClick: () => { + void this.run('Clear', () => cache.clear()); + }, + }); + } + async run(action, task) { + if (this.busy) return; + this.busy = true; + this.result.text = `${action} in progress...`; + try { + await task(); + this.warmState.text = action === 'Warm' ? 'Generic prompts: warmed in IndexedDB' : 'Generic prompts: cache cleared'; + this.result.text = action === 'Warm' ? 'Source stored without making an asset resident.' : 'Cache cleared. Retry while offline to see a miss.'; + } catch (error) { + this.result.text = `${action} failed: ${error instanceof Error ? error.message : String(error)}`; + } finally { + this.hud.setStatus(this.result.text); + this.busy = false; + } + } + async request(asset, name) { + if (this.busy) return; + this.busy = true; + this.result.text = `Loading ${name}...`; + const scope = this.loader.createScope({ name: `request:${name}` }); + try { + await scope.load(asset); + this.result.text = `${name}: loaded under ${this.app.connectivity.mode} policy.`; + } catch (error) { + this.result.text = + error instanceof AssetCacheMissError + ? `${name}: cache miss, no network request.` + : `${name}: ${error instanceof Error ? error.message : String(error)}`; + } finally { + scope.destroy(); + this.hud.setStatus(this.result.text); + this.busy = false; + } + } + draw(context) { + context.render(this.backdrop); + context.render(this.title); + context.render(this.mode); + context.render(this.warmState); + context.render(this.result); + } + destroy() { + this.app.connectivity.mode = 'auto'; + this.hud?.dispose(); + this.panel?.dispose(); + this.backdrop.destroy(); + this.title.destroy(); + this.mode.destroy(); + this.warmState.destroy(); + this.result.destroy(); + cache.destroy(); + super.destroy(); + } +} +const app = new Application({ + scenes: { CachedAssetsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 20, 32), + loader: { basePath: 'assets/', cache }, +}); +await app.start(CachedAssetsScene); diff --git a/examples/application-scenes/cached-assets-offline.ts b/examples/application-scenes/cached-assets-offline.ts new file mode 100644 index 000000000..7af7c9a1c --- /dev/null +++ b/examples/application-scenes/cached-assets-offline.ts @@ -0,0 +1,154 @@ +import { + Application, + Asset, + AssetCache, + AssetCacheMissError, + Color, + ConnectivityPolicyResolver, + FixedResolutionCanvasSizing, + Graphics, + IndexedDbStore, + type RenderingContext, + Scene, + Text, +} from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +const cache = new AssetCache({ + stores: new IndexedDbStore('exojs-playground-offline-demo'), + policy: new ConnectivityPolicyResolver(), +}); + +const warmAsset = Asset.type('json', assets.demo.inputPrompts.generic.data); +const coldAsset = Asset.type('json', assets.demo.inputPrompts.keyboardMouse.data); + +class CachedAssetsScene extends Scene { + private readonly backdrop = new Graphics(); + private readonly title = new Text('Offline Asset Lab', { fillColor: Color.white, fontSize: 44 }); + private readonly mode = new Text('Network allowed', { fillColor: new Color(106, 229, 171), fontSize: 28 }); + private readonly warmState = new Text('Generic prompts: not warmed', { fillColor: Color.white, fontSize: 25 }); + private readonly result = new Text('Choose an action in the panel.', { fillColor: Color.white, fontSize: 25 }); + private hud!: ReturnType; + private panel!: ReturnType; + private busy = false; + + override init(): void { + this.backdrop.fillColor = new Color(24, 37, 55); + this.backdrop.drawRoundedRectangle(150, 130, 980, 450, 24); + this.backdrop.lineWidth = 2; + this.backdrop.lineColor = new Color(94, 127, 158); + this.backdrop.drawRoundedRectangle(150, 130, 980, 450, 24); + this.title.position.set(215, 195); + this.mode.position.set(215, 285); + this.warmState.position.set(215, 380); + this.result.position.set(215, 465); + + this.hud = mountControls({ + title: 'Cached Assets Offline', + controls: [{ keys: 'Panel', action: 'warm, request, clear, and change network policy' }], + status: 'Online policy. Warm the first JSON source, then switch offline.', + hint: 'Offline mode changes loader policy; it does not disable your browser connection.', + }); + this.panel = mountControlPanel({ title: 'Cache actions' }); + this.panel.addButton({ + label: 'Warm generic prompts', + onClick: () => { + void this.run('Warm', () => this.app.loader.cacheSource(warmAsset)); + }, + }); + this.panel.addToggle({ + label: 'Offline policy', + value: false, + onChange: offline => { + this.app.connectivity.mode = offline ? 'offline' : 'online'; + this.mode.text = offline ? 'Cache only: network forbidden' : 'Network allowed'; + this.mode.style.fillColor = offline ? new Color(255, 186, 95) : new Color(106, 229, 171); + this.hud.setStatus(offline ? 'Offline policy: requests must be in the cache.' : 'Online policy: requests may use the network.'); + }, + }); + this.panel.addButton({ + label: 'Request warmed source', + onClick: () => { + void this.request(warmAsset, 'Generic prompts'); + }, + }); + this.panel.addButton({ + label: 'Request unwarmed source', + onClick: () => { + void this.request(coldAsset, 'Keyboard prompts'); + }, + }); + this.panel.addButton({ + label: 'Clear demo cache', + onClick: () => { + void this.run('Clear', () => cache.clear()); + }, + }); + } + + private async run(action: 'Warm' | 'Clear', task: () => Promise): Promise { + if (this.busy) return; + this.busy = true; + this.result.text = `${action} in progress...`; + try { + await task(); + this.warmState.text = action === 'Warm' ? 'Generic prompts: warmed in IndexedDB' : 'Generic prompts: cache cleared'; + this.result.text = action === 'Warm' ? 'Source stored without making an asset resident.' : 'Cache cleared. Retry while offline to see a miss.'; + } catch (error) { + this.result.text = `${action} failed: ${error instanceof Error ? error.message : String(error)}`; + } finally { + this.hud.setStatus(this.result.text); + this.busy = false; + } + } + + private async request(asset: typeof warmAsset, name: string): Promise { + if (this.busy) return; + this.busy = true; + this.result.text = `Loading ${name}...`; + const scope = this.loader.createScope({ name: `request:${name}` }); + try { + await scope.load(asset); + this.result.text = `${name}: loaded under ${this.app.connectivity.mode} policy.`; + } catch (error) { + this.result.text = + error instanceof AssetCacheMissError + ? `${name}: cache miss, no network request.` + : `${name}: ${error instanceof Error ? error.message : String(error)}`; + } finally { + scope.destroy(); + this.hud.setStatus(this.result.text); + this.busy = false; + } + } + + override draw(context: RenderingContext): void { + context.render(this.backdrop); + context.render(this.title); + context.render(this.mode); + context.render(this.warmState); + context.render(this.result); + } + + override destroy(): void { + this.app.connectivity.mode = 'auto'; + this.hud?.dispose(); + this.panel?.dispose(); + this.backdrop.destroy(); + this.title.destroy(); + this.mode.destroy(); + this.warmState.destroy(); + this.result.destroy(); + cache.destroy(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { CachedAssetsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 20, 32), + loader: { basePath: 'assets/', cache }, +}); + +await app.start(CachedAssetsScene); diff --git a/examples/application-scenes/loading-screen.js b/examples/application-scenes/loading-screen.js index 941fb6f61..f4407c136 100644 --- a/examples/application-scenes/loading-screen.js +++ b/examples/application-scenes/loading-screen.js @@ -18,6 +18,8 @@ class BootScene extends Scene { label; loaded = 0; total = 0; + failed = false; + loading = false; message = 'Waiting for the first request…'; onLoadStart; onLoadProgress; @@ -34,19 +36,29 @@ class BootScene extends Scene { this.label.setAnchor(0.5, 0); // Every listener is kept in a field so `unload()` can take it off again. this.onLoadStart = key => { - this.message = `Loading ${key}…`; + this.loading = true; + if (!this.failed) { + this.message = `Loading ${key}…`; + } }; this.onLoadProgress = (loaded, total, key) => { this.loaded = loaded; this.total = total; - this.message = `${loaded} / ${total} — ${key}`; + if (!this.failed) { + this.message = `${loaded} / ${total} — ${key}`; + } }; this.onLoadError = (key, error) => { - // onLoadComplete still fires once the rest of the batch settles. + this.failed = true; this.message = `Failed to load "${key}": ${error.message}`; }; this.onLoadComplete = () => { - this.enterGame(); + this.loading = false; + if (this.failed) { + this.message = 'Load failed. Press Space to retry.'; + } else { + this.enterGame(); + } }; app.loader.onLoadStart.add(this.onLoadStart); app.loader.onLoadProgress.add(this.onLoadProgress); @@ -54,9 +66,25 @@ class BootScene extends Scene { app.loader.onLoadComplete.add(this.onLoadComplete); // Trigger loads from anywhere - the signals above see all of them. The // claim goes on the application loader so the assets outlive this scene. - app.loader.load(GameAssets); + this.inputs.onTrigger(Keyboard.Space, () => { + if (this.failed && !this.loading) { + this.loadAssets(); + } + }); + this.loadAssets(); } // #endregion guide:boot-signals + loadAssets() { + this.failed = false; + this.loading = true; + this.loaded = 0; + this.total = 0; + this.message = 'Loading assets…'; + void this.app.loader.load(GameAssets).catch(error => { + this.failed = true; + this.message = `Load failed: ${String(error)}. Press Space to retry.`; + }); + } // #region guide:boot-unsubscribe unload() { // `this.app` is still valid here - `unload()` runs before the scene is @@ -109,15 +137,15 @@ class PlayScene extends Scene { // Already resident: BootScene claimed the catalog on the application // loader, so reading the same handles here costs nothing. this.ship = new Sprite(GameAssets.ship).setAnchor(0.5).setPosition(width / 2, height / 2); - this.label = new Text('Loaded — press Space to boot again.', { fillColor: Color.white, fontSize: 22, align: 'center' }); + this.label = new Text('Loaded — press Space to visit the warm cache.', { fillColor: Color.white, fontSize: 22, align: 'center' }); this.label.setAnchor(0.5, 0).setPosition(width / 2, height * 0.68); this.inputs.onTrigger(Keyboard.Space, () => { void app.scenes.change(BootScene); }); this.hud = mountControls({ - title: 'Loading Screen', + title: 'Loading Progress and Retry', controls: [{ keys: 'Space', action: 'return to the boot scene' }], - hint: 'The boot scene drives its bar from the loader-wide signals, then navigates once the shared batch drains.', + hint: 'The boot scene follows real loader signals, retries failures, and enters the game after a successful batch.', }); } draw(context) { diff --git a/examples/application-scenes/loading-screen.ts b/examples/application-scenes/loading-screen.ts index 8f5b940e8..22ae40777 100644 --- a/examples/application-scenes/loading-screen.ts +++ b/examples/application-scenes/loading-screen.ts @@ -33,6 +33,8 @@ class BootScene extends Scene { private loaded = 0; private total = 0; + private failed = false; + private loading = false; private message = 'Waiting for the first request…'; private onLoadStart!: (key: string, url: string) => void; @@ -54,19 +56,29 @@ class BootScene extends Scene { // Every listener is kept in a field so `unload()` can take it off again. this.onLoadStart = (key: string) => { - this.message = `Loading ${key}…`; + this.loading = true; + if (!this.failed) { + this.message = `Loading ${key}…`; + } }; this.onLoadProgress = (loaded: number, total: number, key: string) => { this.loaded = loaded; this.total = total; - this.message = `${loaded} / ${total} — ${key}`; + if (!this.failed) { + this.message = `${loaded} / ${total} — ${key}`; + } }; this.onLoadError = (key: string, error: Error) => { - // onLoadComplete still fires once the rest of the batch settles. + this.failed = true; this.message = `Failed to load "${key}": ${error.message}`; }; this.onLoadComplete = () => { - this.enterGame(); + this.loading = false; + if (this.failed) { + this.message = 'Load failed. Press Space to retry.'; + } else { + this.enterGame(); + } }; app.loader.onLoadStart.add(this.onLoadStart); @@ -76,10 +88,27 @@ class BootScene extends Scene { // Trigger loads from anywhere - the signals above see all of them. The // claim goes on the application loader so the assets outlive this scene. - app.loader.load(GameAssets); + this.inputs.onTrigger(Keyboard.Space, () => { + if (this.failed && !this.loading) { + this.loadAssets(); + } + }); + this.loadAssets(); } // #endregion guide:boot-signals + private loadAssets(): void { + this.failed = false; + this.loading = true; + this.loaded = 0; + this.total = 0; + this.message = 'Loading assets…'; + void this.app.loader.load(GameAssets).catch(error => { + this.failed = true; + this.message = `Load failed: ${String(error)}. Press Space to retry.`; + }); + } + // #region guide:boot-unsubscribe override unload(): void { // `this.app` is still valid here - `unload()` runs before the scene is @@ -144,7 +173,7 @@ class PlayScene extends Scene { // loader, so reading the same handles here costs nothing. this.ship = new Sprite(GameAssets.ship).setAnchor(0.5).setPosition(width / 2, height / 2); - this.label = new Text('Loaded — press Space to boot again.', { fillColor: Color.white, fontSize: 22, align: 'center' }); + this.label = new Text('Loaded — press Space to visit the warm cache.', { fillColor: Color.white, fontSize: 22, align: 'center' }); this.label.setAnchor(0.5, 0).setPosition(width / 2, height * 0.68); this.inputs.onTrigger(Keyboard.Space, () => { @@ -152,9 +181,9 @@ class PlayScene extends Scene { }); this.hud = mountControls({ - title: 'Loading Screen', + title: 'Loading Progress and Retry', controls: [{ keys: 'Space', action: 'return to the boot scene' }], - hint: 'The boot scene drives its bar from the loader-wide signals, then navigates once the shared batch drains.', + hint: 'The boot scene follows real loader signals, retries failures, and enters the game after a successful batch.', }); } diff --git a/examples/application-scenes/multi-view-split-screen.js b/examples/application-scenes/multi-view-split-screen.js index 1a5621283..eff8624cf 100644 --- a/examples/application-scenes/multi-view-split-screen.js +++ b/examples/application-scenes/multi-view-split-screen.js @@ -1,10 +1,13 @@ // Auto-generated from multi-view-split-screen.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, View } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, Graphics, Keyboard, Scene, Sprite, Text, View } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; class SplitScreenScene extends Scene { texture; leftView; rightView; divider; + grid; + labels; leftPlayer; rightPlayer; move = { @@ -17,6 +20,10 @@ class SplitScreenScene extends Scene { up: 0, down: 0, }; + padMove = [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]; init() { const app = this.app; const { width, height } = app; @@ -26,6 +33,16 @@ class SplitScreenScene extends Scene { this.divider = new Graphics(); this.divider.fillColor = Color.white; this.divider.drawRectangle(width / 2 - 1, 0, 2, height); + this.grid = new Graphics(); + this.grid.lineWidth = 2; + this.grid.lineColor = new Color(42, 70, 96); + for (let x = -1600; x <= 1600; x += 160) this.grid.drawLine(x, -1200, x, 1200); + for (let y = -1200; y <= 1200; y += 160) this.grid.drawLine(-1600, y, 1600, y); + this.labels = [ + new Text('P1 · WASD / Pad 1', { fillColor: new Color(120, 190, 255), fontSize: 24 }).setPosition(24, 160), + new Text('P2 · Arrows / Pad 2', { fillColor: new Color(255, 180, 120), fontSize: 24 }).setPosition(width / 2 + 24, 160), + ]; + mountControls({ title: 'Local Split Screen', hint: 'Move each player to see its camera follow independently. Controllers are optional.' }); this.leftPlayer = new Sprite(this.texture) .setAnchor(0.5) .setPosition(-160, 0) @@ -82,20 +99,39 @@ class SplitScreenScene extends Scene { this.inputs.onStop(Keyboard.Down, () => { this.move.down = 0; }); + for (let index = 0; index < 2; index++) { + const pad = app.input.gamepads[index]; + const movement = this.padMove[index]; + pad.onActive(GamepadAxis.LeftStickX, value => { + movement.x = value; + }); + pad.onStop(GamepadAxis.LeftStickX, () => { + movement.x = 0; + }); + pad.onActive(GamepadAxis.LeftStickY, value => { + movement.y = value; + }); + pad.onStop(GamepadAxis.LeftStickY, () => { + movement.y = 0; + }); + } } update(delta) { const speed = 300 * delta; - this.leftPlayer.move((this.move.d - this.move.a) * speed, (this.move.s - this.move.w) * speed); - this.rightPlayer.move((this.move.right - this.move.left) * speed, (this.move.down - this.move.up) * speed); + this.leftPlayer.move((this.move.d - this.move.a + this.padMove[0].x) * speed, (this.move.s - this.move.w + this.padMove[0].y) * speed); + this.rightPlayer.move((this.move.right - this.move.left + this.padMove[1].x) * speed, (this.move.down - this.move.up + this.padMove[1].y) * speed); this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y); this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y); } draw(context) { + context.render(this.grid, { view: this.leftView }); context.render(this.leftPlayer, { view: this.leftView }); context.render(this.rightPlayer, { view: this.leftView }); + context.render(this.grid, { view: this.rightView }); context.render(this.leftPlayer, { view: this.rightView }); context.render(this.rightPlayer, { view: this.rightView }); context.render(this.divider, { view: context.screenView }); + for (const label of this.labels) context.render(label, { view: context.screenView }); } } const app = new Application({ diff --git a/examples/application-scenes/multi-view-split-screen.ts b/examples/application-scenes/multi-view-split-screen.ts index 67858c3fc..ae011793e 100644 --- a/examples/application-scenes/multi-view-split-screen.ts +++ b/examples/application-scenes/multi-view-split-screen.ts @@ -2,21 +2,26 @@ import { Application, Color, FixedResolutionCanvasSizing, + GamepadAxis, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, + Text, Texture, View, } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; class SplitScreenScene extends Scene { private texture!: Texture; private leftView!: View; private rightView!: View; private divider!: Graphics; + private grid!: Graphics; + private labels!: Text[]; private leftPlayer!: Sprite; private rightPlayer!: Sprite; private move = { @@ -29,6 +34,10 @@ class SplitScreenScene extends Scene { up: 0, down: 0, }; + private padMove = [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]; override init(): void { const app = this.app; @@ -42,6 +51,16 @@ class SplitScreenScene extends Scene { this.divider = new Graphics(); this.divider.fillColor = Color.white; this.divider.drawRectangle(width / 2 - 1, 0, 2, height); + this.grid = new Graphics(); + this.grid.lineWidth = 2; + this.grid.lineColor = new Color(42, 70, 96); + for (let x = -1600; x <= 1600; x += 160) this.grid.drawLine(x, -1200, x, 1200); + for (let y = -1200; y <= 1200; y += 160) this.grid.drawLine(-1600, y, 1600, y); + this.labels = [ + new Text('P1 · WASD / Pad 1', { fillColor: new Color(120, 190, 255), fontSize: 24 }).setPosition(24, 160), + new Text('P2 · Arrows / Pad 2', { fillColor: new Color(255, 180, 120), fontSize: 24 }).setPosition(width / 2 + 24, 160), + ]; + mountControls({ title: 'Local Split Screen', hint: 'Move each player to see its camera follow independently. Controllers are optional.' }); this.leftPlayer = new Sprite(this.texture) .setAnchor(0.5) @@ -100,23 +119,42 @@ class SplitScreenScene extends Scene { this.inputs.onStop(Keyboard.Down, () => { this.move.down = 0; }); + for (let index = 0; index < 2; index++) { + const pad = app.input.gamepads[index]; + const movement = this.padMove[index]; + pad.onActive(GamepadAxis.LeftStickX, value => { + movement.x = value; + }); + pad.onStop(GamepadAxis.LeftStickX, () => { + movement.x = 0; + }); + pad.onActive(GamepadAxis.LeftStickY, value => { + movement.y = value; + }); + pad.onStop(GamepadAxis.LeftStickY, () => { + movement.y = 0; + }); + } } override update(delta: Seconds): void { const speed = 300 * delta; - this.leftPlayer.move((this.move.d - this.move.a) * speed, (this.move.s - this.move.w) * speed); - this.rightPlayer.move((this.move.right - this.move.left) * speed, (this.move.down - this.move.up) * speed); + this.leftPlayer.move((this.move.d - this.move.a + this.padMove[0].x) * speed, (this.move.s - this.move.w + this.padMove[0].y) * speed); + this.rightPlayer.move((this.move.right - this.move.left + this.padMove[1].x) * speed, (this.move.down - this.move.up + this.padMove[1].y) * speed); this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y); this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y); } override draw(context: RenderingContext): void { + context.render(this.grid, { view: this.leftView }); context.render(this.leftPlayer, { view: this.leftView }); context.render(this.rightPlayer, { view: this.leftView }); + context.render(this.grid, { view: this.rightView }); context.render(this.leftPlayer, { view: this.rightView }); context.render(this.rightPlayer, { view: this.rightView }); context.render(this.divider, { view: context.screenView }); + for (const label of this.labels) context.render(label, { view: context.screenView }); } } diff --git a/examples/application-scenes/multiple-scenes.js b/examples/application-scenes/multiple-scenes.js index 3c018d999..df8c0f42f 100644 --- a/examples/application-scenes/multiple-scenes.js +++ b/examples/application-scenes/multiple-scenes.js @@ -1,49 +1,83 @@ // Auto-generated from multiple-scenes.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; +const history = []; +const record = event => { + history.push(event); + if (history.length > 8) { + history.shift(); + } +}; +const makeReadout = width => new Text('', { fillColor: Color.white, fontSize: 19, align: 'center' }).setAnchor(0.5, 0).setPosition(width / 2, 390); +const makeBackground = (width, height, color) => { + const background = new Graphics(); + background.fillColor = color; + background.drawRectangle(0, 0, width, height); + return background; +}; class MenuScene extends Scene { - label; - onTap; + background; + title; + readout; + updates = 0; + draws = 0; + async load() { + record('Menu: load'); + } init() { - const app = this.app; - const { width, height } = app; - // Each scene owns its background: `init` runs once per activation, so - // navigating back and forth repaints the frame in this scene's colour. - app.clearColor.set(18, 38, 72, 1); - this.label = new Text('MENU\nClick to Start', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' }); - this.label.setAnchor(0.5); - this.label.setPosition(width / 2, height / 2); - this.inputs.onTrigger(Keyboard.Space, () => { - void app.scenes.change(GameScene); - }); - this.onTap = () => { - void app.scenes.change(GameScene); - }; - app.input.onPointerTap.add(this.onTap); + record('Menu: init'); + this.onActivate.add(() => record('Menu: activate')); + this.background = makeBackground(this.app.width, this.app.height, new Color(18, 38, 72)); + this.title = new Text('MENU\nSpace: start game', { align: 'center', fillColor: Color.white, fontSize: 36, fontWeight: 'bold' }); + this.title.setAnchor(0.5).setPosition(this.app.width / 2, 220); + this.readout = makeReadout(this.app.width); + this.inputs.onTrigger(Keyboard.Space, () => void this.app.scenes.change(GameScene)); + } + update() { + this.updates++; } draw(context) { - context.render(this.label); + this.draws++; + this.readout.text = `Menu update ${this.updates} · draw ${this.draws}\n${history.join('\n')}`; + context.render(this.background); + context.render(this.title); + context.render(this.readout); } destroy() { - const app = this.app; - app.input.onPointerTap.remove(this.onTap); + record('Menu: destroy'); super.destroy(); } } class GameScene extends Scene { - label; + background; + title; + readout; + updates = 0; + draws = 0; + async load() { + record('Game: load'); + } init() { - const app = this.app; - const { width, height } = app; - app.clearColor.set(24, 72, 42, 1); - this.label = new Text('GAME\nEsc to Menu', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' }); - this.label.setAnchor(0.5); - this.label.setPosition(width / 2, height / 2); - this.inputs.onTrigger(Keyboard.Escape, () => { - void app.scenes.change(MenuScene); - }); + record('Game: init'); + this.onActivate.add(() => record('Game: activate')); + this.background = makeBackground(this.app.width, this.app.height, new Color(24, 72, 42)); + this.title = new Text('GAME\nEsc: return to menu', { align: 'center', fillColor: Color.white, fontSize: 36, fontWeight: 'bold' }); + this.title.setAnchor(0.5).setPosition(this.app.width / 2, 220); + this.readout = makeReadout(this.app.width); + this.inputs.onTrigger(Keyboard.Escape, () => void this.app.scenes.change(MenuScene)); + } + update() { + this.updates++; } draw(context) { - context.render(this.label); + this.draws++; + this.readout.text = `Game update ${this.updates} · draw ${this.draws}\n${history.join('\n')}`; + context.render(this.background); + context.render(this.title); + context.render(this.readout); + } + destroy() { + record('Game: destroy'); + super.destroy(); } } const app = new Application({ @@ -55,8 +89,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(MenuScene); diff --git a/examples/application-scenes/multiple-scenes.ts b/examples/application-scenes/multiple-scenes.ts index ba2bde38b..abde0d585 100644 --- a/examples/application-scenes/multiple-scenes.ts +++ b/examples/application-scenes/multiple-scenes.ts @@ -1,62 +1,98 @@ -import { Application, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, Text } from '@codexo/exojs'; -class MenuScene extends Scene { - private label!: Text; - private onTap!: () => void; +const history: string[] = []; +const record = (event: string): void => { + history.push(event); + if (history.length > 8) { + history.shift(); + } +}; - override init(): void { - const app = this.app; - const { width, height } = app; +const makeReadout = (width: number): Text => + new Text('', { fillColor: Color.white, fontSize: 19, align: 'center' }).setAnchor(0.5, 0).setPosition(width / 2, 390); - // Each scene owns its background: `init` runs once per activation, so - // navigating back and forth repaints the frame in this scene's colour. - app.clearColor.set(18, 38, 72, 1); +const makeBackground = (width: number, height: number, color: Color): Graphics => { + const background = new Graphics(); + background.fillColor = color; + background.drawRectangle(0, 0, width, height); + return background; +}; - this.label = new Text('MENU\nClick to Start', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' }); - this.label.setAnchor(0.5); - this.label.setPosition(width / 2, height / 2); +class MenuScene extends Scene { + private background!: Graphics; + private title!: Text; + private readout!: Text; + private updates = 0; + private draws = 0; + + override async load(): Promise { + record('Menu: load'); + } - this.inputs.onTrigger(Keyboard.Space, () => { - void app.scenes.change(GameScene); - }); + override init(): void { + record('Menu: init'); + this.onActivate.add(() => record('Menu: activate')); + this.background = makeBackground(this.app.width, this.app.height, new Color(18, 38, 72)); + this.title = new Text('MENU\nSpace: start game', { align: 'center', fillColor: Color.white, fontSize: 36, fontWeight: 'bold' }); + this.title.setAnchor(0.5).setPosition(this.app.width / 2, 220); + this.readout = makeReadout(this.app.width); + this.inputs.onTrigger(Keyboard.Space, () => void this.app.scenes.change(GameScene)); + } - this.onTap = () => { - void app.scenes.change(GameScene); - }; - app.input.onPointerTap.add(this.onTap); + override update(): void { + this.updates++; } override draw(context: RenderingContext): void { - context.render(this.label); + this.draws++; + this.readout.text = `Menu update ${this.updates} · draw ${this.draws}\n${history.join('\n')}`; + context.render(this.background); + context.render(this.title); + context.render(this.readout); } override destroy(): void { - const app = this.app; - app.input.onPointerTap.remove(this.onTap); + record('Menu: destroy'); super.destroy(); } } class GameScene extends Scene { - private label!: Text; + private background!: Graphics; + private title!: Text; + private readout!: Text; + private updates = 0; + private draws = 0; + + override async load(): Promise { + record('Game: load'); + } override init(): void { - const app = this.app; - const { width, height } = app; - - app.clearColor.set(24, 72, 42, 1); - - this.label = new Text('GAME\nEsc to Menu', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' }); - this.label.setAnchor(0.5); - this.label.setPosition(width / 2, height / 2); + record('Game: init'); + this.onActivate.add(() => record('Game: activate')); + this.background = makeBackground(this.app.width, this.app.height, new Color(24, 72, 42)); + this.title = new Text('GAME\nEsc: return to menu', { align: 'center', fillColor: Color.white, fontSize: 36, fontWeight: 'bold' }); + this.title.setAnchor(0.5).setPosition(this.app.width / 2, 220); + this.readout = makeReadout(this.app.width); + this.inputs.onTrigger(Keyboard.Escape, () => void this.app.scenes.change(MenuScene)); + } - this.inputs.onTrigger(Keyboard.Escape, () => { - void app.scenes.change(MenuScene); - }); + override update(): void { + this.updates++; } override draw(context: RenderingContext): void { - context.render(this.label); + this.draws++; + this.readout.text = `Game update ${this.updates} · draw ${this.draws}\n${history.join('\n')}`; + context.render(this.background); + context.render(this.title); + context.render(this.readout); + } + + override destroy(): void { + record('Game: destroy'); + super.destroy(); } } @@ -69,9 +105,7 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(MenuScene); diff --git a/examples/application-scenes/world-vs-screen-coords.js b/examples/application-scenes/world-vs-screen-coords.js index fa0a6c945..72c03d1ce 100644 --- a/examples/application-scenes/world-vs-screen-coords.js +++ b/examples/application-scenes/world-vs-screen-coords.js @@ -1,63 +1,92 @@ // Auto-generated from world-vs-screen-coords.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text, View } from '@codexo/exojs'; -class WorldScreenScene extends Scene { +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text, View } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; +class CameraNavigationScene extends Scene { view; grid; markers; - text; - pointer = { x: 0, y: 0 }; + readout; + hud; + up; + down; + left; + right; + pointer = { x: 640, y: 360 }; + world = { x: 640, y: 360 }; + onPointerMove = pointer => { + this.pointer.x = pointer.x; + this.pointer.y = pointer.y; + }; + onPointerTap = pointer => { + const point = this.view.screenToWorld(pointer.x, pointer.y); + this.markers.fillColor = new Color(255, 190, 75); + this.markers.drawCircle(point.x, point.y, 8); + }; + onMouseWheel = (_deltaX, deltaY) => { + this.view.setZoom(Math.max(0.3, Math.min(3, this.view.zoomLevel * Math.exp(-deltaY * 0.001)))); + }; init() { - const app = this.app; - const width = app.width; - const height = app.height; - this.view = new View(260, 160, width, height); + const { width, height } = this.app; + this.view = new View(width / 2, height / 2, width, height); + this.pointer = { x: width / 2, y: height / 2 }; this.grid = new Graphics(); - this.markers = new Graphics(); - this.text = new Text('', { fillColor: Color.white, fontSize: 16 }); this.grid.lineWidth = 1; - this.grid.lineColor = new Color(60, 60, 60); - for (let x = -200; x <= 1200; x += 100) { - this.grid.drawLine(x, -200, x, 1000); + this.grid.lineColor = new Color(65, 74, 92); + for (let x = -800; x <= width + 800; x += 80) { + this.grid.drawLine(x, -600, x, height + 600); } - for (let y = -200; y <= 1000; y += 100) { - this.grid.drawLine(-200, y, 1200, y); + for (let y = -600; y <= height + 600; y += 80) { + this.grid.drawLine(-800, y, width + 800, y); } - app.input.onPointerMove.add(pointer => { - this.pointer = { x: pointer.x, y: pointer.y }; - }); - app.input.onPointerTap.add(pointer => { - const world = this.toWorld(pointer.x, pointer.y); - this.markers.fillColor = new Color(255, 220, 80); - this.markers.drawCircle(world.x, world.y, 8); + this.markers = new Graphics(); + this.readout = new Text('', { fillColor: Color.white, fontSize: 17 }); + this.readout.setPosition(18, height - 88); + this.up = this.inputs.onActive([Keyboard.W, Keyboard.Up]); + this.down = this.inputs.onActive([Keyboard.S, Keyboard.Down]); + this.left = this.inputs.onActive([Keyboard.A, Keyboard.Left]); + this.right = this.inputs.onActive([Keyboard.D, Keyboard.Right]); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerTap.add(this.onPointerTap); + this.app.input.onMouseWheel.add(this.onMouseWheel); + this.hud = mountControls({ + title: 'Camera Navigation', + controls: [ + { keys: 'WASD / arrows', action: 'pan' }, + { keys: 'Wheel', action: 'zoom' }, + { keys: 'Move / click', action: 'inspect / mark world point' }, + ], + hint: 'Markers use world coordinates; the readout stays fixed on screen.', }); } + update(delta) { + const x = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0); + const y = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0); + this.view.move(x * 420 * delta, y * 420 * delta); + this.world = this.view.screenToWorld(this.pointer.x, this.pointer.y); + this.readout.text = `Screen ${this.pointer.x.toFixed(0)}, ${this.pointer.y.toFixed(0)}\nWorld ${this.world.x.toFixed(0)}, ${this.world.y.toFixed(0)}\nZoom ${this.view.zoomLevel.toFixed(2)}`; + } draw(context) { - const world = this.toWorld(this.pointer.x, this.pointer.y); - this.text.text = `screen: ${this.pointer.x | 0}, ${this.pointer.y | 0}\nworld: ${world.x | 0}, ${world.y | 0}`; - this.text.setPosition(12, 12); - context.backend.setView(this.view); - context.render(this.grid); - context.render(this.markers); - context.backend.setView(null); - context.render(this.text); + context.render(this.grid, { view: this.view }); + context.render(this.markers, { view: this.view }); + context.render(this.readout, { view: context.screenView }); } - toWorld(screenX, screenY) { - // Pointer coordinates are already in design space, so screenToWorld only - // has to undo this view's camera transform (pan/zoom) to reach world space. - return this.view.screenToWorld(screenX, screenY); + destroy() { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerTap.remove(this.onPointerTap); + this.app.input.onMouseWheel.remove(this.onMouseWheel); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ - scenes: { WorldScreenScene }, + scenes: { CameraNavigationScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + clearColor: new Color(10, 12, 20), + loader: { basePath: 'assets/' }, }); -await app.start(WorldScreenScene); +await app.start(CameraNavigationScene); diff --git a/examples/application-scenes/world-vs-screen-coords.ts b/examples/application-scenes/world-vs-screen-coords.ts index 5eb4dd3aa..72b3f59bc 100644 --- a/examples/application-scenes/world-vs-screen-coords.ts +++ b/examples/application-scenes/world-vs-screen-coords.ts @@ -1,76 +1,111 @@ -import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text, View } from '@codexo/exojs'; +import { + Application, + Color, + FixedResolutionCanvasSizing, + Graphics, + type InputBinding, + Keyboard, + type RenderingContext, + Scene, + type Seconds, + Text, + View, +} from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; -class WorldScreenScene extends Scene { +class CameraNavigationScene extends Scene { private view!: View; private grid!: Graphics; private markers!: Graphics; - private text!: Text; - private pointer = { x: 0, y: 0 }; + private readout!: Text; + private hud!: ReturnType; + private up!: InputBinding; + private down!: InputBinding; + private left!: InputBinding; + private right!: InputBinding; + private pointer = { x: 640, y: 360 }; + private world = { x: 640, y: 360 }; + private readonly onPointerMove = (pointer: { x: number; y: number }): void => { + this.pointer.x = pointer.x; + this.pointer.y = pointer.y; + }; + private readonly onPointerTap = (pointer: { x: number; y: number }): void => { + const point = this.view.screenToWorld(pointer.x, pointer.y); + this.markers.fillColor = new Color(255, 190, 75); + this.markers.drawCircle(point.x, point.y, 8); + }; + private readonly onMouseWheel = (_deltaX: number, deltaY: number): void => { + this.view.setZoom(Math.max(0.3, Math.min(3, this.view.zoomLevel * Math.exp(-deltaY * 0.001)))); + }; override init(): void { - const app = this.app; - const width = app.width; - const height = app.height; - - this.view = new View(260, 160, width, height); + const { width, height } = this.app; + this.view = new View(width / 2, height / 2, width, height); + this.pointer = { x: width / 2, y: height / 2 }; this.grid = new Graphics(); - this.markers = new Graphics(); - this.text = new Text('', { fillColor: Color.white, fontSize: 16 }); - this.grid.lineWidth = 1; - this.grid.lineColor = new Color(60, 60, 60); - - for (let x = -200; x <= 1200; x += 100) { - this.grid.drawLine(x, -200, x, 1000); + this.grid.lineColor = new Color(65, 74, 92); + for (let x = -800; x <= width + 800; x += 80) { + this.grid.drawLine(x, -600, x, height + 600); } - - for (let y = -200; y <= 1000; y += 100) { - this.grid.drawLine(-200, y, 1200, y); + for (let y = -600; y <= height + 600; y += 80) { + this.grid.drawLine(-800, y, width + 800, y); } + this.markers = new Graphics(); + this.readout = new Text('', { fillColor: Color.white, fontSize: 17 }); + this.readout.setPosition(18, height - 88); - app.input.onPointerMove.add(pointer => { - this.pointer = { x: pointer.x, y: pointer.y }; + this.up = this.inputs.onActive([Keyboard.W, Keyboard.Up]); + this.down = this.inputs.onActive([Keyboard.S, Keyboard.Down]); + this.left = this.inputs.onActive([Keyboard.A, Keyboard.Left]); + this.right = this.inputs.onActive([Keyboard.D, Keyboard.Right]); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerTap.add(this.onPointerTap); + this.app.input.onMouseWheel.add(this.onMouseWheel); + this.hud = mountControls({ + title: 'Camera Navigation', + controls: [ + { keys: 'WASD / arrows', action: 'pan' }, + { keys: 'Wheel', action: 'zoom' }, + { keys: 'Move / click', action: 'inspect / mark world point' }, + ], + hint: 'Markers use world coordinates; the readout stays fixed on screen.', }); + } - app.input.onPointerTap.add(pointer => { - const world = this.toWorld(pointer.x, pointer.y); - this.markers.fillColor = new Color(255, 220, 80); - this.markers.drawCircle(world.x, world.y, 8); - }); + override update(delta: Seconds): void { + const x = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0); + const y = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0); + this.view.move(x * 420 * delta, y * 420 * delta); + this.world = this.view.screenToWorld(this.pointer.x, this.pointer.y); + this.readout.text = `Screen ${this.pointer.x.toFixed(0)}, ${this.pointer.y.toFixed(0)}\nWorld ${this.world.x.toFixed(0)}, ${this.world.y.toFixed(0)}\nZoom ${this.view.zoomLevel.toFixed(2)}`; } override draw(context: RenderingContext): void { - const world = this.toWorld(this.pointer.x, this.pointer.y); - - this.text.text = `screen: ${this.pointer.x | 0}, ${this.pointer.y | 0}\nworld: ${world.x | 0}, ${world.y | 0}`; - this.text.setPosition(12, 12); - - context.backend.setView(this.view); - context.render(this.grid); - context.render(this.markers); - context.backend.setView(null); - context.render(this.text); + context.render(this.grid, { view: this.view }); + context.render(this.markers, { view: this.view }); + context.render(this.readout, { view: context.screenView }); } - private toWorld(screenX: number, screenY: number): { x: number; y: number } { - // Pointer coordinates are already in design space, so screenToWorld only - // has to undo this view's camera transform (pan/zoom) to reach world space. - return this.view.screenToWorld(screenX, screenY); + override destroy(): void { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerTap.remove(this.onPointerTap); + this.app.input.onMouseWheel.remove(this.onMouseWheel); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ - scenes: { WorldScreenScene }, + scenes: { CameraNavigationScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing(), }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + clearColor: new Color(10, 12, 20), + loader: { basePath: 'assets/' }, }); -await app.start(WorldScreenScene); +await app.start(CameraNavigationScene); diff --git a/examples/assets/json/maps/harbor-plaza.tmj b/examples/assets/json/maps/harbor-plaza.tmj index 6c7416f5e..827baf660 100644 --- a/examples/assets/json/maps/harbor-plaza.tmj +++ b/examples/assets/json/maps/harbor-plaza.tmj @@ -361,6 +361,21 @@ "ellipse": true } ] + }, + { + "id": 3, + "name": "Gameplay", + "type": "objectgroup", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "draworder": "topdown", + "objects": [ + { "id": 6, "name": "gate-switch", "type": "Switch", "x": 384, "y": 560, "width": 64, "height": 64, "rotation": 0, "visible": true }, + { "id": 7, "name": "market-gate", "type": "Door", "x": 608, "y": 448, "width": 64, "height": 128, "rotation": 0, "visible": true }, + { "id": 8, "name": "harbor-gem", "type": "Collectible", "x": 928, "y": 528, "width": 64, "height": 64, "rotation": 0, "visible": true } + ] } ], "tilesets": [ diff --git a/examples/assets/json/maps/ownership-workshop.tmj b/examples/assets/json/maps/ownership-workshop.tmj new file mode 100644 index 000000000..82317b094 --- /dev/null +++ b/examples/assets/json/maps/ownership-workshop.tmj @@ -0,0 +1,291 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.10.2", + "orientation": "orthogonal", + "renderorder": "right-down", + "class": "level", + "width": 20, + "height": 12, + "tilewidth": 64, + "tileheight": 64, + "infinite": false, + "backgroundcolor": "#1c242e", + "properties": [ + { + "name": "region", + "type": "string", + "value": "Workshop" + } + ], + "layers": [ + { + "id": 1, + "name": "Ground", + "type": "tilelayer", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "width": 20, + "height": 12, + "data": [ + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 24, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 19, + 24, + 19, + 19, + 19, + 19, + 24, + 19, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 24, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 23, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 19, + 19 + ] + } + ], + "tilesets": [ + { + "firstgid": 1, + "name": "map-pack", + "image": "../../demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png", + "imagewidth": 1088, + "imageheight": 768, + "tilewidth": 64, + "tileheight": 64, + "columns": 17, + "tilecount": 204, + "spacing": 0, + "margin": 0 + } + ] +} diff --git a/examples/assets/json/maps/physics-room.tmj b/examples/assets/json/maps/physics-room.tmj new file mode 100644 index 000000000..9ec955b5f --- /dev/null +++ b/examples/assets/json/maps/physics-room.tmj @@ -0,0 +1,331 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.10.2", + "orientation": "orthogonal", + "renderorder": "right-down", + "class": "level", + "width": 20, + "height": 11, + "tilewidth": 64, + "tileheight": 64, + "infinite": false, + "backgroundcolor": "#1c242e", + "layers": [ + { + "id": 1, + "name": "Ground", + "type": "tilelayer", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "width": 20, + "height": 11, + "data": [ + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 29, + 29, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 29, + 29, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29 + ] + }, + { + "id": 2, + "name": "Collision", + "type": "objectgroup", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "draworder": "topdown", + "objects": [ + { + "id": 1, + "name": "floor", + "type": "solid", + "x": 0, + "y": 640, + "width": 1280, + "height": 64, + "rotation": 0, + "visible": true + }, + { + "id": 2, + "name": "left-wall", + "type": "solid", + "x": 0, + "y": 0, + "width": 64, + "height": 704, + "rotation": 0, + "visible": true + }, + { + "id": 3, + "name": "right-wall", + "type": "solid", + "x": 1216, + "y": 0, + "width": 64, + "height": 704, + "rotation": 0, + "visible": true + }, + { + "id": 4, + "name": "low-platform", + "type": "solid", + "x": 256, + "y": 384, + "width": 256, + "height": 64, + "rotation": 0, + "visible": true + }, + { + "id": 5, + "name": "high-platform", + "type": "solid", + "x": 768, + "y": 256, + "width": 256, + "height": 64, + "rotation": 0, + "visible": true + } + ] + } + ], + "tilesets": [ + { + "firstgid": 1, + "name": "map-pack", + "image": "../../demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png", + "imagewidth": 1088, + "imageheight": 768, + "tilewidth": 64, + "tileheight": 64, + "columns": 17, + "tilecount": 204, + "spacing": 0, + "margin": 0 + } + ] +} diff --git a/examples/audio-basics/audio-buses.js b/examples/audio-basics/audio-buses.js index 283de2156..f9f5c03e4 100644 --- a/examples/audio-basics/audio-buses.js +++ b/examples/audio-basics/audio-buses.js @@ -1,130 +1,79 @@ // Auto-generated from audio-buses.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; -// Each row drives one of the three engine-built-in busses. `master` is the -// root; `music` and `sound` are its children, so the music/SFX bars scale -// their own bus while the master bar scales everything downstream. -const rows = [ - { name: 'Master', color: new Color(255, 180, 120), bus: () => app.audio.master }, - { name: 'Music', color: new Color(120, 200, 255), bus: () => app.audio.music }, - { name: 'SFX', color: new Color(130, 255, 170), bus: () => app.audio.sound }, -]; +import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; class AudioBusesScene extends Scene { music; sfx; - graphics; - labels; - sfxLabel; - tapPrompt; - drag = -1; - // Layout (canvas-relative), computed in init() once the canvas size is known. - trackX = 0; - trackW = 0; - rowY = []; - sfxButton = { x: 0, y: 0, w: 0, h: 0 }; + musicVoice; + diagram; + panel; hud; async load() { - const app = this.app; - const { width, height } = app; - // Centre the bus mixer horizontally and spread the bars across the wide - // 16:9 canvas. - this.trackW = width * 0.5; - 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 }; - // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('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.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); 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); - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it and the queued music starts. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + } + init() { + const audio = this.app.audio; + this.diagram = new Text('Master\n + Music: looping track\n + Sound: click effect', { fillColor: Color.white, fontSize: 26 }); + this.diagram.setPosition(420, 180); this.hud = mountControls({ - title: 'Audio Buses', - controls: [ - { keys: 'Drag', action: 'a bus bar to set its volume' }, - { keys: 'Click', action: '"Play SFX" (button near the bottom) to fire a clip' }, - ], - status: 'Click or press any key to start the music…', - hint: 'Master scales every bus; Music and SFX scale only their own. Bars show live volume, labels show it in dB.', + title: 'Audio Mixer Buses', + status: 'Music is routed to Music. The button routes each click to Sound.', + hint: 'Master changes both children; Music and Sound affect only their own voices.', }); - app.input.onPointerDown.add(p => { - this.drag = this.rowFromY(p.y); - this.updateSlider(p.x); + this.panel = mountControlPanel({ title: 'Mixer' }); + this.panel.addSlider({ + label: 'Master', + min: 0, + max: 1, + step: 0.01, + value: audio.master.volume, + onChange: value => { + audio.master.volume = value; + }, }); - app.input.onPointerMove.add(p => { - this.updateSlider(p.x); + this.panel.addSlider({ + label: 'Music', + min: 0, + max: 1, + step: 0.01, + value: audio.music.volume, + onChange: value => { + audio.music.volume = value; + }, }); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addSlider({ + label: 'Sound', + min: 0, + max: 1, + step: 0.01, + value: audio.sound.volume, + onChange: value => { + audio.sound.volume = value; + }, }); - app.input.onPointerTap.add(p => { - if (this.insideSfxButton(p.x, p.y)) { - app.audio.play(this.sfx); - this.hud.setStatus('SFX fired on the SFX bus — try lowering Master or SFX, then fire again.'); - } + this.panel.addButton({ + label: 'Play sound effect', + onClick: () => { + audio.play(this.sfx, { bus: audio.sound }); + }, }); - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.6 }); - this.hud.setStatus('Music playing on the Music bus. Drag a bar to mix.'); - } - rowFromY(y) { - for (let i = 0; i < this.rowY.length; i++) { - if (Math.abs(y - this.rowY[i]) <= 24) return i; - } - return -1; - } - insideSfxButton(x, y) { - return x >= this.sfxButton.x && x <= this.sfxButton.x + this.sfxButton.w && y >= this.sfxButton.y && y <= this.sfxButton.y + this.sfxButton.h; - } - updateSlider(x) { - if (this.drag < 0) return; - const t = Math.max(0, Math.min(1, (x - this.trackX) / this.trackW)); - rows[this.drag].bus().volume = t; + this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.6 }); } draw(context) { - const app = this.app; - this.graphics.clear(); - rows.forEach((row, index) => { - const value = row.bus().volume; - const db = 20 * Math.log10(Math.max(0.0001, value)); - this.labels[index].text = `${row.name}: ${db.toFixed(1)} dB`; - // Trough. - this.graphics.fillColor = new Color(55, 55, 55); - this.graphics.drawRectangle(this.trackX, this.rowY[index] - 8, this.trackW, 16); - // Filled level. - this.graphics.fillColor = row.color; - this.graphics.drawRectangle(this.trackX, this.rowY[index] - 8, this.trackW * value, 16); - }); - // SFX trigger button. - this.graphics.fillColor = new Color(200, 200, 200); - this.graphics.drawRectangle(this.sfxButton.x, this.sfxButton.y, this.sfxButton.w, this.sfxButton.h); - context.render(this.graphics); - for (const label of this.labels) context.render(label); - context.render(this.sfxLabel); - if (app.audio.locked) { - context.render(this.tapPrompt); - } + context.render(this.diagram); + } + destroy() { + this.musicVoice?.stop(); + this.panel?.dispose(); + this.hud?.dispose(); + this.diagram?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { AudioBusesScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); await app.start(AudioBusesScene); diff --git a/examples/audio-basics/audio-buses.ts b/examples/audio-basics/audio-buses.ts index dd5c39e24..290106e48 100644 --- a/examples/audio-basics/audio-buses.ts +++ b/examples/audio-basics/audio-buses.ts @@ -1,149 +1,97 @@ -import { Application, Asset, AudioStream, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; - -// Each row drives one of the three engine-built-in busses. `master` is the -// root; `music` and `sound` are its children, so the music/SFX bars scale -// their own bus while the master bar scales everything downstream. -const rows = [ - { name: 'Master', color: new Color(255, 180, 120), bus: () => app.audio.master }, - { name: 'Music', color: new Color(120, 200, 255), bus: () => app.audio.music }, - { name: 'SFX', color: new Color(130, 255, 170), bus: () => app.audio.sound }, -]; +import { + Application, + Asset, + type AudioStream, + Color, + FixedResolutionCanvasSizing, + type RenderingContext, + Scene, + type Sound, + Text, + type Voice, +} from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; class AudioBusesScene extends Scene { private music!: AudioStream; private sfx!: Sound; - private graphics!: Graphics; - private labels!: Text[]; - private sfxLabel!: Text; - private tapPrompt!: Text; - private drag = -1; - // Layout (canvas-relative), computed in init() once the canvas size is known. - private trackX = 0; - private trackW = 0; - private rowY: number[] = []; - private sfxButton = { x: 0, y: 0, w: 0, h: 0 }; + private musicVoice!: Voice; + private diagram!: Text; + private panel!: ReturnType; private hud!: ReturnType; override async load(): Promise { - const app = this.app; - const { width, height } = app; - - // Centre the bus mixer horizontally and spread the bars across the wide - // 16:9 canvas. - this.trackW = width * 0.5; - 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 }; - - // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('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.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); 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); - - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it and the queued music starts. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + override init(): void { + const audio = this.app.audio; + this.diagram = new Text('Master\n + Music: looping track\n + Sound: click effect', { fillColor: Color.white, fontSize: 26 }); + this.diagram.setPosition(420, 180); this.hud = mountControls({ - title: 'Audio Buses', - controls: [ - { keys: 'Drag', action: 'a bus bar to set its volume' }, - { keys: 'Click', action: '"Play SFX" (button near the bottom) to fire a clip' }, - ], - status: 'Click or press any key to start the music…', - hint: 'Master scales every bus; Music and SFX scale only their own. Bars show live volume, labels show it in dB.', + title: 'Audio Mixer Buses', + status: 'Music is routed to Music. The button routes each click to Sound.', + hint: 'Master changes both children; Music and Sound affect only their own voices.', }); - - app.input.onPointerDown.add(p => { - this.drag = this.rowFromY(p.y); - this.updateSlider(p.x); + this.panel = mountControlPanel({ title: 'Mixer' }); + this.panel.addSlider({ + label: 'Master', + min: 0, + max: 1, + step: 0.01, + value: audio.master.volume, + onChange: value => { + audio.master.volume = value; + }, }); - app.input.onPointerMove.add(p => { - this.updateSlider(p.x); + this.panel.addSlider({ + label: 'Music', + min: 0, + max: 1, + step: 0.01, + value: audio.music.volume, + onChange: value => { + audio.music.volume = value; + }, }); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addSlider({ + label: 'Sound', + min: 0, + max: 1, + step: 0.01, + value: audio.sound.volume, + onChange: value => { + audio.sound.volume = value; + }, }); - app.input.onPointerTap.add(p => { - if (this.insideSfxButton(p.x, p.y)) { - app.audio.play(this.sfx); - this.hud.setStatus('SFX fired on the SFX bus — try lowering Master or SFX, then fire again.'); - } + this.panel.addButton({ + label: 'Play sound effect', + onClick: () => { + audio.play(this.sfx, { bus: audio.sound }); + }, }); - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.6 }); - this.hud.setStatus('Music playing on the Music bus. Drag a bar to mix.'); - } - - private rowFromY(y: number): number { - for (let i = 0; i < this.rowY.length; i++) { - if (Math.abs(y - this.rowY[i]) <= 24) return i; - } - return -1; - } - - private insideSfxButton(x: number, y: number): boolean { - return x >= this.sfxButton.x && x <= this.sfxButton.x + this.sfxButton.w && y >= this.sfxButton.y && y <= this.sfxButton.y + this.sfxButton.h; - } - - private updateSlider(x: number): void { - if (this.drag < 0) return; - const t = Math.max(0, Math.min(1, (x - this.trackX) / this.trackW)); - rows[this.drag].bus().volume = t; + this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.6 }); } override draw(context: RenderingContext): void { - const app = this.app; - this.graphics.clear(); - - rows.forEach((row, index) => { - const value = row.bus().volume; - const db = 20 * Math.log10(Math.max(0.0001, value)); - this.labels[index].text = `${row.name}: ${db.toFixed(1)} dB`; - - // Trough. - this.graphics.fillColor = new Color(55, 55, 55); - this.graphics.drawRectangle(this.trackX, this.rowY[index] - 8, this.trackW, 16); - // Filled level. - this.graphics.fillColor = row.color; - this.graphics.drawRectangle(this.trackX, this.rowY[index] - 8, this.trackW * value, 16); - }); - - // SFX trigger button. - this.graphics.fillColor = new Color(200, 200, 200); - this.graphics.drawRectangle(this.sfxButton.x, this.sfxButton.y, this.sfxButton.w, this.sfxButton.h); - - context.render(this.graphics); - for (const label of this.labels) context.render(label); - context.render(this.sfxLabel); + context.render(this.diagram); + } - if (app.audio.locked) { - context.render(this.tapPrompt); - } + override destroy(): void { + this.musicVoice?.stop(); + this.panel?.dispose(); + this.hud?.dispose(); + this.diagram?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { AudioBusesScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); diff --git a/examples/audio-basics/crossfade-tracks.js b/examples/audio-basics/crossfade-tracks.js index c3003ecbe..9ce576d9a 100644 --- a/examples/audio-basics/crossfade-tracks.js +++ b/examples/audio-basics/crossfade-tracks.js @@ -1,6 +1,6 @@ // Auto-generated from crossfade-tracks.ts - edit the .ts source, not this file. import { Application, Asset, Color, crossFade, FixedResolutionCanvasSizing, Graphics, Scene, Text, Time } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; const PEAK = 0.7; const COLOR_A = new Color(120, 200, 255); const COLOR_B = new Color(255, 160, 120); @@ -11,10 +11,11 @@ class CrossfadeTracksScene extends Scene { trackB; trackAVoice; trackBVoice; - toB = true; - // Displayed meter levels, eased toward each voice's target volume. - dispA = PEAK; - dispB = 0; + mix = 0; + fadeFrom = 0; + fadeTo = 0; + fadeElapsed = 2; + fadeDuration = 2; graphics; labelA; labelB; @@ -25,6 +26,7 @@ class CrossfadeTracksScene extends Scene { meterBX = 0; meterBaseY = 0; hud; + panel; async load() { const app = this.app; const { width, height } = app; @@ -55,27 +57,34 @@ class CrossfadeTracksScene extends Scene { .setPosition(width / 2, height - 48); this.hud = mountControls({ title: 'Crossfade Tracks', - controls: [{ keys: 'Click', action: 'crossfade between Track A and Track B (2s)' }], + controls: [{ keys: 'Choose A or B', action: 'crossfade between looping tracks (2s)' }], status: 'Click or press any key to start…', - hint: 'The brighter meter with the bar above it is the active track; both loop continuously while their volumes ramp.', - }); - app.input.onPointerTap.add(() => { - // stopAfter: false keeps both loops alive so we can crossfade back. - if (this.toB) { - void crossFade(this.trackAVoice, this.trackBVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false }); - this.hud.setStatus('Crossfading to Track B…'); - } else { - void crossFade(this.trackBVoice, this.trackAVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false }); - this.hud.setStatus('Crossfading to Track A…'); - } - this.toB = !this.toB; + hint: 'The meter follows the same two-second interval scheduled for the voice gains.', }); + this.panel = mountControlPanel({ title: 'Select track' }); + this.panel.addButton({ label: 'Track A', onClick: () => this.selectTrack(0) }); + this.panel.addButton({ label: 'Track B', onClick: () => this.selectTrack(1) }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - start both loops (B silent) so // crossFade only has to ramp gains rather than start playback mid-fade. this.trackAVoice = app.audio.play(this.trackA, { loop: true, volume: PEAK }); this.trackBVoice = app.audio.play(this.trackB, { loop: true, volume: 0 }); - this.hud.setStatus('Track A active — click to crossfade.'); + this.hud.setStatus('Track A active - choose a track to crossfade.'); + } + selectTrack(target) { + if (this.app.audio.locked || this.fadeElapsed < this.fadeDuration || target === this.mix) return; + this.fadeFrom = this.mix; + this.fadeTo = target; + this.fadeElapsed = 0; + if (target === 1) void crossFade(this.trackAVoice, this.trackBVoice, Time.seconds(this.fadeDuration), { toVolume: PEAK, stopAfter: false }); + else void crossFade(this.trackBVoice, this.trackAVoice, Time.seconds(this.fadeDuration), { toVolume: PEAK, stopAfter: false }); + this.hud.setStatus(`Crossfading to Track ${target === 1 ? 'B' : 'A'}…`); + } + update(delta) { + if (this.fadeElapsed >= this.fadeDuration) return; + this.fadeElapsed = Math.min(this.fadeDuration, this.fadeElapsed + delta); + this.mix = this.fadeFrom + (this.fadeTo - this.fadeFrom) * (this.fadeElapsed / this.fadeDuration); + if (this.fadeElapsed === this.fadeDuration) this.hud.setStatus(`Track ${this.fadeTo === 1 ? 'B' : 'A'} active.`); } drawMeter(x, level, active, color) { const height = METER_H; @@ -99,12 +108,8 @@ class CrossfadeTracksScene extends Scene { draw(context) { const app = this.app; this.graphics.clear(); - // voice.volume returns the fade TARGET immediately, so ease the - // displayed level toward it for a smooth meter during the 2s ramp. - this.dispA += (this.trackAVoice.volume - this.dispA) * 0.06; - this.dispB += (this.trackBVoice.volume - this.dispB) * 0.06; - const aLevel = this.dispA; - const bLevel = this.dispB; + const aLevel = PEAK * (1 - this.mix); + const bLevel = PEAK * this.mix; const aActive = aLevel >= bLevel; this.drawMeter(this.meterAX, aLevel, aActive, COLOR_A); this.drawMeter(this.meterBX, bLevel, !aActive, COLOR_B); @@ -119,6 +124,18 @@ class CrossfadeTracksScene extends Scene { context.render(this.tapPrompt); } } + destroy() { + this.trackAVoice?.stop(); + this.trackBVoice?.stop(); + this.panel?.dispose(); + this.hud?.dispose(); + this.graphics?.destroy(); + this.labelA?.destroy(); + this.labelB?.destroy(); + this.nowPlaying?.destroy(); + this.tapPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { CrossfadeTracksScene }, diff --git a/examples/audio-basics/crossfade-tracks.ts b/examples/audio-basics/crossfade-tracks.ts index feae4f15f..98c4f714b 100644 --- a/examples/audio-basics/crossfade-tracks.ts +++ b/examples/audio-basics/crossfade-tracks.ts @@ -8,11 +8,12 @@ import { Graphics, type RenderingContext, Scene, + type Seconds, Text, Time, type Voice, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; const PEAK = 0.7; const COLOR_A = new Color(120, 200, 255); @@ -26,10 +27,11 @@ class CrossfadeTracksScene extends Scene { private trackB!: AudioStream; private trackAVoice!: Voice; private trackBVoice!: Voice; - private toB = true; - // Displayed meter levels, eased toward each voice's target volume. - private dispA = PEAK; - private dispB = 0; + private mix = 0; + private fadeFrom = 0; + private fadeTo = 0; + private fadeElapsed = 2; + private readonly fadeDuration = 2; private graphics!: Graphics; private labelA!: Text; private labelB!: Text; @@ -40,6 +42,7 @@ class CrossfadeTracksScene extends Scene { private meterBX = 0; private meterBaseY = 0; private hud!: ReturnType; + private panel!: ReturnType; override async load(): Promise { const app = this.app; @@ -76,29 +79,37 @@ class CrossfadeTracksScene extends Scene { this.hud = mountControls({ title: 'Crossfade Tracks', - controls: [{ keys: 'Click', action: 'crossfade between Track A and Track B (2s)' }], + controls: [{ keys: 'Choose A or B', action: 'crossfade between looping tracks (2s)' }], status: 'Click or press any key to start…', - hint: 'The brighter meter with the bar above it is the active track; both loop continuously while their volumes ramp.', - }); - - app.input.onPointerTap.add(() => { - // stopAfter: false keeps both loops alive so we can crossfade back. - if (this.toB) { - void crossFade(this.trackAVoice, this.trackBVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false }); - this.hud.setStatus('Crossfading to Track B…'); - } else { - void crossFade(this.trackBVoice, this.trackAVoice, Time.seconds(2), { toVolume: PEAK, stopAfter: false }); - this.hud.setStatus('Crossfading to Track A…'); - } - this.toB = !this.toB; + hint: 'The meter follows the same two-second interval scheduled for the voice gains.', }); + this.panel = mountControlPanel({ title: 'Select track' }); + this.panel.addButton({ label: 'Track A', onClick: () => this.selectTrack(0) }); + this.panel.addButton({ label: 'Track B', onClick: () => this.selectTrack(1) }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - start both loops (B silent) so // crossFade only has to ramp gains rather than start playback mid-fade. this.trackAVoice = app.audio.play(this.trackA, { loop: true, volume: PEAK }); this.trackBVoice = app.audio.play(this.trackB, { loop: true, volume: 0 }); - this.hud.setStatus('Track A active — click to crossfade.'); + this.hud.setStatus('Track A active - choose a track to crossfade.'); + } + + private selectTrack(target: 0 | 1): void { + if (this.app.audio.locked || this.fadeElapsed < this.fadeDuration || target === this.mix) return; + this.fadeFrom = this.mix; + this.fadeTo = target; + this.fadeElapsed = 0; + if (target === 1) void crossFade(this.trackAVoice, this.trackBVoice, Time.seconds(this.fadeDuration), { toVolume: PEAK, stopAfter: false }); + else void crossFade(this.trackBVoice, this.trackAVoice, Time.seconds(this.fadeDuration), { toVolume: PEAK, stopAfter: false }); + this.hud.setStatus(`Crossfading to Track ${target === 1 ? 'B' : 'A'}…`); + } + + override update(delta: Seconds): void { + if (this.fadeElapsed >= this.fadeDuration) return; + this.fadeElapsed = Math.min(this.fadeDuration, this.fadeElapsed + delta); + this.mix = this.fadeFrom + (this.fadeTo - this.fadeFrom) * (this.fadeElapsed / this.fadeDuration); + if (this.fadeElapsed === this.fadeDuration) this.hud.setStatus(`Track ${this.fadeTo === 1 ? 'B' : 'A'} active.`); } private drawMeter(x: number, level: number, active: boolean, color: Color): void { @@ -128,13 +139,8 @@ class CrossfadeTracksScene extends Scene { const app = this.app; this.graphics.clear(); - // voice.volume returns the fade TARGET immediately, so ease the - // displayed level toward it for a smooth meter during the 2s ramp. - this.dispA += (this.trackAVoice.volume - this.dispA) * 0.06; - this.dispB += (this.trackBVoice.volume - this.dispB) * 0.06; - - const aLevel = this.dispA; - const bLevel = this.dispB; + const aLevel = PEAK * (1 - this.mix); + const bLevel = PEAK * this.mix; const aActive = aLevel >= bLevel; this.drawMeter(this.meterAX, aLevel, aActive, COLOR_A); @@ -153,6 +159,19 @@ class CrossfadeTracksScene extends Scene { context.render(this.tapPrompt); } } + + override destroy(): void { + this.trackAVoice?.stop(); + this.trackBVoice?.stop(); + this.panel?.dispose(); + this.hud?.dispose(); + this.graphics?.destroy(); + this.labelA?.destroy(); + this.labelB?.destroy(); + this.nowPlaying?.destroy(); + this.tapPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/audio-basics/sound-pool.js b/examples/audio-basics/sound-pool.js index 0f87d79da..7caf18f4f 100644 --- a/examples/audio-basics/sound-pool.js +++ b/examples/audio-basics/sound-pool.js @@ -17,6 +17,7 @@ class SoundPoolScene extends Scene { voices = []; clock = 0; evictions = 0; + lastDetune = 0; hud; init() { const app = this.app; @@ -40,13 +41,13 @@ class SoundPoolScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Sound Pool', + title: 'Polyphonic Sound', controls: [ { keys: 'Click', action: 'enable audio (once)' }, { keys: 'Space', action: 'hold to spawn voices into the pool' }, ], status: 'Click or press any key to enable audio, then hold Space.', - hint: `The pool caps at ${POOL_SIZE} concurrent voices. Each slot below lights up while a voice plays; at capacity the oldest voice is evicted so playback never stacks unbounded.`, + hint: `The pool caps at ${POOL_SIZE} voices. Each shot varies pitch; at capacity, the oldest voice is evicted.`, }); this.inputs.onActive(Keyboard.Space, () => { this.firing = true; @@ -64,9 +65,11 @@ class SoundPoolScene extends Scene { this.voices.shift(); this.evictions += 1; } - this.voices.push(this.clock + this.sound.duration); // Small random pitch per shot keeps the barrage from sounding robotic. - app.audio.play(this.sound, { playbackRate: 0.85 + Math.random() * 0.3, volume: 0.5 }); + const playbackRate = 0.85 + Math.random() * 0.3; + this.lastDetune = 1200 * Math.log2(playbackRate); + this.voices.push(this.clock + this.sound.duration / playbackRate); + app.audio.play(this.sound, { playbackRate, volume: 0.5 }); } update(delta) { const app = this.app; @@ -105,7 +108,7 @@ class SoundPoolScene extends Scene { } this.graphics.drawRectangle(x, y, cell, cell); } - this.readout.text = `Active voices: ${active} / ${POOL_SIZE} evicted: ${this.evictions}`; + this.readout.text = `Voices ${active} / ${POOL_SIZE} · evicted ${this.evictions} · last pitch ${this.lastDetune >= 0 ? '+' : ''}${this.lastDetune.toFixed(0)} cents`; context.render(this.graphics); context.render(this.label); context.render(this.readout); @@ -113,6 +116,10 @@ class SoundPoolScene extends Scene { context.render(this.tapPrompt); } } + destroy() { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { SoundPoolScene }, diff --git a/examples/audio-basics/sound-pool.ts b/examples/audio-basics/sound-pool.ts index 186eccd93..cc9a620d7 100644 --- a/examples/audio-basics/sound-pool.ts +++ b/examples/audio-basics/sound-pool.ts @@ -18,6 +18,7 @@ class SoundPoolScene extends Scene { private voices: number[] = []; private clock = 0; private evictions = 0; + private lastDetune = 0; private hud!: ReturnType; override init(): void { @@ -46,13 +47,13 @@ class SoundPoolScene extends Scene { .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Sound Pool', + title: 'Polyphonic Sound', controls: [ { keys: 'Click', action: 'enable audio (once)' }, { keys: 'Space', action: 'hold to spawn voices into the pool' }, ], status: 'Click or press any key to enable audio, then hold Space.', - hint: `The pool caps at ${POOL_SIZE} concurrent voices. Each slot below lights up while a voice plays; at capacity the oldest voice is evicted so playback never stacks unbounded.`, + hint: `The pool caps at ${POOL_SIZE} voices. Each shot varies pitch; at capacity, the oldest voice is evicted.`, }); this.inputs.onActive(Keyboard.Space, () => { @@ -74,9 +75,11 @@ class SoundPoolScene extends Scene { this.evictions += 1; } - this.voices.push(this.clock + this.sound.duration); // Small random pitch per shot keeps the barrage from sounding robotic. - app.audio.play(this.sound, { playbackRate: 0.85 + Math.random() * 0.3, volume: 0.5 }); + const playbackRate = 0.85 + Math.random() * 0.3; + this.lastDetune = 1200 * Math.log2(playbackRate); + this.voices.push(this.clock + this.sound.duration / playbackRate); + app.audio.play(this.sound, { playbackRate, volume: 0.5 }); } override update(delta: Seconds): void { @@ -124,7 +127,7 @@ class SoundPoolScene extends Scene { this.graphics.drawRectangle(x, y, cell, cell); } - this.readout.text = `Active voices: ${active} / ${POOL_SIZE} evicted: ${this.evictions}`; + this.readout.text = `Voices ${active} / ${POOL_SIZE} · evicted ${this.evictions} · last pitch ${this.lastDetune >= 0 ? '+' : ''}${this.lastDetune.toFixed(0)} cents`; context.render(this.graphics); context.render(this.label); @@ -134,6 +137,11 @@ class SoundPoolScene extends Scene { context.render(this.tapPrompt); } } + + override destroy(): void { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/audio-fx/compressor.js b/examples/audio-fx/compressor.js index 284a06e19..8de60c0c6 100644 --- a/examples/audio-fx/compressor.js +++ b/examples/audio-fx/compressor.js @@ -1,126 +1,96 @@ // Auto-generated from compressor.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; import { CompressorEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; -const sliders = [ - { key: 'threshold', min: -60, max: 0 }, - { key: 'ratio', min: 1, max: 16 }, - { key: 'attack', min: 0.001, max: 0.2 }, - { key: 'release', min: 0.02, max: 0.8 }, -]; +import { mountControlPanel, mountControls } from '@examples/runtime'; class CompressorScene extends Scene { music; - filter; - gfx; - labels; - meterLabel; - tapPrompt; - drag = -1; - // Canvas-relative bar layout computed in init(). - barX = 0; - barW = 0; - labelX = 0; - rowY = []; - meterY = 0; + voice; + compressor; + meter = new Graphics(); + label; + panel; hud; + bypass = false; async load() { - const app = this.app; - const { width, height } = app; - // Wide horizontal bars centred on the 16:9 canvas; labels sit to the left. - this.barW = width * 0.45; - this.barX = width * 0.32; - this.labelX = width * 0.1; - this.rowY = sliders.map((_, i) => height * 0.26 + i * 90); - this.meterY = this.rowY[this.rowY.length - 1] + 100; - // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.music = music; - this.filter = new CompressorEffect(); - app.audio.music.addEffect(this.filter); - this.gfx = new Graphics(); - this.labels = sliders.map(() => new Text('', { fillColor: Color.white, fontSize: 16 })); - this.meterLabel = new Text('', { fillColor: Color.white, fontSize: 16 }); - this.meterLabel.setPosition(this.labelX, this.meterY - 6); - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it and the queued music starts. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + } + init() { + const audio = this.app.audio; + this.compressor = new CompressorEffect({ threshold: -30, ratio: 6 }); + audio.music.addEffect(this.compressor); + this.voice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.15 }); + this.label = new Text('', { fillColor: Color.white, fontSize: 24 }); + this.label.setPosition(380, 280); this.hud = mountControls({ - title: 'Compressor', - controls: [{ keys: 'Drag', action: 'sweep a parameter bar' }], - status: 'Click or press any key to start…', - hint: 'The red bar shows live gain reduction — louder peaks pull it further right.', + title: 'Compression', + status: 'Quiet source. Choose Loud to push peaks over the threshold.', + hint: 'The red meter reads CompressorEffect.reduction from the active audio node.', }); - app.input.onPointerDown.add(p => { - this.drag = this.sliderAt(p.y); - this.apply(p.x); + this.panel = mountControlPanel({ title: 'Compression' }); + this.panel.addButton({ label: 'Quiet input', onClick: () => this.setInput(0.15) }); + this.panel.addButton({ label: 'Loud input', onClick: () => this.setInput(1) }); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + if (value === this.bypass) return; + this.bypass = value; + if (value) audio.music.removeEffect(this.compressor); + else audio.music.addEffect(this.compressor); + this.hud.setStatus(value ? 'Compressor bypassed.' : 'Compressor active. Compare Quiet and Loud input.'); + }, }); - app.input.onPointerMove.add(p => { - this.apply(p.x); + this.panel.addSlider({ + label: 'Threshold (dB)', + min: -50, + max: -5, + step: 1, + value: -30, + onChange: value => { + this.compressor.threshold = value; + }, }); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addSlider({ + label: 'Ratio', + min: 1, + max: 12, + step: 0.5, + value: 6, + onChange: value => { + this.compressor.ratio = value; + }, }); - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); - this.hud.setStatus('Compressing music bus…'); - } - sliderAt(y) { - for (let i = 0; i < sliders.length; i++) if (Math.abs(y - this.rowY[i]) <= 16) return i; - return -1; } - apply(x) { - if (this.drag < 0) return; - const def = sliders[this.drag]; - const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW)); - this.filter[def.key] = def.min + (def.max - def.min) * t; - } - value(def) { - return this.filter[def.key]; + setInput(volume) { + this.voice.volume = volume; + this.hud.setStatus(`${volume === 1 ? 'Loud' : 'Quiet'} input; compressor ${this.bypass ? 'bypassed' : 'active'}.`); } draw(context) { - const app = this.app; - this.gfx.clear(); - for (let i = 0; i < sliders.length; i++) { - const def = sliders[i]; - const y = this.rowY[i]; - const val = this.value(def); - const t = (val - def.min) / (def.max - def.min); - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12); - this.gfx.fillColor = new Color(120, 200, 255); - this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12); - this.labels[i].text = `${def.key}: ${val.toFixed(def.key === 'ratio' ? 2 : 3)}`; - this.labels[i].setPosition(this.labelX, y - 12); - context.render(this.labels[i]); - } - const reduction = this.filter.reduction; - const meterT = Math.max(0, Math.min(1, -reduction / 24)); - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, this.meterY, this.barW, 12); - this.gfx.fillColor = new Color(255, 140, 140); - this.gfx.drawRectangle(this.barX, this.meterY, this.barW * meterT, 12); - this.meterLabel.text = `gain reduction: ${reduction.toFixed(1)} dB`; - context.render(this.meterLabel); - context.render(this.gfx); - if (app.audio.locked) { - context.render(this.tapPrompt); - } + const reduction = this.bypass ? 0 : this.compressor.reduction; + this.meter.clear(); + this.meter.fillColor = new Color(65, 70, 84); + this.meter.drawRectangle(380, 340, 520, 26); + this.meter.fillColor = new Color(245, 105, 105); + this.meter.drawRectangle(380, 340, 520 * Math.max(0, Math.min(1, -reduction / 24)), 26); + this.label.text = `Live gain reduction: ${reduction.toFixed(1)} dB`; + context.render(this.meter); + context.render(this.label); + } + destroy() { + this.voice?.stop(); + if (!this.bypass) this.app.audio.music.removeEffect(this.compressor); + this.compressor?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.meter.destroy(); + this.label?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { CompressorScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(CompressorScene); diff --git a/examples/audio-fx/compressor.ts b/examples/audio-fx/compressor.ts index e50044d49..035211cc3 100644 --- a/examples/audio-fx/compressor.ts +++ b/examples/audio-fx/compressor.ts @@ -1,152 +1,115 @@ -import { Application, Asset, AudioStream, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { + Application, + Asset, + type AudioStream, + Color, + FixedResolutionCanvasSizing, + Graphics, + type RenderingContext, + Scene, + Text, + type Voice, +} from '@codexo/exojs'; import { CompressorEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; - -type CompressorParam = 'threshold' | 'ratio' | 'attack' | 'release'; - -interface SliderDef { - key: CompressorParam; - min: number; - max: number; -} - -const sliders: SliderDef[] = [ - { key: 'threshold', min: -60, max: 0 }, - { key: 'ratio', min: 1, max: 16 }, - { key: 'attack', min: 0.001, max: 0.2 }, - { key: 'release', min: 0.02, max: 0.8 }, -]; +import { mountControlPanel, mountControls } from '@examples/runtime'; class CompressorScene extends Scene { private music!: AudioStream; - private filter!: CompressorEffect; - private gfx!: Graphics; - private labels!: Text[]; - private meterLabel!: Text; - private tapPrompt!: Text; - private drag = -1; - // Canvas-relative bar layout computed in init(). - private barX = 0; - private barW = 0; - private labelX = 0; - private rowY: number[] = []; - private meterY = 0; + private voice!: Voice; + private compressor!: CompressorEffect; + private meter = new Graphics(); + private label!: Text; + private panel!: ReturnType; private hud!: ReturnType; + private bypass = false; override async load(): Promise { - const app = this.app; - const { width, height } = app; - - // Wide horizontal bars centred on the 16:9 canvas; labels sit to the left. - this.barW = width * 0.45; - this.barX = width * 0.32; - this.labelX = width * 0.1; - this.rowY = sliders.map((_, i) => height * 0.26 + i * 90); - this.meterY = this.rowY[this.rowY.length - 1] + 100; - - // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.music = music; - this.filter = new CompressorEffect(); - app.audio.music.addEffect(this.filter); - - this.gfx = new Graphics(); - this.labels = sliders.map(() => new Text('', { fillColor: Color.white, fontSize: 16 })); - this.meterLabel = new Text('', { fillColor: Color.white, fontSize: 16 }); - this.meterLabel.setPosition(this.labelX, this.meterY - 6); + this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + } - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it and the queued music starts. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + override init(): void { + const audio = this.app.audio; + this.compressor = new CompressorEffect({ threshold: -30, ratio: 6 }); + audio.music.addEffect(this.compressor); + this.voice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.15 }); + this.label = new Text('', { fillColor: Color.white, fontSize: 24 }); + this.label.setPosition(380, 280); this.hud = mountControls({ - title: 'Compressor', - controls: [{ keys: 'Drag', action: 'sweep a parameter bar' }], - status: 'Click or press any key to start…', - hint: 'The red bar shows live gain reduction — louder peaks pull it further right.', + title: 'Compression', + status: 'Quiet source. Choose Loud to push peaks over the threshold.', + hint: 'The red meter reads CompressorEffect.reduction from the active audio node.', }); - - app.input.onPointerDown.add(p => { - this.drag = this.sliderAt(p.y); - this.apply(p.x); + this.panel = mountControlPanel({ title: 'Compression' }); + this.panel.addButton({ label: 'Quiet input', onClick: () => this.setInput(0.15) }); + this.panel.addButton({ label: 'Loud input', onClick: () => this.setInput(1) }); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + if (value === this.bypass) return; + this.bypass = value; + if (value) audio.music.removeEffect(this.compressor); + else audio.music.addEffect(this.compressor); + this.hud.setStatus(value ? 'Compressor bypassed.' : 'Compressor active. Compare Quiet and Loud input.'); + }, }); - app.input.onPointerMove.add(p => { - this.apply(p.x); + this.panel.addSlider({ + label: 'Threshold (dB)', + min: -50, + max: -5, + step: 1, + value: -30, + onChange: value => { + this.compressor.threshold = value; + }, }); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addSlider({ + label: 'Ratio', + min: 1, + max: 12, + step: 0.5, + value: 6, + onChange: value => { + this.compressor.ratio = value; + }, }); - - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); - this.hud.setStatus('Compressing music bus…'); - } - - private sliderAt(y: number): number { - for (let i = 0; i < sliders.length; i++) if (Math.abs(y - this.rowY[i]) <= 16) return i; - return -1; - } - - private apply(x: number): void { - if (this.drag < 0) return; - const def = sliders[this.drag]; - const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW)); - this.filter[def.key] = def.min + (def.max - def.min) * t; } - private value(def: SliderDef): number { - return this.filter[def.key]; + private setInput(volume: number): void { + this.voice.volume = volume; + this.hud.setStatus(`${volume === 1 ? 'Loud' : 'Quiet'} input; compressor ${this.bypass ? 'bypassed' : 'active'}.`); } override draw(context: RenderingContext): void { - const app = this.app; - this.gfx.clear(); - for (let i = 0; i < sliders.length; i++) { - const def = sliders[i]; - const y = this.rowY[i]; - const val = this.value(def); - const t = (val - def.min) / (def.max - def.min); - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12); - this.gfx.fillColor = new Color(120, 200, 255); - this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12); - this.labels[i].text = `${def.key}: ${val.toFixed(def.key === 'ratio' ? 2 : 3)}`; - this.labels[i].setPosition(this.labelX, y - 12); - context.render(this.labels[i]); - } - - const reduction = this.filter.reduction; - const meterT = Math.max(0, Math.min(1, -reduction / 24)); - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, this.meterY, this.barW, 12); - this.gfx.fillColor = new Color(255, 140, 140); - this.gfx.drawRectangle(this.barX, this.meterY, this.barW * meterT, 12); - this.meterLabel.text = `gain reduction: ${reduction.toFixed(1)} dB`; - context.render(this.meterLabel); - - context.render(this.gfx); + const reduction = this.bypass ? 0 : this.compressor.reduction; + this.meter.clear(); + this.meter.fillColor = new Color(65, 70, 84); + this.meter.drawRectangle(380, 340, 520, 26); + this.meter.fillColor = new Color(245, 105, 105); + this.meter.drawRectangle(380, 340, 520 * Math.max(0, Math.min(1, -reduction / 24)), 26); + this.label.text = `Live gain reduction: ${reduction.toFixed(1)} dB`; + context.render(this.meter); + context.render(this.label); + } - if (app.audio.locked) { - context.render(this.tapPrompt); - } + override destroy(): void { + this.voice?.stop(); + if (!this.bypass) this.app.audio.music.removeEffect(this.compressor); + this.compressor?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.meter.destroy(); + this.label?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { CompressorScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(CompressorScene); diff --git a/examples/audio-fx/effect-chains.js b/examples/audio-fx/effect-chains.js index 91be35ae5..ecf4337bb 100644 --- a/examples/audio-fx/effect-chains.js +++ b/examples/audio-fx/effect-chains.js @@ -1,353 +1,172 @@ // Auto-generated from effect-chains.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, HighpassFilter, Keyboard, LowpassFilter, Scene, Text } from '@codexo/exojs'; -import { - AudioAnalyser, - AutoWahEffect, - BitCrusherEffect, - ChorusEffect, - CompressorEffect, - ConvolutionEffect, - DistortionEffect, - EqualizerEffect, - FlangerEffect, - GranularEffect, - LimiterEffect, - PhaserEffect, - PingPongDelayEffect, - PitchShiftEffect, - ReverbEffect, - RingModulatorEffect, - TremoloEffect, -} from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; -// Ten fixed chains. Order matters inside each one: the chain is wired -// front-to-back, so a filter placed before a distortion shapes what the -// distortion has to work with, not what it produced. +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, HighpassFilter, LowpassFilter, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, BitCrusherEffect, ChorusEffect, DistortionEffect, PingPongDelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; +import { mountControlPanel, mountControls } from '@examples/runtime'; const CHAINS = [ { name: 'Telephone', - note: 'Band-limit first, then saturate what is left', + note: 'Band-limit the sound before adding saturation.', stages: [ - { name: 'Highpass', detail: '800 Hz', create: () => new HighpassFilter({ frequency: 800 }) }, - { name: 'Lowpass', detail: '2.5 kHz', create: () => new LowpassFilter({ frequency: 2500 }) }, - { name: 'Distortion', detail: 'drive 0.35', create: () => new DistortionEffect({ drive: 0.35, tone: 0.5, wet: 0.8 }) }, + { name: 'Highpass', create: () => new HighpassFilter({ frequency: 800 }) }, + { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 2500 }) }, + { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.35, wet: 0.8 }) }, ], }, { name: 'Underwater', - note: 'Everything above 400 Hz is gone before the chorus detunes it', + note: 'Remove highs, then add a slow chorus and room tail.', stages: [ - { name: 'Lowpass', detail: '420 Hz, Q 2', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, - { name: 'Chorus', detail: '0.6 Hz, 8 ms', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, - { name: 'Reverb', detail: '3 s tail', create: () => new ReverbEffect({ durationSeconds: 3, decay: 2, wet: 0.45 }) }, + { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, + { name: 'Chorus', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, + { name: 'Reverb', create: () => new ReverbEffect({ decay: 2, wet: 0.45 }) }, ], }, { - name: 'Arcade Cabinet', - note: 'Quantise to 5 bits, then push the mids to imitate a small speaker', + name: 'Arcade', + note: 'Quantize the samples, then warm the edges with distortion.', stages: [ - { name: 'BitCrusher', detail: '5 bits', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, - { name: 'Distortion', detail: 'drive 0.25', create: () => new DistortionEffect({ drive: 0.25, tone: 0.7, wet: 0.6 }) }, - { name: 'Equalizer', detail: '+6 dB @ 1.2 kHz', create: () => new EqualizerEffect({ low: -6, mid: 6, high: -3, midFrequency: 1200 }) }, + { name: 'BitCrusher', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, + { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.25, wet: 0.6 }) }, ], }, { - name: 'Jet Flyby', - note: 'High feedback turns the flanger into a resonant comb sweep', + name: 'Space Echo', + note: 'Stereo repeats follow a short room reflection.', stages: [ - { - name: 'Flanger', - detail: '0.15 Hz, fb 0.85', - create: () => new FlangerEffect({ delayMs: 3, depthMs: 4, rateHz: 0.15, feedback: 0.85, wet: 0.6 }), - }, - { name: 'Limiter', detail: '-6 dBFS', create: () => new LimiterEffect({ threshold: -6 }) }, - ], - }, - { - name: 'Space Wash', - note: 'Eight allpass stages sweeping into cross-channel echoes', - stages: [ - { - name: 'Phaser', - detail: '8 stages, 0.3 Hz', - create: () => new PhaserEffect({ stages: 8, rateHz: 0.3, baseFrequency: 400, depth: 0.8, feedback: 0.6, wet: 0.6 }), - }, - { name: 'PingPongDelay', detail: '320 ms, fb 0.5', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.4 }) }, - ], - }, - { - name: 'Pulse Gate', - note: 'The tremolo chops the signal; the wah follows each chop', - stages: [ - { name: 'Tremolo', detail: '6 Hz, auto-pan', create: () => new TremoloEffect({ rateHz: 6, depth: 0.5, autoPan: true }) }, - { - name: 'AutoWah', - detail: '180 Hz, Q 6', - create: () => new AutoWahEffect({ baseFrequency: 180, sensitivity: 3500, q: 6, responseMs: 20, wet: 0.8 }), - }, - ], - }, - { - name: 'Alien Voice', - note: 'Ring modulation adds inharmonic sidebands, then everything drops a fifth', - stages: [ - { name: 'RingModulator', detail: '110 Hz sine', create: () => new RingModulatorEffect({ frequency: 110, wet: 0.7 }) }, - { name: 'PitchShift', detail: '0.7x', create: () => new PitchShiftEffect({ pitch: 0.7 }) }, - ], - }, - { - name: 'Ghost', - note: 'Grains scattered across a 2 s window, smeared into a long tail', - stages: [ - { - name: 'Granular', - detail: '80 ms, 30/s', - create: () => new GranularEffect({ grainSize: 0.08, density: 30, spread: 0.7, pitchMin: 0.98, pitchMax: 1.02, wet: 0.9, normalizeGain: true }), - }, - { name: 'Reverb', detail: '4 s tail', create: () => new ReverbEffect({ durationSeconds: 4, decay: 2.5, wet: 0.5 }) }, - ], - }, - { - name: 'Cathedral', - note: 'A recorded 593 ms room, with the rumble filtered back out', - stages: [ - { name: 'Convolution', detail: 'AK-SROOMS_016', create: ({ impulse }) => new ConvolutionEffect({ impulse, wet: 0.9 }) }, - { name: 'Highpass', detail: '180 Hz', create: () => new HighpassFilter({ frequency: 180 }) }, - ], - }, - { - name: 'Radio Squash', - note: 'Dynamics flattened until the quiet parts are as loud as the peaks', - stages: [ - { - name: 'Compressor', - detail: '-28 dB, 12:1', - create: () => new CompressorEffect({ threshold: -28, ratio: 12, attack: 0.003, release: 0.15, knee: 6 }), - }, - { name: 'Limiter', detail: '-2 dBFS', create: () => new LimiterEffect({ threshold: -2 }) }, - { name: 'Equalizer', detail: '+5 dB mid', create: () => new EqualizerEffect({ low: -4, mid: 5, high: 2 }) }, + { name: 'Reverb', create: () => new ReverbEffect({ decay: 1.5, wet: 0.25 }) }, + { name: 'PingPongDelay', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.45 }) }, ], }, ]; -const BANDS = 32; -const SLOT_WIDTH = 360; -const SLOT_HEIGHT = 110; -const SLOT_GAP = 24; -// The controls overlay occupies the top-left corner, so the chain row starts -// below it rather than beside it. -const SLOT_TOP = 250; -const GRAPH_INSET = 80; -const GRAPH_FLOOR = 640; -const GRAPH_HEIGHT = 220; -const NAME_COLOR = Color.white; -const NAME_COLOR_DIM = new Color(84, 90, 104); -const DETAIL_COLOR = new Color(140, 152, 176); -const DETAIL_COLOR_DIM = new Color(64, 70, 82); -const SLOT_COLOR_DIM = new Color(26, 28, 34); -const BAR_COLOR_DIM = new Color(58, 62, 72); -// One colour per band, blue at the bottom to warm at the top. Precomputed so -// the draw loop never allocates. -const BAR_COLORS = Array.from({ length: BANDS }, (_, i) => { - const tilt = i / (BANDS - 1); - return new Color(Math.floor(70 + tilt * 185), Math.floor(190 - tilt * 70), Math.floor(255 - tilt * 75)); -}); class EffectChainsScene extends Scene { music; impact; - impulse; + musicVoice; analyser; - spectrum = new Uint8Array(BANDS); - /** The effects currently inserted on the master bus, in chain order. */ + spectrum = new Uint8Array(16); active = []; - index = 0; - bypassed = false; - preparing = false; - flash = 0; - gfx; + selected = 0; + bypass = false; + generation = 0; + rack = new Graphics(); title; - subtitle; - slotNames = []; - slotDetails = []; - arrows = []; - tapPrompt; + stageLabels = []; + panel; hud; async load() { - const app = this.app; - const { width, height } = app; - // AudioStream has no seamless adapter - await it explicitly. The - // impulse response is awaited too, so the Cathedral chain can hand a - // decoded buffer to the convolver the moment it is selected. this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impulse = await this.loader.load(Asset.type('sound', 'audio/ir/AK-SROOMS_016.wav')); this.impact = this.loader.get('audio/impact-light.ogg'); - // Chains live on the master bus, not on `audio.music` - that way the - // looping track and the one-shot impact both run through the same - // effects instead of only whichever bus owns them. - this.analyser = new AudioAnalyser({ source: app.audio.master, fftSize: 2048, smoothingTimeConstant: 0.75 }); - this.gfx = new Graphics(); - this.title = new Text('', { fillColor: Color.white, fontSize: 38 }).setAnchor(0.5, 0.5).setPosition(width / 2, 72); - this.subtitle = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 19 }).setAnchor(0.5, 0.5).setPosition(width / 2, 120); - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 24 }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 44); - // Three slots is the longest chain; shorter ones hide the extra nodes. - for (let i = 0; i < 3; i++) { - this.slotNames.push(new Text('', { fillColor: Color.white, fontSize: 26 }).setAnchor(0.5, 0.5)); - this.slotDetails.push(new Text('', { fillColor: DETAIL_COLOR, fontSize: 18 }).setAnchor(0.5, 0.5)); - } - for (let i = 0; i < 2; i++) { - this.arrows.push(new Text('>', { fillColor: new Color(90, 100, 120), fontSize: 26 }).setAnchor(0.5, 0.5)); - } + } + init() { + const audio = this.app.audio; + this.analyser = new AudioAnalyser({ source: audio.master, fftSize: 2048, smoothingTimeConstant: 0.75 }); + this.title = new Text('', { fillColor: Color.white, fontSize: 30, align: 'center' }).setAnchor(0.5).setPosition(640, 170); + this.stageLabels = Array.from({ length: 3 }, () => new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }).setAnchor(0.5)); this.hud = mountControls({ - title: 'Effect chains', - controls: [ - { keys: '< / >', action: 'previous / next chain' }, - { keys: 'Space', action: 'bypass the chain (A/B against dry)' }, - { keys: 'Click', action: 'fire an impact through the chain' }, - ], - status: 'Click or press any key to start…', + title: 'Audio Effect Rack', + status: 'Choose a chain and compare it with dry output.', + hint: 'Effects run in the order shown on the rack.', }); - this.inputs.onTrigger(Keyboard.Right, () => this.select(this.index + 1)); - this.inputs.onTrigger(Keyboard.Left, () => this.select(this.index - 1)); - this.inputs.onTrigger(Keyboard.Space, () => this.toggleBypass()); - app.input.onPointerTap.add(() => this.strike()); - this.root.addChild(this.gfx, this.title, this.subtitle, ...this.slotNames, ...this.slotDetails, ...this.arrows, this.tapPrompt); - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.7 }); - this.select(0); - } - select(next) { - this.index = (next + CHAINS.length) % CHAINS.length; + this.panel = mountControlPanel({ title: 'Effect rack' }); + CHAINS.forEach((chain, index) => this.panel.addButton({ label: chain.name, onClick: () => this.select(index) })); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + this.bypass = value; + this.rebuild(); + }, + }); + this.panel.addButton({ + label: 'Play impact', + onClick: () => { + if (!audio.locked) audio.play(this.impact); + }, + }); + this.musicVoice = audio.play(this.music, { loop: true, volume: 0.7 }); this.rebuild(); } - toggleBypass() { - this.bypassed = !this.bypassed; + select(index) { + if (index === this.selected) return; + this.selected = index; this.rebuild(); } - /** - * Tear the current chain down and - unless bypassed - build the selected - * one. Bypass is not a separate code path: it is simply the teardown - * without the rebuild, so there is only one way effects reach the bus. - */ rebuild() { const master = this.app.audio.master; + const generation = ++this.generation; for (const effect of this.active) { master.removeEffect(effect); effect.destroy(); } this.active = []; - const chain = CHAINS[this.index]; - if (!this.bypassed) { - for (const stage of chain.stages) { - const effect = stage.create({ impulse: this.impulse }); - master.addEffect(effect); - this.active.push(effect); - } - } - // Worklet-backed effects (BitCrusher, PitchShift, Granular) pass audio - // through untouched until their processor module has loaded, so a - // freshly selected chain can be inaudible for a frame or two. - const built = this.active; - this.preparing = built.length > 0; - void Promise.all(built.map(effect => effect.ready)).then(() => { - if (this.active === built) { - this.preparing = false; + const chain = CHAINS[this.selected]; + this.title.text = `${chain.name}: ${chain.stages.map(stage => stage.name).join(' -> ')}`; + const slotWidth = 820 / chain.stages.length; + this.stageLabels.forEach((label, index) => { + label.visible = index < chain.stages.length; + if (label.visible) { + label.text = chain.stages[index].name; + label.setPosition(225 + (index + 0.5) * slotWidth - 6, 305); } }); - const width = this.app.width; - this.title.text = chain.name; - this.subtitle.text = `chain ${this.index + 1} of ${CHAINS.length}`; - // The per-chain note goes in the overlay: on the canvas it would run - // under the controls panel. this.hud.setHint(chain.note); - const count = chain.stages.length; - const totalWidth = count * SLOT_WIDTH + (count - 1) * SLOT_GAP; - const left = (width - totalWidth) / 2; - for (let i = 0; i < this.slotNames.length; i++) { - const stage = chain.stages[i]; - const slotCentre = left + i * (SLOT_WIDTH + SLOT_GAP) + SLOT_WIDTH / 2; - this.slotNames[i].visible = stage !== undefined; - this.slotDetails[i].visible = stage !== undefined; - if (stage) { - this.slotNames[i].text = stage.name; - this.slotNames[i].setPosition(slotCentre, SLOT_TOP + SLOT_HEIGHT / 2 - 16); - this.slotDetails[i].text = stage.detail; - this.slotDetails[i].setPosition(slotCentre, SLOT_TOP + SLOT_HEIGHT / 2 + 22); - } - } - for (let i = 0; i < this.arrows.length; i++) { - const visible = i < count - 1; - this.arrows[i].visible = visible; - if (visible) { - this.arrows[i].setPosition(left + (i + 1) * SLOT_WIDTH + i * SLOT_GAP + SLOT_GAP / 2, SLOT_TOP + SLOT_HEIGHT / 2); - } - } - for (const text of this.slotNames) { - text.style.fillColor = this.bypassed ? NAME_COLOR_DIM : NAME_COLOR; + if (this.bypass) { + this.hud.setStatus(`${chain.name} bypassed; dry signal.`); + return; } - for (const text of this.slotDetails) { - text.style.fillColor = this.bypassed ? DETAIL_COLOR_DIM : DETAIL_COLOR; + for (const stage of chain.stages) { + const effect = stage.create(); + master.addEffect(effect); + this.active.push(effect); } + this.hud.setStatus(`${chain.name}: preparing ${this.active.length} effects...`); + void Promise.all(this.active.map(effect => effect.ready)).then( + () => { + if (generation === this.generation) this.hud.setStatus(`${chain.name}: ${this.active.length} effects ready.`); + }, + error => { + if (generation === this.generation) this.hud.setStatus(`${chain.name}: ${error instanceof Error ? error.message : String(error)}`); + }, + ); } - strike() { - if (this.app.audio.locked) { - return; + draw(context) { + this.analyser.getSpectrumLog(this.spectrum, { bands: this.spectrum.length, fMin: 40, fMax: 16000 }); + this.rack.clear(); + const stages = CHAINS[this.selected].stages; + const width = 820 / stages.length; + stages.forEach((_, index) => { + this.rack.fillColor = this.bypass ? new Color(46, 49, 55) : new Color(53, 93, 121); + this.rack.drawRoundedRectangle(225 + index * width, 260, width - 12, 90, 12); + }); + for (let index = 0; index < this.spectrum.length; index++) { + this.rack.fillColor = new Color(85 + index * 8, 180, 230 - index * 5); + this.rack.drawRectangle(120 + index * 65, 600 - this.spectrum[index] * 0.7, 48, this.spectrum[index] * 0.7); } - this.app.audio.play(this.impact); - this.flash = 1; + context.render(this.rack); + context.render(this.title); + for (const label of this.stageLabels) if (label.visible) context.render(label); } - update(time) { - this.flash = Math.max(0, this.flash - time * 2.5); - this.tapPrompt.visible = this.app.audio.locked; - const chain = CHAINS[this.index]; - if (this.app.audio.locked) { - this.hud.setStatus('Click or press any key to start…'); - } else if (this.bypassed) { - this.hud.setStatus('Bypassed — this is the dry signal'); - } else if (this.preparing) { - this.hud.setStatus(`${chain.name} — loading worklets…`); - } else { - this.hud.setStatus(`${chain.name} — ${this.active.length} effects on master`); - } - this.analyser.getSpectrumLog(this.spectrum, { bands: BANDS, fMin: 40, fMax: 16000 }); - const width = this.app.width; - const count = chain.stages.length; - const totalWidth = count * SLOT_WIDTH + (count - 1) * SLOT_GAP; - const left = (width - totalWidth) / 2; - const dim = this.bypassed; - this.gfx.clear(); - // Chain nodes. A bypassed chain keeps its boxes in place and greys them - // out, so the row never jumps while comparing against the dry signal. - const lift = Math.floor(this.flash * 40); - const slotColor = dim ? SLOT_COLOR_DIM : new Color(38 + lift, 52 + lift, 74 + lift); - for (let i = 0; i < count; i++) { - this.gfx.fillColor = slotColor; - this.gfx.drawRectangle(left + i * (SLOT_WIDTH + SLOT_GAP), SLOT_TOP, SLOT_WIDTH, SLOT_HEIGHT); - } - // Spectrum, measured on the master output - that is downstream of the - // chain, so the bars show what actually reaches the speakers. - const barWidth = (width - GRAPH_INSET * 2) / BANDS; - for (let i = 0; i < BANDS; i++) { - const barHeight = Math.max(2, (this.spectrum[i] / 255) * GRAPH_HEIGHT); - this.gfx.fillColor = dim ? BAR_COLOR_DIM : BAR_COLORS[i]; - this.gfx.drawRectangle(GRAPH_INSET + i * barWidth, GRAPH_FLOOR - barHeight, barWidth - 4, barHeight); + destroy() { + ++this.generation; + this.musicVoice?.stop(); + for (const effect of this.active) { + this.app.audio.master.removeEffect(effect); + effect.destroy(); } - } - draw(context) { - context.render(this.root); + this.active = []; + this.analyser?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.rack.destroy(); + this.title?.destroy(); + for (const label of this.stageLabels) label.destroy(); + super.destroy(); } } -const application = new Application({ - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, +const app = new Application({ + scenes: { EffectChainsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -void application.start(EffectChainsScene); +await app.start(EffectChainsScene); diff --git a/examples/audio-fx/effect-chains.ts b/examples/audio-fx/effect-chains.ts index b769f511f..8750b3cfa 100644 --- a/examples/audio-fx/effect-chains.ts +++ b/examples/audio-fx/effect-chains.ts @@ -7,428 +7,196 @@ import { FixedResolutionCanvasSizing, Graphics, HighpassFilter, - Keyboard, LowpassFilter, type RenderingContext, Scene, - type Seconds, type Sound, Text, + type Voice, } from '@codexo/exojs'; -import { - AudioAnalyser, - AutoWahEffect, - BitCrusherEffect, - ChorusEffect, - CompressorEffect, - ConvolutionEffect, - DistortionEffect, - EqualizerEffect, - FlangerEffect, - GranularEffect, - LimiterEffect, - PhaserEffect, - PingPongDelayEffect, - PitchShiftEffect, - ReverbEffect, - RingModulatorEffect, - TremoloEffect, -} from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; - -/** Everything a stage needs from the scene to build its effect. */ -interface ChainContext { - impulse: Sound; -} - -/** One effect in a chain, with the parameter that gives it its character. */ -interface Stage { - name: string; - detail: string; - create(context: ChainContext): AudioEffect; -} +import { AudioAnalyser, BitCrusherEffect, ChorusEffect, DistortionEffect, PingPongDelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; +import { mountControlPanel, mountControls } from '@examples/runtime'; interface Chain { name: string; note: string; - stages: Stage[]; + stages: { name: string; create(): AudioEffect }[]; } -// Ten fixed chains. Order matters inside each one: the chain is wired -// front-to-back, so a filter placed before a distortion shapes what the -// distortion has to work with, not what it produced. const CHAINS: Chain[] = [ { name: 'Telephone', - note: 'Band-limit first, then saturate what is left', + note: 'Band-limit the sound before adding saturation.', stages: [ - { name: 'Highpass', detail: '800 Hz', create: () => new HighpassFilter({ frequency: 800 }) }, - { name: 'Lowpass', detail: '2.5 kHz', create: () => new LowpassFilter({ frequency: 2500 }) }, - { name: 'Distortion', detail: 'drive 0.35', create: () => new DistortionEffect({ drive: 0.35, tone: 0.5, wet: 0.8 }) }, + { name: 'Highpass', create: () => new HighpassFilter({ frequency: 800 }) }, + { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 2500 }) }, + { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.35, wet: 0.8 }) }, ], }, { name: 'Underwater', - note: 'Everything above 400 Hz is gone before the chorus detunes it', - stages: [ - { name: 'Lowpass', detail: '420 Hz, Q 2', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, - { name: 'Chorus', detail: '0.6 Hz, 8 ms', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, - { name: 'Reverb', detail: '3 s tail', create: () => new ReverbEffect({ durationSeconds: 3, decay: 2, wet: 0.45 }) }, - ], - }, - { - name: 'Arcade Cabinet', - note: 'Quantise to 5 bits, then push the mids to imitate a small speaker', - stages: [ - { name: 'BitCrusher', detail: '5 bits', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, - { name: 'Distortion', detail: 'drive 0.25', create: () => new DistortionEffect({ drive: 0.25, tone: 0.7, wet: 0.6 }) }, - { name: 'Equalizer', detail: '+6 dB @ 1.2 kHz', create: () => new EqualizerEffect({ low: -6, mid: 6, high: -3, midFrequency: 1200 }) }, - ], - }, - { - name: 'Jet Flyby', - note: 'High feedback turns the flanger into a resonant comb sweep', - stages: [ - { - name: 'Flanger', - detail: '0.15 Hz, fb 0.85', - create: () => new FlangerEffect({ delayMs: 3, depthMs: 4, rateHz: 0.15, feedback: 0.85, wet: 0.6 }), - }, - { name: 'Limiter', detail: '-6 dBFS', create: () => new LimiterEffect({ threshold: -6 }) }, - ], - }, - { - name: 'Space Wash', - note: 'Eight allpass stages sweeping into cross-channel echoes', - stages: [ - { - name: 'Phaser', - detail: '8 stages, 0.3 Hz', - create: () => new PhaserEffect({ stages: 8, rateHz: 0.3, baseFrequency: 400, depth: 0.8, feedback: 0.6, wet: 0.6 }), - }, - { name: 'PingPongDelay', detail: '320 ms, fb 0.5', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.4 }) }, - ], - }, - { - name: 'Pulse Gate', - note: 'The tremolo chops the signal; the wah follows each chop', - stages: [ - { name: 'Tremolo', detail: '6 Hz, auto-pan', create: () => new TremoloEffect({ rateHz: 6, depth: 0.5, autoPan: true }) }, - { - name: 'AutoWah', - detail: '180 Hz, Q 6', - create: () => new AutoWahEffect({ baseFrequency: 180, sensitivity: 3500, q: 6, responseMs: 20, wet: 0.8 }), - }, - ], - }, - { - name: 'Alien Voice', - note: 'Ring modulation adds inharmonic sidebands, then everything drops a fifth', + note: 'Remove highs, then add a slow chorus and room tail.', stages: [ - { name: 'RingModulator', detail: '110 Hz sine', create: () => new RingModulatorEffect({ frequency: 110, wet: 0.7 }) }, - { name: 'PitchShift', detail: '0.7x', create: () => new PitchShiftEffect({ pitch: 0.7 }) }, + { name: 'Lowpass', create: () => new LowpassFilter({ frequency: 420, resonance: 2 }) }, + { name: 'Chorus', create: () => new ChorusEffect({ delayMs: 25, depthMs: 8, rateHz: 0.6, wet: 0.6 }) }, + { name: 'Reverb', create: () => new ReverbEffect({ decay: 2, wet: 0.45 }) }, ], }, { - name: 'Ghost', - note: 'Grains scattered across a 2 s window, smeared into a long tail', + name: 'Arcade', + note: 'Quantize the samples, then warm the edges with distortion.', stages: [ - { - name: 'Granular', - detail: '80 ms, 30/s', - create: () => new GranularEffect({ grainSize: 0.08, density: 30, spread: 0.7, pitchMin: 0.98, pitchMax: 1.02, wet: 0.9, normalizeGain: true }), - }, - { name: 'Reverb', detail: '4 s tail', create: () => new ReverbEffect({ durationSeconds: 4, decay: 2.5, wet: 0.5 }) }, + { name: 'BitCrusher', create: () => new BitCrusherEffect({ bits: 5, frequencyReduction: 0.35 }) }, + { name: 'Distortion', create: () => new DistortionEffect({ drive: 0.25, wet: 0.6 }) }, ], }, { - name: 'Cathedral', - note: 'A recorded 593 ms room, with the rumble filtered back out', + name: 'Space Echo', + note: 'Stereo repeats follow a short room reflection.', stages: [ - { name: 'Convolution', detail: 'AK-SROOMS_016', create: ({ impulse }) => new ConvolutionEffect({ impulse, wet: 0.9 }) }, - { name: 'Highpass', detail: '180 Hz', create: () => new HighpassFilter({ frequency: 180 }) }, - ], - }, - { - name: 'Radio Squash', - note: 'Dynamics flattened until the quiet parts are as loud as the peaks', - stages: [ - { - name: 'Compressor', - detail: '-28 dB, 12:1', - create: () => new CompressorEffect({ threshold: -28, ratio: 12, attack: 0.003, release: 0.15, knee: 6 }), - }, - { name: 'Limiter', detail: '-2 dBFS', create: () => new LimiterEffect({ threshold: -2 }) }, - { name: 'Equalizer', detail: '+5 dB mid', create: () => new EqualizerEffect({ low: -4, mid: 5, high: 2 }) }, + { name: 'Reverb', create: () => new ReverbEffect({ decay: 1.5, wet: 0.25 }) }, + { name: 'PingPongDelay', create: () => new PingPongDelayEffect({ delayTime: 0.32, feedback: 0.5, wet: 0.45 }) }, ], }, ]; -const BANDS = 32; -const SLOT_WIDTH = 360; -const SLOT_HEIGHT = 110; -const SLOT_GAP = 24; -// The controls overlay occupies the top-left corner, so the chain row starts -// below it rather than beside it. -const SLOT_TOP = 250; -const GRAPH_INSET = 80; -const GRAPH_FLOOR = 640; -const GRAPH_HEIGHT = 220; - -const NAME_COLOR = Color.white; -const NAME_COLOR_DIM = new Color(84, 90, 104); -const DETAIL_COLOR = new Color(140, 152, 176); -const DETAIL_COLOR_DIM = new Color(64, 70, 82); -const SLOT_COLOR_DIM = new Color(26, 28, 34); -const BAR_COLOR_DIM = new Color(58, 62, 72); - -// One colour per band, blue at the bottom to warm at the top. Precomputed so -// the draw loop never allocates. -const BAR_COLORS = Array.from({ length: BANDS }, (_, i) => { - const tilt = i / (BANDS - 1); - return new Color(Math.floor(70 + tilt * 185), Math.floor(190 - tilt * 70), Math.floor(255 - tilt * 75)); -}); - class EffectChainsScene extends Scene { private music!: AudioStream; private impact!: Sound; - private impulse!: Sound; + private musicVoice!: Voice; private analyser!: AudioAnalyser; - private readonly spectrum = new Uint8Array(BANDS); - - /** The effects currently inserted on the master bus, in chain order. */ + private readonly spectrum = new Uint8Array(16); private active: AudioEffect[] = []; - private index = 0; - private bypassed = false; - private preparing = false; - private flash = 0; - - private gfx!: Graphics; + private selected = 0; + private bypass = false; + private generation = 0; + private rack = new Graphics(); private title!: Text; - private subtitle!: Text; - private slotNames: Text[] = []; - private slotDetails: Text[] = []; - private arrows: Text[] = []; - private tapPrompt!: Text; + private stageLabels: Text[] = []; + private panel!: ReturnType; private hud!: ReturnType; override async load(): Promise { - const app = this.app; - const { width, height } = app; - - // AudioStream has no seamless adapter - await it explicitly. The - // impulse response is awaited too, so the Cathedral chain can hand a - // decoded buffer to the convolver the moment it is selected. this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impulse = await this.loader.load(Asset.type('sound', 'audio/ir/AK-SROOMS_016.wav')); this.impact = this.loader.get('audio/impact-light.ogg'); + } - // Chains live on the master bus, not on `audio.music` - that way the - // looping track and the one-shot impact both run through the same - // effects instead of only whichever bus owns them. - this.analyser = new AudioAnalyser({ source: app.audio.master, fftSize: 2048, smoothingTimeConstant: 0.75 }); - - this.gfx = new Graphics(); - this.title = new Text('', { fillColor: Color.white, fontSize: 38 }).setAnchor(0.5, 0.5).setPosition(width / 2, 72); - this.subtitle = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 19 }).setAnchor(0.5, 0.5).setPosition(width / 2, 120); - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 24 }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 44); - - // Three slots is the longest chain; shorter ones hide the extra nodes. - for (let i = 0; i < 3; i++) { - this.slotNames.push(new Text('', { fillColor: Color.white, fontSize: 26 }).setAnchor(0.5, 0.5)); - this.slotDetails.push(new Text('', { fillColor: DETAIL_COLOR, fontSize: 18 }).setAnchor(0.5, 0.5)); - } - for (let i = 0; i < 2; i++) { - this.arrows.push(new Text('>', { fillColor: new Color(90, 100, 120), fontSize: 26 }).setAnchor(0.5, 0.5)); - } - + override init(): void { + const audio = this.app.audio; + this.analyser = new AudioAnalyser({ source: audio.master, fftSize: 2048, smoothingTimeConstant: 0.75 }); + this.title = new Text('', { fillColor: Color.white, fontSize: 30, align: 'center' }).setAnchor(0.5).setPosition(640, 170); + this.stageLabels = Array.from({ length: 3 }, () => new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }).setAnchor(0.5)); this.hud = mountControls({ - title: 'Effect chains', - controls: [ - { keys: '< / >', action: 'previous / next chain' }, - { keys: 'Space', action: 'bypass the chain (A/B against dry)' }, - { keys: 'Click', action: 'fire an impact through the chain' }, - ], - status: 'Click or press any key to start…', + title: 'Audio Effect Rack', + status: 'Choose a chain and compare it with dry output.', + hint: 'Effects run in the order shown on the rack.', }); - - this.inputs.onTrigger(Keyboard.Right, () => this.select(this.index + 1)); - this.inputs.onTrigger(Keyboard.Left, () => this.select(this.index - 1)); - this.inputs.onTrigger(Keyboard.Space, () => this.toggleBypass()); - app.input.onPointerTap.add(() => this.strike()); - - this.root.addChild(this.gfx, this.title, this.subtitle, ...this.slotNames, ...this.slotDetails, ...this.arrows, this.tapPrompt); - - // Core defers playback until the AudioContext unlocks on the first - // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.7 }); - this.select(0); - } - - private select(next: number): void { - this.index = (next + CHAINS.length) % CHAINS.length; + this.panel = mountControlPanel({ title: 'Effect rack' }); + CHAINS.forEach((chain, index) => this.panel.addButton({ label: chain.name, onClick: () => this.select(index) })); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + this.bypass = value; + this.rebuild(); + }, + }); + this.panel.addButton({ + label: 'Play impact', + onClick: () => { + if (!audio.locked) audio.play(this.impact); + }, + }); + this.musicVoice = audio.play(this.music, { loop: true, volume: 0.7 }); this.rebuild(); } - private toggleBypass(): void { - this.bypassed = !this.bypassed; + private select(index: number): void { + if (index === this.selected) return; + this.selected = index; this.rebuild(); } - /** - * Tear the current chain down and - unless bypassed - build the selected - * one. Bypass is not a separate code path: it is simply the teardown - * without the rebuild, so there is only one way effects reach the bus. - */ private rebuild(): void { const master = this.app.audio.master; - + const generation = ++this.generation; for (const effect of this.active) { master.removeEffect(effect); effect.destroy(); } this.active = []; - - const chain = CHAINS[this.index]!; - - if (!this.bypassed) { - for (const stage of chain.stages) { - const effect = stage.create({ impulse: this.impulse }); - master.addEffect(effect); - this.active.push(effect); - } - } - - // Worklet-backed effects (BitCrusher, PitchShift, Granular) pass audio - // through untouched until their processor module has loaded, so a - // freshly selected chain can be inaudible for a frame or two. - const built = this.active; - this.preparing = built.length > 0; - void Promise.all(built.map(effect => effect.ready)).then(() => { - if (this.active === built) { - this.preparing = false; + const chain = CHAINS[this.selected]!; + this.title.text = `${chain.name}: ${chain.stages.map(stage => stage.name).join(' -> ')}`; + const slotWidth = 820 / chain.stages.length; + this.stageLabels.forEach((label, index) => { + label.visible = index < chain.stages.length; + if (label.visible) { + label.text = chain.stages[index]!.name; + label.setPosition(225 + (index + 0.5) * slotWidth - 6, 305); } }); - - const width = this.app.width; - - this.title.text = chain.name; - this.subtitle.text = `chain ${this.index + 1} of ${CHAINS.length}`; - - // The per-chain note goes in the overlay: on the canvas it would run - // under the controls panel. this.hud.setHint(chain.note); - - const count = chain.stages.length; - const totalWidth = count * SLOT_WIDTH + (count - 1) * SLOT_GAP; - const left = (width - totalWidth) / 2; - - for (let i = 0; i < this.slotNames.length; i++) { - const stage = chain.stages[i]; - const slotCentre = left + i * (SLOT_WIDTH + SLOT_GAP) + SLOT_WIDTH / 2; - - this.slotNames[i]!.visible = stage !== undefined; - this.slotDetails[i]!.visible = stage !== undefined; - - if (stage) { - this.slotNames[i]!.text = stage.name; - this.slotNames[i]!.setPosition(slotCentre, SLOT_TOP + SLOT_HEIGHT / 2 - 16); - this.slotDetails[i]!.text = stage.detail; - this.slotDetails[i]!.setPosition(slotCentre, SLOT_TOP + SLOT_HEIGHT / 2 + 22); - } - } - - for (let i = 0; i < this.arrows.length; i++) { - const visible = i < count - 1; - this.arrows[i]!.visible = visible; - if (visible) { - this.arrows[i]!.setPosition(left + (i + 1) * SLOT_WIDTH + i * SLOT_GAP + SLOT_GAP / 2, SLOT_TOP + SLOT_HEIGHT / 2); - } - } - - for (const text of this.slotNames) { - text.style.fillColor = this.bypassed ? NAME_COLOR_DIM : NAME_COLOR; + if (this.bypass) { + this.hud.setStatus(`${chain.name} bypassed; dry signal.`); + return; } - for (const text of this.slotDetails) { - text.style.fillColor = this.bypassed ? DETAIL_COLOR_DIM : DETAIL_COLOR; + for (const stage of chain.stages) { + const effect = stage.create(); + master.addEffect(effect); + this.active.push(effect); } + this.hud.setStatus(`${chain.name}: preparing ${this.active.length} effects...`); + void Promise.all(this.active.map(effect => effect.ready)).then( + () => { + if (generation === this.generation) this.hud.setStatus(`${chain.name}: ${this.active.length} effects ready.`); + }, + error => { + if (generation === this.generation) this.hud.setStatus(`${chain.name}: ${error instanceof Error ? error.message : String(error)}`); + }, + ); } - private strike(): void { - if (this.app.audio.locked) { - return; + override draw(context: RenderingContext): void { + this.analyser.getSpectrumLog(this.spectrum, { bands: this.spectrum.length, fMin: 40, fMax: 16000 }); + this.rack.clear(); + const stages = CHAINS[this.selected]!.stages; + const width = 820 / stages.length; + stages.forEach((_, index) => { + this.rack.fillColor = this.bypass ? new Color(46, 49, 55) : new Color(53, 93, 121); + this.rack.drawRoundedRectangle(225 + index * width, 260, width - 12, 90, 12); + }); + for (let index = 0; index < this.spectrum.length; index++) { + this.rack.fillColor = new Color(85 + index * 8, 180, 230 - index * 5); + this.rack.drawRectangle(120 + index * 65, 600 - this.spectrum[index]! * 0.7, 48, this.spectrum[index]! * 0.7); } - - this.app.audio.play(this.impact); - this.flash = 1; + context.render(this.rack); + context.render(this.title); + for (const label of this.stageLabels) if (label.visible) context.render(label); } - override update(time: Seconds): void { - this.flash = Math.max(0, this.flash - time * 2.5); - this.tapPrompt.visible = this.app.audio.locked; - - const chain = CHAINS[this.index]!; - - if (this.app.audio.locked) { - this.hud.setStatus('Click or press any key to start…'); - } else if (this.bypassed) { - this.hud.setStatus('Bypassed — this is the dry signal'); - } else if (this.preparing) { - this.hud.setStatus(`${chain.name} — loading worklets…`); - } else { - this.hud.setStatus(`${chain.name} — ${this.active.length} effects on master`); - } - - this.analyser.getSpectrumLog(this.spectrum, { bands: BANDS, fMin: 40, fMax: 16000 }); - - const width = this.app.width; - const count = chain.stages.length; - const totalWidth = count * SLOT_WIDTH + (count - 1) * SLOT_GAP; - const left = (width - totalWidth) / 2; - const dim = this.bypassed; - - this.gfx.clear(); - - // Chain nodes. A bypassed chain keeps its boxes in place and greys them - // out, so the row never jumps while comparing against the dry signal. - const lift = Math.floor(this.flash * 40); - const slotColor = dim ? SLOT_COLOR_DIM : new Color(38 + lift, 52 + lift, 74 + lift); - - for (let i = 0; i < count; i++) { - this.gfx.fillColor = slotColor; - this.gfx.drawRectangle(left + i * (SLOT_WIDTH + SLOT_GAP), SLOT_TOP, SLOT_WIDTH, SLOT_HEIGHT); - } - - // Spectrum, measured on the master output - that is downstream of the - // chain, so the bars show what actually reaches the speakers. - const barWidth = (width - GRAPH_INSET * 2) / BANDS; - - for (let i = 0; i < BANDS; i++) { - const barHeight = Math.max(2, (this.spectrum[i]! / 255) * GRAPH_HEIGHT); - - this.gfx.fillColor = dim ? BAR_COLOR_DIM : BAR_COLORS[i]!; - this.gfx.drawRectangle(GRAPH_INSET + i * barWidth, GRAPH_FLOOR - barHeight, barWidth - 4, barHeight); + override destroy(): void { + ++this.generation; + this.musicVoice?.stop(); + for (const effect of this.active) { + this.app.audio.master.removeEffect(effect); + effect.destroy(); } - } - - override draw(context: RenderingContext): void { - context.render(this.root); + this.active = []; + this.analyser?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.rack.destroy(); + this.title?.destroy(); + for (const label of this.stageLabels) label.destroy(); + super.destroy(); } } -const application = new Application({ - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, +const app = new Application({ + scenes: { EffectChainsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -void application.start(EffectChainsScene); +await app.start(EffectChainsScene); diff --git a/examples/audio-fx/reverb-and-delay.js b/examples/audio-fx/reverb-and-delay.js index ffba10884..8fe67759d 100644 --- a/examples/audio-fx/reverb-and-delay.js +++ b/examples/audio-fx/reverb-and-delay.js @@ -1,142 +1,82 @@ // Auto-generated from reverb-and-delay.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; class ReverbAndDelayScene extends Scene { sound; reverb; delay; - sliders = []; - labels = []; - gfx; - prompt; - tapPrompt; - flash = 0; - triggers = 0; - drag = -1; - // Canvas-relative layout computed in init(). - pad = { x: 0, y: 0, w: 0, h: 0 }; - barX = 0; - barW = 0; - labelX = 0; - rowY = []; + label; + panel; hud; + preset = 'Small room'; + bypass = false; + reverbWet = 0.3; + delayWet = 0.12; init() { - const app = this.app; - const { width, height } = app; - this.pad = { x: width / 2 - 240, y: 36, w: 480, h: 100 }; - this.barW = width * 0.5; - this.barX = width * 0.25; - this.labelX = width * 0.06; - this.rowY = Array.from({ length: 5 }, (_, i) => 210 + i * 78); - // 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. + const audio = this.app.audio; 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 }); - app.audio.sound.addEffect(this.reverb); - app.audio.sound.addEffect(this.delay); - this.sliders = [ - { label: 'reverb wet', min: 0, max: 1, get: () => this.reverb.wet, set: v => (this.reverb.wet = v) }, - { label: 'reverb decay', min: 0.5, max: 10, get: () => this.reverb.decay, set: v => (this.reverb.decay = v) }, - { label: 'delay wet', min: 0, max: 1, get: () => this.delay.wet, set: v => (this.delay.wet = v) }, - { label: 'delay time (s)', min: 0.02, max: 0.82, get: () => this.delay.delaySeconds, set: v => (this.delay.delaySeconds = v) }, - { label: 'delay feedback', min: 0, max: 0.95, get: () => this.delay.feedback, set: v => (this.delay.feedback = v) }, - ]; - this.labels = this.sliders.map((_, i) => { - const label = new Text('', { fillColor: Color.white, fontSize: 16 }); - label.setPosition(this.labelX, this.rowY[i] - 12); - return label; - }); - this.gfx = new Graphics(); - this.prompt = new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, this.pad.y + this.pad.h / 2); - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); + this.reverb = new ReverbEffect({ wet: 0.3, decay: 1.3 }); + this.delay = new DelayEffect({ wet: 0.12, delaySeconds: 0.18, feedback: 0.2 }); + audio.sound.addEffect(this.reverb); + audio.sound.addEffect(this.delay); + this.label = new Text('Sound -> Reverb -> Delay -> Sound bus', { fillColor: Color.white, fontSize: 25 }); + this.label.setPosition(365, 250); this.hud = mountControls({ title: 'Reverb and Delay', - controls: [ - { keys: 'Click pad', action: 'trigger the impact sound' }, - { keys: 'Drag bar', action: 'sweep a parameter' }, - ], - status: 'Click or press any key to start…', + status: 'Small room: play the impact, then compare presets and bypass.', + hint: 'The dry/wet path stays audible as the room and echo change.', }); - this.root.addChild(this.gfx, ...this.labels, this.prompt, this.tapPrompt); - app.input.onPointerDown.add(p => { - if (p.x >= this.pad.x && p.x <= this.pad.x + this.pad.w && p.y >= this.pad.y && p.y <= this.pad.y + this.pad.h) { - this.strike(); - return; - } - this.drag = this.sliderAt(p.y); - this.apply(p.x); + this.panel = mountControlPanel({ title: 'Space and echoes' }); + this.panel.addButton({ + label: 'Play impact', + onClick: () => { + if (!audio.locked) audio.play(this.sound, { bus: audio.sound }); + }, }); - app.input.onPointerMove.add(p => this.apply(p.x)); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addButton({ label: 'Small room', onClick: () => this.setPreset('Small room', 0.3, 1.3, 0.12, 0.18, 0.2) }); + this.panel.addButton({ label: 'Large hall', onClick: () => this.setPreset('Large hall', 0.55, 5, 0.18, 0.32, 0.3) }); + this.panel.addButton({ label: 'Echo', onClick: () => this.setPreset('Echo', 0.12, 1.5, 0.55, 0.48, 0.55) }); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + this.bypass = value; + this.applyWet(); + }, }); - this.hud.setStatus('Click the pad to trigger the impact'); - } - sliderAt(y) { - for (let i = 0; i < this.rowY.length; i++) if (Math.abs(y - this.rowY[i]) <= 16) return i; - return -1; } - apply(x) { - if (this.drag < 0) return; - const def = this.sliders[this.drag]; - const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW)); - def.set(def.min + (def.max - def.min) * t); + setPreset(name, reverbWet, decay, delayWet, delaySeconds, feedback) { + this.preset = name; + this.reverb.decay = decay; + this.delay.delaySeconds = delaySeconds; + this.delay.feedback = feedback; + this.reverbWet = reverbWet; + this.delayWet = delayWet; + this.applyWet(); } - strike() { - // The pointer gesture also unlocks the AudioContext; firing while still - // locked would be silent, so wait until audio is ready. - if (this.app.audio.locked) return; - this.app.audio.play(this.sound); - this.flash = 1; - this.triggers += 1; - this.hud.setStatus(`Impacts triggered: ${this.triggers}`); - } - update(delta) { - this.flash = Math.max(0, this.flash - delta * 2.2); + applyWet() { + this.reverb.wet = this.bypass ? 0 : this.reverbWet; + this.delay.wet = this.bypass ? 0 : this.delayWet; + this.hud.setStatus(`${this.preset}${this.bypass ? ' (bypassed)' : ''}: reverb ${this.reverb.wet.toFixed(2)}, delay ${this.delay.wet.toFixed(2)}.`); } draw(context) { - const app = this.app; - this.gfx.clear(); - // A big click pad that flashes on each trigger so the play action reads. - const lit = Math.floor(60 + this.flash * 180); - this.gfx.fillColor = new Color(lit, lit, Math.floor(60 + this.flash * 120)); - this.gfx.drawRoundedRectangle(this.pad.x, this.pad.y, this.pad.w, this.pad.h, 12); - for (let i = 0; i < this.sliders.length; i++) { - const def = this.sliders[i]; - const y = this.rowY[i]; - const value = def.get(); - const t = (value - def.min) / (def.max - def.min); - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12); - this.gfx.fillColor = new Color(120, 200, 255); - this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12); - this.labels[i].text = `${def.label}: ${value.toFixed(2)}`; - } - this.prompt.text = app.audio.locked ? 'Click or press a key to enable audio' : 'Click the pad to play impact'; - context.render(this.root); + context.render(this.label); + } + destroy() { + this.app.audio.sound.removeEffect(this.delay).removeEffect(this.reverb); + this.delay?.destroy(); + this.reverb?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.label?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { ReverbAndDelayScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(ReverbAndDelayScene); diff --git a/examples/audio-fx/reverb-and-delay.ts b/examples/audio-fx/reverb-and-delay.ts index dfcb0ec2d..9b9aad4df 100644 --- a/examples/audio-fx/reverb-and-delay.ts +++ b/examples/audio-fx/reverb-and-delay.ts @@ -1,176 +1,90 @@ -import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, Sound, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Sound, Text } from '@codexo/exojs'; import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; - -interface SliderDef { - label: string; - min: number; - max: number; - get(): number; - set(value: number): void; -} +import { mountControlPanel, mountControls } from '@examples/runtime'; class ReverbAndDelayScene extends Scene { private sound!: Sound; private reverb!: ReverbEffect; private delay!: DelayEffect; - private sliders: SliderDef[] = []; - private labels: Text[] = []; - private gfx!: Graphics; - private prompt!: Text; - private tapPrompt!: Text; - private flash = 0; - private triggers = 0; - private drag = -1; - // Canvas-relative layout computed in init(). - private pad = { x: 0, y: 0, w: 0, h: 0 }; - private barX = 0; - private barW = 0; - private labelX = 0; - private rowY: number[] = []; + private label!: Text; + private panel!: ReturnType; private hud!: ReturnType; + private preset = 'Small room'; + private bypass = false; + private reverbWet = 0.3; + private delayWet = 0.12; override init(): void { - const app = this.app; - const { width, height } = app; - - this.pad = { x: width / 2 - 240, y: 36, w: 480, h: 100 }; - this.barW = width * 0.5; - this.barX = width * 0.25; - this.labelX = width * 0.06; - this.rowY = Array.from({ length: 5 }, (_, i) => 210 + i * 78); - - // 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. + const audio = this.app.audio; this.sound = this.loader.get('audio/impact-light.ogg'); + this.reverb = new ReverbEffect({ wet: 0.3, decay: 1.3 }); + this.delay = new DelayEffect({ wet: 0.12, delaySeconds: 0.18, feedback: 0.2 }); + audio.sound.addEffect(this.reverb); + audio.sound.addEffect(this.delay); - // 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 }); - app.audio.sound.addEffect(this.reverb); - app.audio.sound.addEffect(this.delay); - - this.sliders = [ - { label: 'reverb wet', min: 0, max: 1, get: () => this.reverb.wet, set: v => (this.reverb.wet = v) }, - { label: 'reverb decay', min: 0.5, max: 10, get: () => this.reverb.decay, set: v => (this.reverb.decay = v) }, - { label: 'delay wet', min: 0, max: 1, get: () => this.delay.wet, set: v => (this.delay.wet = v) }, - { label: 'delay time (s)', min: 0.02, max: 0.82, get: () => this.delay.delaySeconds, set: v => (this.delay.delaySeconds = v) }, - { label: 'delay feedback', min: 0, max: 0.95, get: () => this.delay.feedback, set: v => (this.delay.feedback = v) }, - ]; - this.labels = this.sliders.map((_, i) => { - const label = new Text('', { fillColor: Color.white, fontSize: 16 }); - label.setPosition(this.labelX, this.rowY[i]! - 12); - return label; - }); - - this.gfx = new Graphics(); - this.prompt = new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, this.pad.y + this.pad.h / 2); - - // Shown while the browser still blocks audio (`app.audio.locked`); the - // first click or keypress unlocks it. - this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height - 48); - + this.label = new Text('Sound -> Reverb -> Delay -> Sound bus', { fillColor: Color.white, fontSize: 25 }); + this.label.setPosition(365, 250); this.hud = mountControls({ title: 'Reverb and Delay', - controls: [ - { keys: 'Click pad', action: 'trigger the impact sound' }, - { keys: 'Drag bar', action: 'sweep a parameter' }, - ], - status: 'Click or press any key to start…', + status: 'Small room: play the impact, then compare presets and bypass.', + hint: 'The dry/wet path stays audible as the room and echo change.', }); - - this.root.addChild(this.gfx, ...this.labels, this.prompt, this.tapPrompt); - - app.input.onPointerDown.add(p => { - if (p.x >= this.pad.x && p.x <= this.pad.x + this.pad.w && p.y >= this.pad.y && p.y <= this.pad.y + this.pad.h) { - this.strike(); - return; - } - - this.drag = this.sliderAt(p.y); - this.apply(p.x); + this.panel = mountControlPanel({ title: 'Space and echoes' }); + this.panel.addButton({ + label: 'Play impact', + onClick: () => { + if (!audio.locked) audio.play(this.sound, { bus: audio.sound }); + }, }); - app.input.onPointerMove.add(p => this.apply(p.x)); - app.input.onPointerUp.add(() => { - this.drag = -1; + this.panel.addButton({ label: 'Small room', onClick: () => this.setPreset('Small room', 0.3, 1.3, 0.12, 0.18, 0.2) }); + this.panel.addButton({ label: 'Large hall', onClick: () => this.setPreset('Large hall', 0.55, 5, 0.18, 0.32, 0.3) }); + this.panel.addButton({ label: 'Echo', onClick: () => this.setPreset('Echo', 0.12, 1.5, 0.55, 0.48, 0.55) }); + this.panel.addToggle({ + label: 'Bypass', + value: false, + onChange: value => { + this.bypass = value; + this.applyWet(); + }, }); - - this.hud.setStatus('Click the pad to trigger the impact'); - } - - private sliderAt(y: number): number { - for (let i = 0; i < this.rowY.length; i++) if (Math.abs(y - this.rowY[i]!) <= 16) return i; - return -1; - } - - private apply(x: number): void { - if (this.drag < 0) return; - - const def = this.sliders[this.drag]!; - const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW)); - - def.set(def.min + (def.max - def.min) * t); } - private strike(): void { - // The pointer gesture also unlocks the AudioContext; firing while still - // locked would be silent, so wait until audio is ready. - if (this.app.audio.locked) return; - - this.app.audio.play(this.sound); - this.flash = 1; - this.triggers += 1; - this.hud.setStatus(`Impacts triggered: ${this.triggers}`); + private setPreset(name: string, reverbWet: number, decay: number, delayWet: number, delaySeconds: number, feedback: number): void { + this.preset = name; + this.reverb.decay = decay; + this.delay.delaySeconds = delaySeconds; + this.delay.feedback = feedback; + this.reverbWet = reverbWet; + this.delayWet = delayWet; + this.applyWet(); } - override update(delta: Seconds): void { - this.flash = Math.max(0, this.flash - delta * 2.2); + private applyWet(): void { + this.reverb.wet = this.bypass ? 0 : this.reverbWet; + this.delay.wet = this.bypass ? 0 : this.delayWet; + this.hud.setStatus(`${this.preset}${this.bypass ? ' (bypassed)' : ''}: reverb ${this.reverb.wet.toFixed(2)}, delay ${this.delay.wet.toFixed(2)}.`); } override draw(context: RenderingContext): void { - const app = this.app; - this.gfx.clear(); - - // A big click pad that flashes on each trigger so the play action reads. - const lit = Math.floor(60 + this.flash * 180); - this.gfx.fillColor = new Color(lit, lit, Math.floor(60 + this.flash * 120)); - this.gfx.drawRoundedRectangle(this.pad.x, this.pad.y, this.pad.w, this.pad.h, 12); - - for (let i = 0; i < this.sliders.length; i++) { - const def = this.sliders[i]!; - const y = this.rowY[i]!; - const value = def.get(); - const t = (value - def.min) / (def.max - def.min); - - this.gfx.fillColor = new Color(70, 70, 70); - this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12); - this.gfx.fillColor = new Color(120, 200, 255); - this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12); - this.labels[i]!.text = `${def.label}: ${value.toFixed(2)}`; - } + context.render(this.label); + } - this.prompt.text = app.audio.locked ? 'Click or press a key to enable audio' : 'Click the pad to play impact'; - context.render(this.root); + override destroy(): void { + this.app.audio.sound.removeEffect(this.delay).removeEffect(this.reverb); + this.delay?.destroy(); + this.reverb?.destroy(); + this.panel?.dispose(); + this.hud?.dispose(); + this.label?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { ReverbAndDelayScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(ReverbAndDelayScene); diff --git a/examples/audio-fx/vocoder.js b/examples/audio-fx/vocoder.js index 4d3b1824a..c28a092b6 100644 --- a/examples/audio-fx/vocoder.js +++ b/examples/audio-fx/vocoder.js @@ -1,7 +1,7 @@ // Auto-generated from vocoder.ts - edit the .ts source, not this file. import { Application, Asset, AudioBus, AudioGenerator, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; import { AudioAnalyser, VocoderEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Spoken phrases (Kenney Voiceover Pack, CC0) - a voice modulator is what makes // a vocoder recognisable as the classic "robot voice" effect. const PHRASES = [ @@ -21,12 +21,16 @@ class VocoderScene extends Scene { hintLabel; tapPrompt; hud; + panel; + carrierVoice = null; + processed = true; + pendingPhrase = false; init() { const app = this.app; const { width, height } = app; // 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 }); + this.modulatorBus = new AudioBus('modulator'); app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { // phrase.asset is a widened `string` (not a path literal), so the @@ -49,26 +53,49 @@ class VocoderScene extends Scene { .setPosition(width / 2, height - 48); this.hud = mountControls({ title: 'Vocoder', - controls: [ - { keys: 'Click', action: 'speak the phrase' }, - { keys: 'Right-click', action: 'next phrase' }, - ], + controls: [{ keys: 'Phrase pads', action: 'play a voice sample' }], hint: 'A spoken voice modulates a sustained saw carrier — the classic robot-voice effect.', }); - this.root.addChild(this.gfx, this.phraseLabel, this.hintLabel, this.tapPrompt); - app.input.onPointerTap.add(() => this.speak()); - app.input.onContextMenu.add(() => this.selectPhrase(this.phraseIndex + 1)); + this.panel = mountControlPanel({ title: 'Voice phrases' }); + PHRASES.forEach((phrase, index) => + this.panel.addButton({ + label: phrase.label, + onClick: () => { + this.selectPhrase(index); + this.speak(); + }, + }), + ); + this.panel.addToggle({ + label: 'Processed', + value: true, + onChange: value => { + this.processed = value; + this.vocoder.wet = value ? 1 : 0; + if (this.carrierVoice) this.carrierVoice.volume = value ? 0.45 : 0; + this.hud.setStatus(value ? 'Processed voice: phrase drives the carrier.' : 'Dry voice: phrase plays directly.'); + }, + }); + this.root.addChild(this.gfx, this.phraseLabel, this.hintLabel); // The carrier is a sustained saw tone shaped by the voice envelope. // An oscillator played while audio is still locked is a no-op - it is // ephemeral and cannot be deferred - so start it from the unlock // gesture. Subscribing is safe even if audio unlocked earlier: // onUnlock replays. - app.audio.onUnlock.add(() => { - app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { volume: 0.45 }); - }); - this.hud.setStatus('Ready — click to speak, right-click to change phrase.'); + app.audio.onUnlock.add(this.startCarrier); + this.hud.setStatus('Choose a phrase; compare processed and dry voice.'); this.selectPhrase(0); } + startCarrier = () => { + this.carrierVoice ??= this.app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { + bus: this.app.audio.sound, + volume: this.processed ? 0.45 : 0, + }); + if (this.pendingPhrase) { + this.pendingPhrase = false; + this.speak(); + } + }; selectPhrase(next) { this.phraseIndex = (next + PHRASES.length) % PHRASES.length; this.hintLabel.text = `${this.phraseIndex + 1} / ${PHRASES.length} — "${PHRASES[this.phraseIndex].label}"`; @@ -78,12 +105,14 @@ class VocoderScene extends Scene { // The pointer gesture also unlocks the AudioContext; speaking while // still locked would be silent, so wait until audio is ready. if (app.audio.locked) { + this.pendingPhrase = true; + this.hud.setStatus('Unlocking audio; the selected phrase will play when ready.'); return; } const phrase = PHRASES[this.phraseIndex]; const sound = this.phrases.get(phrase.key); - if (sound) app.audio.play(sound, { bus: this.modulatorBus }); - this.hud.setStatus(`Speaking: "${phrase.label}"`); + if (sound) app.audio.play(sound, { bus: this.processed ? this.modulatorBus : app.audio.sound }); + this.hud.setStatus(`${this.processed ? 'Processed' : 'Dry'}: "${phrase.label}"`); this.phraseLabel.text = `"${phrase.label}"`; } draw(context) { @@ -99,6 +128,18 @@ class VocoderScene extends Scene { context.render(this.tapPrompt); } } + destroy() { + this.app.audio.onUnlock.remove(this.startCarrier); + this.carrierVoice?.stop(); + this.app.audio.sound.removeEffect(this.vocoder); + this.vocoder?.destroy(); + this.level?.destroy(); + this.app.audio.unregisterBus(this.modulatorBus); + this.panel?.dispose(); + this.hud?.dispose(); + this.tapPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { VocoderScene }, diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index f592c3fd7..073a17ac4 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -10,9 +10,10 @@ import { Scene, Sound, Text, + type Voice, } from '@codexo/exojs'; import { AudioAnalyser, VocoderEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Spoken phrases (Kenney Voiceover Pack, CC0) - a voice modulator is what makes // a vocoder recognisable as the classic "robot voice" effect. @@ -34,6 +35,10 @@ class VocoderScene extends Scene { private hintLabel!: Text; private tapPrompt!: Text; private hud!: ReturnType; + private panel!: ReturnType; + private carrierVoice: Voice | null = null; + private processed = true; + private pendingPhrase = false; override init(): void { const app = this.app; @@ -41,7 +46,7 @@ class VocoderScene extends Scene { // 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 }); + this.modulatorBus = new AudioBus('modulator'); app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { @@ -69,30 +74,53 @@ class VocoderScene extends Scene { this.hud = mountControls({ title: 'Vocoder', - controls: [ - { keys: 'Click', action: 'speak the phrase' }, - { keys: 'Right-click', action: 'next phrase' }, - ], + controls: [{ keys: 'Phrase pads', action: 'play a voice sample' }], hint: 'A spoken voice modulates a sustained saw carrier — the classic robot-voice effect.', }); + this.panel = mountControlPanel({ title: 'Voice phrases' }); + PHRASES.forEach((phrase, index) => + this.panel.addButton({ + label: phrase.label, + onClick: () => { + this.selectPhrase(index); + this.speak(); + }, + }), + ); + this.panel.addToggle({ + label: 'Processed', + value: true, + onChange: value => { + this.processed = value; + this.vocoder.wet = value ? 1 : 0; + if (this.carrierVoice) this.carrierVoice.volume = value ? 0.45 : 0; + this.hud.setStatus(value ? 'Processed voice: phrase drives the carrier.' : 'Dry voice: phrase plays directly.'); + }, + }); - this.root.addChild(this.gfx, this.phraseLabel, this.hintLabel, this.tapPrompt); - - app.input.onPointerTap.add(() => this.speak()); - app.input.onContextMenu.add(() => this.selectPhrase(this.phraseIndex + 1)); + this.root.addChild(this.gfx, this.phraseLabel, this.hintLabel); // The carrier is a sustained saw tone shaped by the voice envelope. // An oscillator played while audio is still locked is a no-op - it is // ephemeral and cannot be deferred - so start it from the unlock // gesture. Subscribing is safe even if audio unlocked earlier: // onUnlock replays. - app.audio.onUnlock.add(() => { - app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { volume: 0.45 }); - }); - this.hud.setStatus('Ready — click to speak, right-click to change phrase.'); + app.audio.onUnlock.add(this.startCarrier); + this.hud.setStatus('Choose a phrase; compare processed and dry voice.'); this.selectPhrase(0); } + private readonly startCarrier = (): void => { + this.carrierVoice ??= this.app.audio.play(new AudioGenerator({ frequency: 110, type: 'sawtooth' }), { + bus: this.app.audio.sound, + volume: this.processed ? 0.45 : 0, + }); + if (this.pendingPhrase) { + this.pendingPhrase = false; + this.speak(); + } + }; + private selectPhrase(next: number): void { this.phraseIndex = (next + PHRASES.length) % PHRASES.length; this.hintLabel.text = `${this.phraseIndex + 1} / ${PHRASES.length} — "${PHRASES[this.phraseIndex]!.label}"`; @@ -104,14 +132,16 @@ class VocoderScene extends Scene { // The pointer gesture also unlocks the AudioContext; speaking while // still locked would be silent, so wait until audio is ready. if (app.audio.locked) { + this.pendingPhrase = true; + this.hud.setStatus('Unlocking audio; the selected phrase will play when ready.'); return; } const phrase = PHRASES[this.phraseIndex]!; const sound = this.phrases.get(phrase.key); - if (sound) app.audio.play(sound, { bus: this.modulatorBus }); - this.hud.setStatus(`Speaking: "${phrase.label}"`); + if (sound) app.audio.play(sound, { bus: this.processed ? this.modulatorBus : app.audio.sound }); + this.hud.setStatus(`${this.processed ? 'Processed' : 'Dry'}: "${phrase.label}"`); this.phraseLabel.text = `"${phrase.label}"`; } @@ -133,6 +163,19 @@ class VocoderScene extends Scene { context.render(this.tapPrompt); } } + + override destroy(): void { + this.app.audio.onUnlock.remove(this.startCarrier); + this.carrierVoice?.stop(); + this.app.audio.sound.removeEffect(this.vocoder); + this.vocoder?.destroy(); + this.level?.destroy(); + this.app.audio.unregisterBus(this.modulatorBus); + this.panel?.dispose(); + this.hud?.dispose(); + this.tapPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/beat-detection/beat-sync-pulse.js b/examples/beat-detection/beat-sync-pulse.js index 2bcd04b12..07237aaf3 100644 --- a/examples/beat-detection/beat-sync-pulse.js +++ b/examples/beat-detection/beat-sync-pulse.js @@ -14,6 +14,7 @@ class BeatSyncPulseScene extends Scene { burst; hud; tapPrompt; + tempoReadout; async load() { const app = this.app; const { width, height } = app; @@ -22,9 +23,12 @@ class BeatSyncPulseScene extends Scene { 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.', + title: 'Beat and Tempo', + hint: 'A detected beat pulses the sprite and particles. The tempo estimate and confidence update continuously.', }); + this.tempoReadout = new Text('Listening for tempo...', { fillColor: Color.white, fontSize: 27, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(width / 2, height * 0.18); this.particles = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: 3500 }); this.systems.add(this.particles); this.particles.setPosition(width / 2, height / 2); @@ -67,11 +71,17 @@ class BeatSyncPulseScene extends Scene { update(delta) { this.pulse = Math.max(0, this.pulse - delta * 1.2); this.sprite.setScale(1 + this.pulse); + const bpm = this.detector.tempo; + this.tempoReadout.text = + bpm > 0 + ? `${bpm.toFixed(1)} BPM | confidence ${this.detector.confidence.toFixed(2)} | onset ${this.detector.onsetStrength.toFixed(2)}` + : 'Listening for tempo...'; } draw(context) { const app = this.app; context.render(this.particles); context.render(this.sprite); + context.render(this.tempoReadout); if (app.audio.locked) { context.render(this.tapPrompt); } diff --git a/examples/beat-detection/beat-sync-pulse.ts b/examples/beat-detection/beat-sync-pulse.ts index 30ed05d98..a1d4800d3 100644 --- a/examples/beat-detection/beat-sync-pulse.ts +++ b/examples/beat-detection/beat-sync-pulse.ts @@ -26,6 +26,7 @@ class BeatSyncPulseScene extends Scene { private burst!: BurstSpawn; private hud!: ReturnType; private tapPrompt!: Text; + private tempoReadout!: Text; override async load(): Promise { const app = this.app; @@ -37,10 +38,14 @@ class BeatSyncPulseScene extends Scene { 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.', + title: 'Beat and Tempo', + hint: 'A detected beat pulses the sprite and particles. The tempo estimate and confidence update continuously.', }); + this.tempoReadout = new Text('Listening for tempo...', { fillColor: Color.white, fontSize: 27, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(width / 2, height * 0.18); + this.particles = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: 3500 }); this.systems.add(this.particles); this.particles.setPosition(width / 2, height / 2); @@ -88,12 +93,18 @@ class BeatSyncPulseScene extends Scene { override update(delta: Seconds): void { this.pulse = Math.max(0, this.pulse - delta * 1.2); this.sprite.setScale(1 + this.pulse); + const bpm = this.detector.tempo; + this.tempoReadout.text = + bpm > 0 + ? `${bpm.toFixed(1)} BPM | confidence ${this.detector.confidence.toFixed(2)} | onset ${this.detector.onsetStrength.toFixed(2)}` + : 'Listening for tempo...'; } override draw(context: RenderingContext): void { const app = this.app; context.render(this.particles); context.render(this.sprite); + context.render(this.tempoReadout); if (app.audio.locked) { context.render(this.tapPrompt); diff --git a/examples/debug-layer/asset-browser.js b/examples/debug-layer/asset-browser.js index 9ab96239e..688abba61 100644 --- a/examples/debug-layer/asset-browser.js +++ b/examples/debug-layer/asset-browser.js @@ -353,6 +353,10 @@ class AssetBrowserScene extends Scene { if (typeof record?.audio === 'string') return record.audio; return ''; } + assetExpression() { + const category = this.cat === 'technical' ? 'technical' : `demo.${this.cat}`; + return `assets.${category}.${this.key}`; + } typeLabel() { return this.assetPath().split('.').pop()?.toUpperCase() ?? ''; } @@ -461,8 +465,7 @@ class AssetBrowserScene extends Scene { return; } if (this.key && x >= PREVIEW_X + PREVIEW_W - 84 && x < PREVIEW_X + PREVIEW_W - 10 && y >= PREVIEW_Y + 8 && y < PREVIEW_Y + 8 + 26) { - const fullKey = `assets.${this.cat}.${this.key}`; - navigator.clipboard?.writeText(fullKey).catch(() => undefined); + navigator.clipboard?.writeText(this.assetExpression()).catch(() => undefined); return; } if (this.isAudioLikeCategory() && this.key) { @@ -714,7 +717,7 @@ class AssetBrowserScene extends Scene { g.drawLine(PREVIEW_X, PREVIEW_Y + 44, W, PREVIEW_Y + 44); context.render(g); if (!this.key) return; - this.txtKey.text = `assets.${this.cat}.${this.key}`; + this.txtKey.text = this.assetExpression(); this.txtKey.setPosition(PREVIEW_X + 10, PREVIEW_Y + 5); context.render(this.txtKey); this.txtPath.text = this.assetPath(); @@ -939,7 +942,7 @@ class AssetBrowserScene extends Scene { } const packs = data.packs ?? []; const lines = [ - `License: ${data.license ?? 'CC0'}`, + `License: ${data.license ?? 'Unknown'}`, `Packs: ${packs.length}`, '', ...packs.slice(0, 14).map(p => ` ${p.slug} (${Object.values(p.fileCountByExtension ?? {}).reduce((a, b) => a + b, 0)} files)`), diff --git a/examples/debug-layer/asset-browser.ts b/examples/debug-layer/asset-browser.ts index 119b399d4..af27a1bba 100644 --- a/examples/debug-layer/asset-browser.ts +++ b/examples/debug-layer/asset-browser.ts @@ -446,6 +446,11 @@ class AssetBrowserScene extends Scene { return ''; } + private assetExpression(): string { + const category = this.cat === 'technical' ? 'technical' : `demo.${this.cat}`; + return `assets.${category}.${this.key}`; + } + private typeLabel(): string { return this.assetPath().split('.').pop()?.toUpperCase() ?? ''; } @@ -568,8 +573,7 @@ class AssetBrowserScene extends Scene { } if (this.key && x >= PREVIEW_X + PREVIEW_W - 84 && x < PREVIEW_X + PREVIEW_W - 10 && y >= PREVIEW_Y + 8 && y < PREVIEW_Y + 8 + 26) { - const fullKey = `assets.${this.cat}.${this.key}`; - navigator.clipboard?.writeText(fullKey).catch(() => undefined); + navigator.clipboard?.writeText(this.assetExpression()).catch(() => undefined); return; } @@ -856,7 +860,7 @@ class AssetBrowserScene extends Scene { if (!this.key) return; - this.txtKey.text = `assets.${this.cat}.${this.key}`; + this.txtKey.text = this.assetExpression(); this.txtKey.setPosition(PREVIEW_X + 10, PREVIEW_Y + 5); context.render(this.txtKey); @@ -1109,7 +1113,7 @@ class AssetBrowserScene extends Scene { } const packs = data.packs ?? []; const lines = [ - `License: ${data.license ?? 'CC0'}`, + `License: ${data.license ?? 'Unknown'}`, `Packs: ${packs.length}`, '', ...packs.slice(0, 14).map(p => ` ${p.slug} (${Object.values(p.fileCountByExtension ?? {}).reduce((a, b) => a + b, 0)} files)`), diff --git a/examples/debug-layer/pointer-and-hittest.js b/examples/debug-layer/pointer-and-hittest.js index aa2714f7e..6d25ff0a2 100644 --- a/examples/debug-layer/pointer-and-hittest.js +++ b/examples/debug-layer/pointer-and-hittest.js @@ -1,26 +1,46 @@ // Auto-generated from pointer-and-hittest.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { mountControls } from '@examples/runtime'; class PointerAndHittestScene extends Scene { sprites; + hud; init() { const app = this.app; const { width, height } = app; this.sprites = []; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 3; i++) { 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); + .setScale(2.4) + .setPosition(width / 2 - 45 + i * 45, height / 2); sprite.zIndex = i; sprite.interactive = true; sprite.draggable = true; - sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255), new Color(255, 230, 130), new Color(220, 140, 255)][i]); + sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255)][i]); // The hitTest layer (and the interaction system itself) walk the // scene graph, so interactive nodes must live under scene.root. this.root.addChild(sprite); this.sprites.push(sprite); } + this.hud = mountControls({ + title: 'Interaction Inspector', + controls: [ + { keys: 'Drag', action: 'move an overlapping sprite' }, + { keys: '1 / 2 / 3', action: 'bring that sprite to the front' }, + ], + status: 'Front: 3 (blue)', + hint: 'The overlays show pointer targets, hit tests, and scene-graph bounds.', + }); + this.inputs.onTrigger(Keyboard.One, () => this.setFront(0)); + this.inputs.onTrigger(Keyboard.Two, () => this.setFront(1)); + this.inputs.onTrigger(Keyboard.Three, () => this.setFront(2)); + } + setFront(index) { + this.sprites.forEach((sprite, i) => { + sprite.zIndex = i === index ? 3 : i; + }); + this.hud.setStatus(`Front: ${index + 1} (${['red', 'green', 'blue'][index]})`); } draw(context) { context.render(this.root); @@ -40,6 +60,7 @@ const app = new Application({ }, }); const debug = new DebugOverlay(app); +debug.layers.boundingBoxes.visible = true; debug.layers.hitTest.visible = true; debug.layers.pointerStack.visible = true; await app.start(PointerAndHittestScene); diff --git a/examples/debug-layer/pointer-and-hittest.ts b/examples/debug-layer/pointer-and-hittest.ts index 8db47d840..f0441c169 100644 --- a/examples/debug-layer/pointer-and-hittest.ts +++ b/examples/debug-layer/pointer-and-hittest.ts @@ -1,28 +1,49 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { mountControls } from '@examples/runtime'; class PointerAndHittestScene extends Scene { private sprites!: Sprite[]; + private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; this.sprites = []; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 3; i++) { 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); + .setScale(2.4) + .setPosition(width / 2 - 45 + i * 45, height / 2); sprite.zIndex = i; sprite.interactive = true; sprite.draggable = true; - sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255), new Color(255, 230, 130), new Color(220, 140, 255)][i]); + sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255)][i]); // The hitTest layer (and the interaction system itself) walk the // scene graph, so interactive nodes must live under scene.root. this.root.addChild(sprite); this.sprites.push(sprite); } + this.hud = mountControls({ + title: 'Interaction Inspector', + controls: [ + { keys: 'Drag', action: 'move an overlapping sprite' }, + { keys: '1 / 2 / 3', action: 'bring that sprite to the front' }, + ], + status: 'Front: 3 (blue)', + hint: 'The overlays show pointer targets, hit tests, and scene-graph bounds.', + }); + this.inputs.onTrigger(Keyboard.One, () => this.setFront(0)); + this.inputs.onTrigger(Keyboard.Two, () => this.setFront(1)); + this.inputs.onTrigger(Keyboard.Three, () => this.setFront(2)); + } + + private setFront(index: number): void { + this.sprites.forEach((sprite, i) => { + sprite.zIndex = i === index ? 3 : i; + }); + this.hud.setStatus(`Front: ${index + 1} (${['red', 'green', 'blue'][index]})`); } override draw(context: RenderingContext): void { @@ -45,6 +66,7 @@ const app = new Application({ }); const debug = new DebugOverlay(app); +debug.layers.boundingBoxes.visible = true; debug.layers.hitTest.visible = true; debug.layers.pointerStack.visible = true; diff --git a/examples/examples.json b/examples/examples.json index 5d17266ad..a70a53b62 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1,78 +1,92 @@ { - "application-scenes": [ + "start-here": [ { - "slug": "scene-less-app", - "path": "application-scenes/scene-less-app.js", + "slug": "hello-world", + "path": "getting-started/hello-world.js", "language": "typescript", - "title": "Scene-less App", - "description": "Run an Application with no Scene at all — a single app-level system drives update and draw directly, loading and rendering a sprite without ever touching Scene.", + "title": "First Animated Sprite", + "description": "Start a Scene, rotate a Sprite using elapsed time, and click the Sprite to pause or resume it.", "backend": "core", - "tags": ["application", "systems"], + "featured": true, + "tags": ["scene", "sprite", "rendering"], "level": "intro" }, { - "slug": "scene-lifecycle", - "path": "application-scenes/scene-lifecycle.js", + "slug": "resize-and-dpr", + "path": "getting-started/resize-and-dpr.js", "language": "typescript", - "title": "Scene Lifecycle", - "description": "Observe the full Scene lifecycle: onLoad, onActivate, onUpdate, onDeactivate, and onUnload through a simple counter display.", + "title": "Resize and DPR", + "description": "Handle window resizes and device pixel ratio changes so the canvas stays sharp on high-DPI screens without stretching.", "backend": "core", - "featured": true, - "tags": ["scene"], + "tags": ["resize", "dpr", "responsive"], "level": "intro" }, { - "slug": "camera-basic", - "path": "application-scenes/camera-basic.js", + "slug": "scene-less-app", + "path": "application-scenes/scene-less-app.js", "language": "typescript", - "title": "Camera Basic", - "description": "High-level Camera facade: the camera follows the pointer, the mouse wheel zooms, the world slowly rotates, and a HUD bar is drawn in screen space through a per-render view override.", + "title": "Scene-less App", + "description": "Run an Application with no Scene at all — a single app-level system drives update and draw directly, loading and rendering a sprite without ever touching Scene.", "backend": "core", - "tags": ["camera", "view", "pointer"], - "capabilities": ["pointer"], + "tags": ["application", "systems"], "level": "intro" }, { - "slug": "camera-and-view", - "path": "application-scenes/camera-and-view.js", + "slug": "orb-dodge", + "path": "showcase/orb-dodge.js", "language": "typescript", - "title": "Camera and View", - "description": "Pan and zoom the Camera by dragging and scrolling; observe world and screen coordinates update live.", + "title": "Orb Dodge", + "description": "A complete dodge-and-collect loop: move with WASD or arrow keys, collect green orbs for points, avoid red orbs, and restart from the game-over screen.", "backend": "core", - "tags": ["camera", "view", "scene"], + "featured": true, + "tags": ["game", "input", "collision", "hud"], "capabilities": ["keyboard"], "level": "intermediate" - }, + } + ], + "scenes-assets": [ { - "slug": "world-vs-screen-coords", - "path": "application-scenes/world-vs-screen-coords.js", + "slug": "multiple-scenes", + "path": "application-scenes/multiple-scenes.js", "language": "typescript", - "title": "World Vs Screen Coords", - "description": "Click anywhere to drop a marker and read the same point in both screen and world space, converted through a camera offset from the origin.", + "title": "Scene Switching and Lifecycle", + "description": "Switch between menu and game scenes with Space and Escape while the screen shows each scene's load, init, activation, update, draw and destroy events.", "backend": "core", - "tags": ["camera", "scene", "pointer"], - "capabilities": ["pointer"], + "featured": true, + "tags": ["scene", "lifecycle"], + "capabilities": ["keyboard"], "level": "intermediate" }, { - "slug": "pause-and-resume", - "path": "application-scenes/pause-and-resume.js", + "slug": "loading-screen", + "path": "application-scenes/loading-screen.js", "language": "typescript", - "title": "Pause and Resume", - "description": "Pause and resume the game loop by pressing Space — freeze updates without stopping the renderer or audio.", + "title": "Loading Progress and Retry", + "description": "Watch real asset-load progress, retry after a failed request, and revisit already cached assets during a second scene transition.", "backend": "core", - "tags": ["scene", "pause"], - "capabilities": ["pointer", "keyboard"], - "level": "intro" + "tags": ["scene", "loader", "assets"], + "level": "intermediate", + "capabilities": ["keyboard"] }, { - "slug": "multiple-scenes", - "path": "application-scenes/multiple-scenes.js", + "slug": "asset-catalogs", + "path": "sprites-textures/asset-catalogs.js", + "language": "typescript", + "title": "Asset Catalogs", + "description": "Declare a typed Assets catalog, merge catalogs with compose, derive one with extend, and load them through two parallel queues with per-queue progress.", + "backend": "core", + "tags": ["loader", "assets", "sprite"], + "level": "intermediate", + "capabilities": ["keyboard"] + }, + { + "slug": "pause-blur", + "path": "showcase/pause-blur.js", "language": "typescript", - "title": "Multiple Scenes", - "description": "Switch between two active Scenes using keyboard shortcuts. Demonstrates addScene, removeScene, and scene priority.", + "title": "Pause Menu", + "description": "Pause scene updates while a menu appears over the frozen world and an application tween fades in the blur; press Esc or click to resume.", "backend": "core", - "tags": ["scene", "scenes"], + "tags": ["fx", "scene"], "capabilities": ["pointer", "keyboard"], "level": "intermediate" }, @@ -88,471 +102,382 @@ "level": "advanced" }, { - "slug": "loading-screen", - "path": "application-scenes/loading-screen.js", - "language": "typescript", - "title": "Loading Screen", - "description": "Drive one progress bar from the loader-wide signals (onLoadStart, onLoadProgress, onLoadError, onLoadComplete), unsubscribe in unload, and guard the hand-over to the game scene.", - "backend": "core", - "tags": ["scene", "loader", "assets"], - "level": "intermediate", - "capabilities": ["keyboard"] - }, - { - "slug": "hud-overlay-scene", - "path": "application-scenes/hud-overlay-scene.js", - "language": "typescript", - "title": "Hud Overlay Scene", - "description": "Build a screen-fixed HUD on scene.ui — a Label and a live ProgressBar auto-rendered above the world, with no separate scene or stack.", - "backend": "core", - "tags": ["hud", "overlay", "scene", "camera"], - "level": "intermediate" - }, - { - "slug": "picture-in-picture", - "path": "application-scenes/picture-in-picture.js", + "slug": "level-loading-and-ownership", + "path": "tilemap/level-loading-and-ownership.js", "language": "typescript", - "title": "Picture in Picture", - "description": "Embed a secondary scene view inside the main view — classic minimap or security-camera picture-in-picture layout.", + "title": "Level Loading and Asset Ownership", + "description": "Load two Tiled rooms with one shared atlas, cancel a pending transition, and inspect the child scopes and atlas claims released on unload.", "backend": "core", - "tags": ["camera", "scene", "rendering"], + "tags": ["tilemap", "loader", "scopes", "cancellation"], "level": "advanced" }, { - "slug": "multi-view-split-screen", - "path": "application-scenes/multi-view-split-screen.js", + "slug": "cached-assets-offline", + "path": "application-scenes/cached-assets-offline.js", "language": "typescript", - "title": "Multi View Split Screen", - "description": "Render two independent camera views side-by-side on a single canvas using viewport scissors.", + "title": "Cached Assets Offline", + "description": "Warm an IndexedDB asset source, switch the loader to offline policy, compare a cache hit and miss, then clear and retry.", "backend": "core", - "tags": ["camera", "view", "scene", "rendering"], - "capabilities": ["keyboard"], + "tags": ["assets", "cache", "offline", "indexeddb"], "level": "advanced" } ], - "audio-basics": [ + "input-ui": [ { - "slug": "play-sound", - "path": "audio-basics/play-sound.js", + "slug": "keyboard", + "path": "input/keyboard.js", "language": "typescript", - "title": "Play Sound", - "description": "Load a Sound asset and play it on a pointer tap — covers the required user-gesture unlock and basic volume control.", + "title": "Keyboard", + "description": "Read key state per-frame and on-event with the Keyboard API. Move a square using the arrow keys.", "backend": "core", "featured": true, - "tags": ["audio", "sound"], - "capabilities": ["pointer", "audio"], + "tags": ["input", "keyboard"], + "capabilities": ["keyboard"], "level": "intro" }, { - "slug": "music-loop", - "path": "audio-basics/music-loop.js", + "slug": "mouse-and-pointer", + "path": "input/mouse-and-pointer.js", "language": "typescript", - "title": "Music Loop", - "description": "Play a seamlessly looping music track with live controls for volume, playback rate, and looping.", + "title": "Mouse and Pointer", + "description": "Track pointer position, button state, and movement delta; fire discrete click and drag events.", "backend": "core", - "tags": ["audio", "music"], - "capabilities": ["audio"], + "tags": ["input", "pointer", "mouse"], + "capabilities": ["pointer"], "level": "intro" }, { - "slug": "sound-pool", - "path": "audio-basics/sound-pool.js", + "slug": "action-mapping", + "path": "input/action-mapping.js", "language": "typescript", - "title": "Sound Pool", - "description": "Manage a fixed pool of audio instances so overlapping sounds play without clipping or stacking.", + "title": "Action Mapping", + "description": "Bind keyboard and gamepad controls to one move action and one pulse action, then read both through ActionMap.", "backend": "core", - "tags": ["audio", "sound", "pool"], - "capabilities": ["keyboard", "audio"], + "tags": ["input", "keyboard", "gamepad"], + "capabilities": ["keyboard", "gamepad"], "level": "intermediate" }, { - "slug": "random-pitch-pool", - "path": "audio-basics/random-pitch-pool.js", + "slug": "gamepad", + "path": "input/gamepad.js", "language": "typescript", - "title": "Random Pitch Pool", - "description": "Play the same sound with randomised pitch on each keypress to add variation to repeated sound effects.", + "title": "Controller Inspector", + "description": "Connect a controller to inspect live stick and button values; see a clear waiting state while disconnected.", "backend": "core", - "tags": ["audio", "sound", "pool"], - "capabilities": ["keyboard", "audio"], - "level": "intermediate" + "featured": true, + "tags": ["input", "gamepad"], + "capabilities": ["gamepad"], + "level": "intro" }, { - "slug": "audio-buses", - "path": "audio-basics/audio-buses.js", + "slug": "key-rebinding", + "path": "input/key-rebinding.js", "language": "typescript", - "title": "Audio Buses", - "description": "Route sounds through named buses (music, sfx, master) and control volume per bus with sliders.", + "title": "Key Rebinding", + "description": "Let players reassign key bindings at runtime and persist the new mapping through a serialisable InputProfile.", "backend": "core", - "tags": ["audio", "buses"], - "capabilities": ["pointer", "audio"], - "level": "intermediate" + "tags": ["input", "keyboard"], + "capabilities": ["keyboard"], + "level": "advanced" }, { - "slug": "crossfade-tracks", - "path": "audio-basics/crossfade-tracks.js", + "slug": "multitouch", + "path": "input/multitouch.js", "language": "typescript", - "title": "Crossfade Tracks", - "description": "Crossfade between two audio tracks by fading out one bus and fading in another over a configurable duration.", + "title": "Multitouch", + "description": "Track up to ten simultaneous touch points and render each finger as a labelled circle.", "backend": "core", - "tags": ["audio", "crossfade"], - "capabilities": ["pointer", "audio"], + "tags": ["input", "touch", "pointer"], + "capabilities": ["pointer", "touch"], "level": "intermediate" - } - ], - "audio-fx": [ + }, { - "slug": "reverb-and-delay", - "path": "audio-fx/reverb-and-delay.js", + "slug": "hud-and-widgets", + "path": "ui/hud-and-widgets.js", "language": "typescript", - "title": "Reverb and Delay", - "description": "Chain a reverb and a delay effect on an audio bus; tweak wet/dry, decay, and feedback live.", + "title": "HUD and Widgets", + "description": "Build a screen-fixed HUD on scene.ui: anchored score and health widgets, plus a panel of clickable, keyboard-focusable buttons that update them.", "backend": "core", - "tags": ["audio", "fx", "reverb", "delay"], - "capabilities": ["pointer", "audio"], + "tags": ["ui", "hud", "button", "widget"], "level": "intro" }, { - "slug": "compressor", - "path": "audio-fx/compressor.js", + "slug": "settings-menu", + "path": "ui/settings-menu.js", "language": "typescript", - "title": "Compressor", - "description": "Apply a dynamic compressor to even out loud and quiet audio signals. Adjust threshold, ratio, attack, and release live.", + "title": "Settings Menu and Focus", + "description": "Adjust native sliders, toggles, and dropdowns with pointer, keyboard, or controller focus; save a small settings object.", "backend": "core", - "tags": ["audio", "fx", "compressor"], - "capabilities": ["pointer", "audio"], + "tags": ["ui", "settings", "focus", "slider", "dropdown"], + "capabilities": ["keyboard", "pointer"], "level": "intermediate" }, { - "slug": "ducking", - "path": "audio-fx/ducking.js", + "slug": "text-entry-and-scrolling", + "path": "ui/text-entry-and-scrolling.js", "language": "typescript", - "title": "Ducking", - "description": "Automatically lower music volume when a sound effect plays — classic game audio ducking via the DuckingFilter.", + "title": "Text Entry and Scrolling", + "description": "Enter a name and note with native canvas text fields, add it to a list, and scroll through the entries.", "backend": "core", - "tags": ["audio", "fx", "ducking"], - "capabilities": ["pointer", "audio"], + "tags": ["ui", "text-input", "text-area", "scrolling"], + "capabilities": ["keyboard", "pointer"], "level": "intermediate" }, { - "slug": "vocoder", - "path": "audio-fx/vocoder.js", - "language": "typescript", - "title": "Vocoder", - "description": "Vocode a spoken voice phrase with a synth carrier to produce the classic robot-voice effect — click to speak, right-click to change phrase.", - "backend": "core", - "tags": ["audio", "fx", "vocoder"], - "capabilities": ["pointer", "audio"], - "level": "advanced" - }, - { - "slug": "convolution-rooms", - "path": "audio-fx/convolution-rooms.js", + "slug": "dialog-system", + "path": "showcase/dialog-system.js", "language": "typescript", - "title": "Convolution Rooms", - "description": "Strike one of four materials, panned to where it sits, and cycle through 30 recorded room impulse responses with a right-click, from a few milliseconds of colouration to a half-second cavern.", + "title": "Dialogue and Choices", + "description": "Reveal dialogue character by character, skip to the full line, and choose a reply.", "backend": "core", - "tags": ["audio", "fx", "convolution", "reverb", "impulse-response"], - "capabilities": ["pointer", "audio"], + "tags": ["scene", "text", "audio"], + "capabilities": ["pointer"], "level": "intermediate" }, { - "slug": "effect-chains", - "path": "audio-fx/effect-chains.js", + "slug": "multi-view-split-screen", + "path": "application-scenes/multi-view-split-screen.js", "language": "typescript", - "title": "Effect Chains", - "description": "Cycle a looping track through ten named effect chains — telephone, underwater, arcade, cathedral — and bypass any of them to A/B against the dry signal.", + "title": "Local Split Screen", + "description": "Move two players with separate keyboards or optional controllers while each camera follows its own player.", "backend": "core", - "tags": ["audio", "fx", "chain", "spectrum", "analyser"], - "capabilities": ["pointer", "keyboard", "audio"], + "tags": ["camera", "view", "scene", "gamepad"], + "capabilities": ["keyboard"], "level": "intermediate" } ], - "beat-detection": [ + "sprites-cameras": [ { - "slug": "beat-sync-pulse", - "path": "beat-detection/beat-sync-pulse.js", + "slug": "sprite-basics", + "path": "sprites-textures/sprite-basics.js", "language": "typescript", - "title": "Beat Sync Pulse", - "description": "Flash a circle in sync with detected beats. BeatDetector fires a callback each time onset energy crosses the threshold.", + "title": "Sprite Basics", + "description": "Load a texture, create a Sprite, and control position, rotation, scale, and alpha inside a Scene.", "backend": "core", - "tags": ["audio", "beat", "reactive"], - "capabilities": ["audio"], + "featured": true, + "tags": ["sprite", "rendering"], "level": "intro" }, { - "slug": "frequency-bands", - "path": "beat-detection/frequency-bands.js", + "slug": "nested-transforms", + "path": "scene-graph/nested-transforms.js", "language": "typescript", - "title": "Frequency Bands", - "description": "Drive animated bars from AudioAnalyser frequency data after a user gesture — sub-bass to brilliance, eight bands.", + "title": "Parent and World Transforms", + "description": "Watch a planet and moon inherit rotating Container transforms while a fixed readout compares their local and world positions.", "backend": "core", - "tags": ["audio", "reactive", "visualiser"], - "capabilities": ["audio"], + "tags": ["scene-graph", "animation"], "level": "intermediate" }, { - "slug": "tempo-tracking", - "path": "beat-detection/tempo-tracking.js", + "slug": "pivot-and-anchor", + "path": "scene-graph/pivot-and-anchor.js", "language": "typescript", - "title": "Tempo Tracking", - "description": "Estimate live BPM from an audio stream and display the running tempo estimate alongside raw onset energy.", + "title": "Pivot and Anchor", + "description": "Choose an origin preset and watch the rotating sprite's position, pivot, and bounds change together.", "backend": "core", - "tags": ["audio", "beat", "reactive"], - "capabilities": ["audio"], - "level": "intermediate" - } - ], - "custom-renderers": [ + "tags": ["scene-graph", "sprite"], + "level": "intro" + }, { - "slug": "custom-render-pass", - "path": "custom-renderers/custom-render-pass.js", + "slug": "blendmodes", + "path": "sprites-textures/blendmodes.js", "language": "typescript", - "title": "Custom Render Pass", - "description": "Compose a RenderPipeline that slots a CallbackRenderPass with procedural geometry between two scene nodes.", - "backend": "advanced", - "tags": ["rendering", "advanced", "custom"], - "level": "advanced" + "title": "Blendmodes", + "description": "Click through all built-in blend modes applied to an overlapping sprite pair to see how they composite.", + "backend": "core", + "tags": ["sprite", "rendering", "fx"], + "capabilities": ["pointer"], + "level": "intermediate" }, { - "slug": "custom-triangle-renderer", - "path": "custom-renderers/custom-triangle-renderer.js", + "slug": "masks", + "path": "scene-graph/masks.js", "language": "typescript", - "title": "Custom Triangle Renderer", - "description": "Write raw WGSL and wire a custom WebGPU pipeline alongside the built-in renderer — a minimal custom-renderer skeleton.", - "backend": "advanced", - "tags": ["rendering", "advanced", "custom", "webgpu"], - "capabilities": ["webgpu"], + "title": "Masks", + "description": "Apply a stencil mask to a Container so children only render inside the mask's geometry.", + "backend": "core", + "tags": ["scene-graph", "fx", "rendering"], "level": "advanced" - } - ], - "debug-layer": [ + }, { - "slug": "performance-overlay", - "path": "debug-layer/performance-overlay.js", + "slug": "texture-sampling", + "path": "sprites-textures/texture-sampling.js", "language": "typescript", - "title": "Performance Overlay", - "description": "Enable the built-in debug overlay to monitor FPS, frame time, GPU time, draw calls, and node count inside your scene; press B to cycle the frame budget the HUD marks overruns against, and A to toggle the asset-cache panel.", + "title": "Texture Sampling and Pixel Snapping", + "description": "Compare nearest, linear and mipmapped sampling against a fixed reference while zooming and toggling render-only pixel snapping.", "backend": "core", - "featured": true, - "tags": ["debug", "performance"], - "capabilities": ["keyboard"], + "tags": ["sprite", "texture", "sampling", "pixel-snap"], + "capabilities": ["keyboard", "pointer"], "level": "intro" }, { - "slug": "bounding-boxes", - "path": "debug-layer/bounding-boxes.js", + "slug": "world-vs-screen-coords", + "path": "application-scenes/world-vs-screen-coords.js", "language": "typescript", - "title": "Bounding Boxes", - "description": "Overlay axis-aligned bounding boxes on every rendered node to debug layout, culling, and hit-test regions.", + "title": "Camera Navigation and Coordinates", + "description": "Pan with WASD or arrows, zoom with the wheel, and place world-space markers while a screen-fixed readout shows pointer coordinates.", "backend": "core", - "tags": ["debug", "rendering"], - "level": "intro" + "tags": ["camera", "view", "pointer", "coordinates"], + "capabilities": ["keyboard", "pointer"], + "level": "intermediate" }, { - "slug": "pointer-and-hittest", - "path": "debug-layer/pointer-and-hittest.js", + "slug": "parallax-starfield", + "path": "scene-graph/parallax-starfield.js", "language": "typescript", - "title": "Pointer and Hittest", - "description": "Visualise pointer hit-test regions and see which node receives each event — useful for debugging interactive areas.", + "title": "Parallax Layers", + "description": "Move the pointer to shift three star layers at different speeds and see depth emerge from their relative motion.", "backend": "core", - "tags": ["debug", "pointer", "input"], + "tags": ["scene-graph", "rendering"], + "capabilities": ["pointer"], "level": "intermediate" }, { - "slug": "signal-bus-inspector", - "path": "debug-layer/signal-bus-inspector.js", + "slug": "picture-in-picture", + "path": "application-scenes/picture-in-picture.js", "language": "typescript", - "title": "Signal Bus Inspector", - "description": "Click to dispatch three Signals at once and watch their listener counts light up; right-click toggles a second listener on one of them.", + "title": "Picture in Picture", + "description": "Embed a secondary scene view inside the main view — classic minimap or security-camera picture-in-picture layout.", "backend": "core", - "tags": ["debug"], - "capabilities": ["pointer"], - "level": "intermediate" + "tags": ["camera", "scene", "rendering"], + "level": "advanced" }, { - "slug": "asset-browser", - "path": "debug-layer/asset-browser.js", + "slug": "mini-map", + "path": "render-targets/mini-map.js", "language": "typescript", - "title": "Asset Browser", - "description": "Interactive browser for the injected globalThis.assets catalog — inspect textures, sprites, audio, SVG, fonts, video, and input-prompt sheets with per-type previews.", + "title": "Minimap", + "description": "Render a second view of the world into a texture and clip its HUD overlay to a circle.", "backend": "core", - "tags": ["asset", "browser", "debug"], - "capabilities": ["pointer"], + "tags": ["rendering", "camera", "advanced"], "level": "intermediate" } ], - "filters": [ - { - "slug": "color-matrix-filter", - "path": "filters/color-matrix-filter.js", - "language": "typescript", - "title": "Color Matrix Filter", - "description": "Tint, desaturate, invert, brighten, push contrast and sepia-tone a sprite — click to cycle through one colour matrix.", - "backend": "core", - "tags": ["fx", "color", "filter"], - "capabilities": ["pointer"], - "level": "intro" - }, - { - "slug": "bloom-filter", - "path": "filters/bloom-filter.js", - "language": "typescript", - "title": "Bloom Filter", - "description": "Add a soft glow to the bright parts of a scene with BloomFilter — sliders drive the luminance threshold, the intensity, how far the glow spreads and how many halvings it takes.", - "backend": "core", - "tags": ["fx", "bloom", "filter"], - "level": "intro" - }, + "animation-game-feel": [ { - "slug": "blur-filter", - "path": "filters/blur-filter.js", + "slug": "frame-animation", + "path": "tweens-animation/frame-animation.js", "language": "typescript", - "title": "Blur Filter", - "description": "Apply a configurable Gaussian blur filter to a Sprite; drag a slider to adjust the radius live.", + "title": "Sprite Animation", + "description": "Play named spritesheet clips with AnimatedSprite, press Right to switch characters, and press Space to pause or resume.", "backend": "core", - "tags": ["fx", "blur", "filter"], - "level": "intro" + "tags": ["animation", "sprite"], + "level": "intermediate" }, { - "slug": "drop-shadow-filter", - "path": "filters/drop-shadow-filter.js", + "slug": "tween-basics", + "path": "tweens-animation/tween-basics.js", "language": "typescript", - "title": "Drop Shadow Filter", - "description": "Give a Sprite a soft, offset drop shadow with DropShadowFilter; sliders drive offset, blur and opacity, and a toggle shows the shadow on its own.", + "title": "Interactive Tweens", + "description": "Click to move a Sprite, click again to interrupt its Tween, and press Space to toggle a yoyo round trip.", "backend": "core", - "tags": ["fx", "shadow", "filter"], + "tags": ["animation", "tween"], + "capabilities": ["keyboard", "pointer"], "level": "intro" }, { - "slug": "chromatic-aberration", - "path": "filters/chromatic-aberration.js", + "slug": "tween-chains", + "path": "tweens-animation/tween-chains.js", "language": "typescript", - "title": "Chromatic Aberration", - "description": "Offset the red and blue channels in opposite directions to simulate lens chromatic aberration — an intensity slider drives the split live.", + "title": "Tween Sequences", + "description": "Build a looping route from an array of waypoints by chaining Tweens once and restarting the first step after the last completes.", "backend": "core", - "tags": ["fx", "filter", "shader"], + "tags": ["animation", "tween"], "level": "intermediate" }, { - "slug": "displacement-filter", - "path": "filters/displacement-filter.js", + "slug": "easing-curves", + "path": "tweens-animation/easing-curves.js", "language": "typescript", - "title": "Displacement Filter", - "description": "Warp a sprite by a direction stored in a map texture — a scrolling ripple map with a live scale slider.", + "title": "Easing Explorer", + "description": "Use Left/Right or click either side to select any built-in easing function and watch a Sprite trace its large curve.", "backend": "core", - "tags": ["fx", "filter", "shader"], + "tags": ["animation", "tween"], "level": "intermediate" }, { - "slug": "filter-stack", - "path": "filters/filter-stack.js", + "slug": "screen-shake-on-explosion", + "path": "showcase/screen-shake-on-explosion.js", "language": "typescript", - "title": "Filter Stack", - "description": "Chain multiple filters on a single sprite and toggle each layer independently to see how they compose.", + "title": "Impact Feedback", + "description": "Click to flash a sprite, burst particles, and shake the camera as one impact response.", "backend": "core", - "tags": ["fx", "filter"], + "tags": ["camera", "fx", "particles"], + "capabilities": ["pointer"], "level": "intermediate" }, { - "slug": "noise-vignette", - "path": "filters/noise-vignette.js", + "slug": "gamepad-spaceship", + "path": "showcase/gamepad-spaceship.js", "language": "typescript", - "title": "Noise Vignette", - "description": "Combine animated film grain and a radial vignette in a single pass filter — common cinematic post-processing.", + "title": "Fly a Spaceship", + "description": "Steer and fire with keyboard or gamepad; collect hits, sparks and optional controller rumble.", "backend": "core", - "tags": ["fx", "filter", "shader"], + "featured": true, + "tags": ["gamepad", "keyboard", "input", "audio", "scene"], + "capabilities": [], "level": "intermediate" }, { - "slug": "crt-scanlines", - "path": "filters/crt-scanlines.js", + "slug": "boss-intro-cinematic", + "path": "showcase/boss-intro-cinematic.js", "language": "typescript", - "title": "Crt Scanlines", - "description": "Overlay a CRT scanline pattern via a custom fragment shader filter — classic retro screen post-processing.", + "title": "Boss Intro Cinematic", + "description": "Tween-driven cinematic sequence with a dramatic boss reveal, screen shake, and synced audio sting.", "backend": "core", - "tags": ["fx", "filter", "shader"], + "tags": ["animation", "audio", "scene"], + "capabilities": ["pointer", "keyboard", "audio"], "level": "intermediate" - }, - { - "slug": "palette-cycling", - "path": "filters/palette-cycling.js", - "language": "typescript", - "title": "Colour Ramp Cycling", - "description": "Shift an RGB 1D LUT each frame to recolour a sprite — three per-channel curves sweeping the colour wheel.", - "backend": "core", - "tags": ["lut", "ramp", "color"], - "level": "advanced" - }, - { - "slug": "custom-fragment-shader", - "path": "filters/custom-fragment-shader.js", - "language": "typescript", - "title": "Custom Fragment Shader", - "description": "Write a GLSL/WGSL fragment shader and wire it as a Filter on a sprite — shows the minimal filter authoring API.", - "backend": "core", - "tags": ["fx", "filter", "shader", "advanced"], - "level": "advanced" - }, - { - "slug": "metaballs", - "path": "filters/metaballs.js", - "language": "typescript", - "title": "Metaballs", - "description": "Render organic metaball blobs using a threshold-based signed-distance shader filter applied to moving circles.", - "backend": "core", - "tags": ["fx", "filter", "shader"], - "level": "advanced" } ], - "geometry-graphics": [ + "graphics-text": [ { - "slug": "graphics-primitives", - "path": "geometry-graphics/graphics-primitives.js", + "slug": "graphics-gradient", + "path": "geometry-graphics/graphics-gradient.js", "language": "typescript", - "title": "Graphics Primitives", - "description": "Draw filled and stroked rectangles, circles, polygons, and Bézier curves with the Graphics API.", + "title": "Shapes and Gradients", + "description": "Draw rectangles, circles, polygons, arcs and stars with solid colors plus linear and radial fill or stroke gradients.", "backend": "core", "featured": true, - "tags": ["graphics", "rendering"], - "capabilities": ["webgpu"], + "tags": ["graphics", "gradient", "fillStyle", "strokeStyle"], "level": "intro" }, { - "slug": "graphics-gradient", - "path": "geometry-graphics/graphics-gradient.js", + "slug": "typographic-styling", + "path": "text-fonts/typographic-styling.js", "language": "typescript", - "title": "Graphics Gradient", - "description": "Paint Graphics shapes with gradients through the fillStyle / strokeStyle API: a linear-gradient rectangle, a radial-gradient circle, a radial-gradient stroked ring, and a spinning linear-gradient star.", + "title": "Text Styles and Fonts", + "description": "Load a custom font and compare it with the default while a styled title demonstrates gradient fill, outline and shadow.", "backend": "core", - "tags": ["graphics", "gradient", "fillStyle", "strokeStyle"], + "tags": ["text", "typography", "font"], "level": "intro" }, { - "slug": "gradient", - "path": "geometry-graphics/gradient.js", + "slug": "multiline-and-wrap", + "path": "text-fonts/multiline-and-wrap.js", "language": "typescript", - "title": "Gradient", - "description": "Use a Gradient Drawable to fill a rectangle with a smooth linear or radial colour gradient.", + "title": "Multiline and Wrap", + "description": "Configure word-wrap width, line height, and horizontal alignment for multi-line Text drawables.", "backend": "core", - "tags": ["graphics", "gradient", "rendering"], - "level": "intro" + "tags": ["text"], + "level": "intermediate" }, { - "slug": "mesh-triangle", - "path": "geometry-graphics/mesh-triangle.js", + "slug": "bitmap-text-basic", + "path": "text-fonts/bitmap-text-basic.js", "language": "typescript", - "title": "Mesh Triangle", - "description": "Render a single vertex-coloured triangle with a custom Mesh — the minimal GPU geometry example.", + "title": "Bitmap Text Basic", + "description": "BitmapText rendered from an AngelCode .fnt atlas (Kenney Blocks, CC0). Demonstrates pre-built atlas rendering, word wrap, and live text updates without Canvas 2D rasterisation.", "backend": "core", - "tags": ["mesh", "graphics", "rendering"], + "tags": ["bitmap", "text", "bmfont"], "level": "intermediate" }, { "slug": "mesh-textured-quad", "path": "geometry-graphics/mesh-textured-quad.js", "language": "typescript", - "title": "Mesh Textured Quad", - "description": "Map a texture onto a custom Mesh quad and control UV coordinates per vertex.", + "title": "Mesh Vertices and UVs", + "description": "Compare vertex colors on a custom triangle with UV-mapped texture coordinates on a rotating quad.", "backend": "core", "tags": ["mesh", "graphics", "rendering"], "level": "intermediate" @@ -576,178 +501,146 @@ "backend": "core", "tags": ["mesh", "shader", "rendering"], "level": "advanced" - }, - { - "slug": "infinite-grid", - "path": "geometry-graphics/infinite-grid.js", - "language": "typescript", - "title": "Infinite Grid", - "description": "Render an infinite scrolling grid in screen space as a debug aid for level editors and world-space tools.", - "backend": "core", - "tags": ["graphics", "rendering", "debug"], - "capabilities": ["keyboard"], - "level": "advanced" - }, - { - "slug": "immediate-mode-rendering", - "path": "geometry-graphics/immediate-mode-rendering.js", - "language": "typescript", - "title": "Immediate-Mode Rendering", - "description": "Draw procedural geometry without a scene graph: gears via drawGeometry and a 2,400-spark field as a single instanced RenderBatch draw call, with a live drawCalls readout.", - "backend": "core", - "tags": ["rendering", "geometry", "instancing", "performance"], - "level": "advanced" } ], - "getting-started": [ + "effects-rendering": [ { - "slug": "hello-world", - "path": "getting-started/hello-world.js", + "slug": "bloom-filter", + "path": "filters/bloom-filter.js", "language": "typescript", - "title": "Hello World", - "description": "Create an Application, attach a Scene, draw a Graphics shape, and see the first render cycle — the minimal ExoJS starting point.", + "title": "Bloom Filter", + "description": "Add a soft glow to the bright parts of a scene with BloomFilter — sliders drive the luminance threshold, the intensity, how far the glow spreads and how many halvings it takes.", "backend": "core", - "featured": true, - "tags": ["scene", "graphics", "rendering"], + "tags": ["fx", "bloom", "filter"], "level": "intro" }, { - "slug": "game-loop", - "path": "getting-started/game-loop.js", + "slug": "blur-filter", + "path": "filters/blur-filter.js", "language": "typescript", - "title": "Game Loop", - "description": "Run a single Scene with a fixed-step update loop, draw a spinning rectangle, and observe how tick and deltaTime drive motion.", + "title": "Blur and Drop Shadow", + "description": "Compare an original sprite with a live blur or offset shadow, adjust each filter, and bypass it.", "backend": "core", - "featured": true, - "tags": ["game", "loop", "scene"], + "tags": ["fx", "blur", "shadow", "filter"], "level": "intro" }, { - "slug": "resize-and-dpr", - "path": "getting-started/resize-and-dpr.js", + "slug": "color-matrix-filter", + "path": "filters/color-matrix-filter.js", "language": "typescript", - "title": "Resize and DPR", - "description": "Handle window resizes and device pixel ratio changes so the canvas stays sharp on high-DPI screens without stretching.", + "title": "Color Transforms", + "description": "Compare the original image with grayscale or sepia matrices, independent 1D channel curves, and a cool 3D LUT.", "backend": "core", - "tags": ["resize", "dpr", "responsive"], - "level": "intro" - } - ], - "input": [ + "tags": ["fx", "color", "filter", "lut"], + "level": "intermediate" + }, { - "slug": "keyboard", - "path": "input/keyboard.js", + "slug": "crt-scanlines", + "path": "filters/crt-scanlines.js", "language": "typescript", - "title": "Keyboard", - "description": "Read key state per-frame and on-event with the Keyboard API. Move a square using the arrow keys.", + "title": "Retro Display", + "description": "Tune a combined CRT filter with scanlines, grain, vignette, barrel distortion, and RGB separation; bypass it to compare the clean scene.", "backend": "core", - "featured": true, - "tags": ["input", "keyboard"], - "capabilities": ["keyboard"], - "level": "intro" + "tags": ["fx", "filter", "shader"], + "level": "intermediate" }, { - "slug": "mouse-and-pointer", - "path": "input/mouse-and-pointer.js", + "slug": "custom-fragment-shader", + "path": "filters/custom-fragment-shader.js", "language": "typescript", - "title": "Mouse and Pointer", - "description": "Track pointer position, button state, and movement delta; fire discrete click and drag events.", + "title": "Write a Fragment Filter", + "description": "Move a ripple over a UV grid while one typed uniform schema feeds matching GLSL and WGSL filter shaders.", "backend": "core", - "tags": ["input", "pointer", "mouse"], + "tags": ["fx", "filter", "shader", "pointer"], "capabilities": ["pointer"], - "level": "intro" + "level": "advanced" }, { - "slug": "pointer-to-world", - "path": "input/pointer-to-world.js", + "slug": "metaballs", + "path": "filters/metaballs.js", "language": "typescript", - "title": "Pointer to World", - "description": "Convert screen-space pointer coordinates to world-space positions as the camera pans and zooms.", + "title": "Metaballs and Filter Order", + "description": "Drag a blob into moving circles, then reverse or bypass blur and threshold passes to see how filter order changes the result.", "backend": "core", - "tags": ["input", "pointer", "camera"], + "tags": ["fx", "filter", "shader", "pointer"], "capabilities": ["pointer"], - "level": "intermediate" + "level": "advanced" }, { - "slug": "gamepad", - "path": "input/gamepad.js", + "slug": "render-to-texture", + "path": "render-targets/render-to-texture.js", "language": "typescript", - "title": "Gamepad", - "description": "Read axes and buttons from a connected gamepad — plug in a controller and drive a circle around the canvas.", + "title": "Capture a Texture", + "description": "Compare a live render texture with a frozen snapshot, then click to capture a new frame.", "backend": "core", - "featured": true, - "tags": ["input", "gamepad"], - "capabilities": ["gamepad"], + "tags": ["rendering", "advanced"], "level": "intro" }, { - "slug": "multitouch", - "path": "input/multitouch.js", + "slug": "trail-feedback", + "path": "render-targets/trail-feedback.js", "language": "typescript", - "title": "Multitouch", - "description": "Track up to ten simultaneous touch points and render each finger as a labelled circle.", + "title": "Trail Feedback", + "description": "Create motion trails by feeding the previous frame back into the current frame with slight fade and blur.", "backend": "core", - "tags": ["input", "touch", "pointer"], - "capabilities": ["pointer", "touch"], + "tags": ["fx", "rendering", "advanced"], "level": "intermediate" }, { - "slug": "action-mapping", - "path": "input/action-mapping.js", + "slug": "water-mirror", + "path": "render-targets/water-mirror.js", "language": "typescript", - "title": "Action Mapping", - "description": "Map multiple physical inputs (keyboard keys + gamepad buttons) to named actions and read them identically in the update loop.", + "title": "Water Reflection and Distortion", + "description": "Capture and flip a live reflection, then drag across the water to change the strength of its scrolling displacement map.", "backend": "core", - "tags": ["input", "keyboard", "gamepad"], - "capabilities": ["keyboard", "gamepad"], - "level": "intermediate" + "tags": ["fx", "rendering", "displacement", "pointer"], + "capabilities": ["pointer"], + "level": "advanced" }, { - "slug": "key-rebinding", - "path": "input/key-rebinding.js", + "slug": "video-drawable", + "path": "sprites-textures/video-drawable.js", "language": "typescript", - "title": "Key Rebinding", - "description": "Let players reassign key bindings at runtime and persist the new mapping through a serialisable InputProfile.", + "title": "Video Drawable", + "description": "Stream a video element as a live GPU texture and composite it with sprites and filters.", "backend": "core", - "tags": ["input", "keyboard"], - "capabilities": ["keyboard"], + "tags": ["sprite", "rendering"], + "capabilities": ["pointer", "keyboard", "audio"], "level": "advanced" }, { - "slug": "multi-gamepad", - "path": "input/multi-gamepad.js", + "slug": "render-pipeline", + "path": "render-targets/render-pipeline.js", "language": "typescript", - "title": "Multi Gamepad", - "description": "Handle multiple connected gamepads simultaneously — each controller drives its own coloured square.", + "title": "Composable Render Pipeline", + "description": "Compose named offscreen, optional blur, custom callback, and screen-space HUD passes; toggle blur to compare the live bypass.", "backend": "core", - "tags": ["input", "gamepad"], - "capabilities": ["gamepad"], + "tags": ["rendering", "advanced", "fx"], "level": "advanced" - } - ], - "lighting": [ + }, { - "slug": "normal-mapped-sprites", - "path": "lighting/normal-mapped-sprites.js", + "slug": "custom-triangle-renderer", + "path": "custom-renderers/custom-triangle-renderer.js", "language": "typescript", - "title": "Normal-Mapped Sprites", - "description": "Forward normal mapping on plain sprites: a LitMaterial samples a tangent-space normal map and shades every sprite against four moving point lights in a single batch, with spinning and mirrored tiles proving the basis rotation.", - "backend": "core", - "featured": false, - "tags": ["lighting", "material", "shader"], - "capabilities": [], + "title": "Custom Triangle Renderer", + "description": "Write raw WGSL and wire a custom WebGPU pipeline alongside the built-in renderer — a minimal custom-renderer skeleton.", + "backend": "advanced", + "tags": ["rendering", "advanced", "custom", "webgpu"], + "capabilities": ["webgpu"], "level": "advanced" - }, + } + ], + "lighting": [ { "slug": "many-lights", "path": "lighting/many-lights.js", "language": "typescript", - "title": "Many Lights", - "description": "Drag the slider from 1 to 48 point lights over a normal-mapped floor: the lights live in a data texture, so the count is a shader loop bound rather than a recompile, and the whole floor stays a single draw call.", + "title": "Normal-Mapped Lighting", + "description": "Drag a point light across a normal-mapped floor, then vary the active light count from 1 to 48 and watch the draw-call readout.", "backend": "core", "featured": false, "tags": ["lighting", "material", "shader", "performance"], - "capabilities": [], + "capabilities": ["pointer"], "level": "advanced" }, { @@ -762,18 +655,6 @@ "capabilities": [], "level": "advanced" }, - { - "slug": "radiance-rooms", - "path": "lighting/radiance-rooms.js", - "language": "typescript", - "title": "Radiance Rooms", - "description": "Radiance cascades against the light-quad renderer on one scene: a single lamp in one of two rooms, a doorway between them, and a switch that rebuilds the lighting system so the same lights can be seen drawn and transported.", - "backend": "core", - "featured": false, - "tags": ["lighting", "shadows", "shader"], - "capabilities": [], - "level": "advanced" - }, { "slug": "light-cookies", "path": "lighting/light-cookies.js", @@ -797,7 +678,19 @@ "tags": ["lighting", "material", "shader"], "capabilities": [], "level": "advanced" - } + }, + { + "slug": "radiance-rooms", + "path": "lighting/radiance-rooms.js", + "language": "typescript", + "title": "Radiance Rooms", + "description": "Radiance cascades against the light-quad renderer on one scene: a single lamp in one of two rooms, a doorway between them, and a switch that rebuilds the lighting system so the same lights can be seen drawn and transported.", + "backend": "core", + "featured": false, + "tags": ["lighting", "shadows", "shader"], + "capabilities": [], + "level": "advanced" + } ], "particles": [ { @@ -810,27 +703,6 @@ "tags": ["particles"], "level": "intro" }, - { - "slug": "bonfire", - "path": "particles/bonfire.js", - "language": "typescript", - "title": "Bonfire", - "description": "Simulate a campfire with an emitter that varies particle colour, lifetime, and velocity over the flame shape.", - "backend": "core", - "tags": ["particles"], - "level": "intermediate" - }, - { - "slug": "fireworks", - "path": "particles/fireworks.js", - "language": "typescript", - "title": "Fireworks", - "description": "Click to launch a firework rocket that bursts into a radial particle shower at its apex.", - "backend": "core", - "tags": ["particles", "pointer"], - "capabilities": ["pointer"], - "level": "intermediate" - }, { "slug": "cursor-attractor-particles", "path": "particles/cursor-attractor-particles.js", @@ -843,449 +715,24 @@ "level": "intermediate" }, { - "slug": "gpu-particles", - "path": "particles/gpu-particles.js", - "language": "typescript", - "title": "Gpu Particles", - "description": "Run hundreds of thousands of particles on the GPU via WebGPU compute shaders, with a smaller CPU-simulated budget on WebGL2 — the live count shows the active backend.", - "backend": "core", - "tags": ["particles", "webgpu", "performance"], - "level": "advanced" - }, - { - "slug": "custom-wgsl-module", - "path": "particles/custom-wgsl-module.js", - "language": "typescript", - "title": "Custom Wgsl Module", - "description": "Supply a custom WGSL compute module to the GPU particle system to implement non-standard particle behaviour.", - "backend": "core", - "tags": ["particles", "advanced", "webgpu"], - "level": "advanced" - } - ], - "pathfinding": [ - { - "slug": "grid-navigation", - "path": "pathfinding/grid-navigation.js", - "language": "typescript", - "title": "Grid Navigation", - "description": "Click to move an agent across a weighted grid: paint walls and mud, toggle jump-point pruning, path smoothing and a 2x2 agent, and watch the expanded-node counter react.", - "backend": "core", - "featured": true, - "tags": ["pathfinding", "grid", "pointer"], - "capabilities": ["pointer"], - "level": "intermediate" - }, - { - "slug": "tilemap-navigation", - "path": "pathfinding/tilemap-navigation.js", - "language": "typescript", - "title": "Tilemap Navigation", - "description": "Build a GridSpace from a tile layer through a cost callback, then route an agent over it — carving a door edits map and grid together.", - "backend": "core", - "tags": ["pathfinding", "tilemap", "pointer"], - "capabilities": ["pointer"], - "level": "intermediate" - } - ], - "performance": [ - { - "slug": "sprite-stress", - "path": "performance/sprite-stress.js", - "language": "typescript", - "title": "Sprite Stress", - "description": "Render thousands of sprites to measure raw throughput; compare WebGPU and WebGL2 backends at high counts.", - "backend": "core", - "featured": true, - "tags": ["performance", "rendering", "webgpu"], - "capabilities": ["webgpu"], - "level": "intermediate" - }, - { - "slug": "multi-texture-stress", - "path": "performance/multi-texture-stress.js", - "language": "typescript", - "title": "Multi Texture Stress", - "description": "Render sprites from many different textures simultaneously to stress-test texture-atlas batching.", - "backend": "core", - "tags": ["performance", "rendering"], - "capabilities": ["webgpu"], - "level": "intermediate" - }, - { - "slug": "particle-stress", - "path": "performance/particle-stress.js", - "language": "typescript", - "title": "Particle Stress", - "description": "Push the GPU particle system to its limits — ramp up particle count until frame time degrades.", - "backend": "core", - "tags": ["performance", "particles", "webgpu"], - "capabilities": ["webgpu"], - "level": "intermediate" - }, - { - "slug": "backend-comparison", - "path": "performance/backend-comparison.js", - "language": "typescript", - "title": "Backend Comparison", - "description": "Switch between WebGPU and WebGL2 backends at runtime and compare frame times for the same scene.", - "backend": "core", - "tags": ["performance", "webgpu", "rendering"], - "capabilities": ["keyboard"], - "level": "intermediate" - }, - { - "slug": "coroutine-loading", - "path": "performance/coroutine-loading.js", - "language": "typescript", - "title": "Coroutine Loading", - "description": "Spread world building over frames with a coroutine: a progress-driven loading bar, a share-based frame budget, and cancellation.", - "backend": "core", - "tags": ["performance", "coroutines"], - "capabilities": ["keyboard"], - "level": "intermediate" - } - ], - "physics": [ - { - "slug": "sprite-follows-body", - "path": "physics/sprite-follows-body.js", - "language": "typescript", - "title": "Sprite Follows Body", - "description": "The minimal physics binding: world.attach builds a body + collider and binds it to a sprite, which then tracks the body each step as it falls onto a static floor.", - "backend": "core", - "tags": ["physics", "binding"], - "level": "intro" - }, - { - "slug": "tiled-map-physics-actor", - "path": "physics/tiled-map-physics-actor.js", - "language": "typescript", - "title": "Tiled Map + Physics Actor", - "description": "Build static colliders from a tilemap object layer with the ObjectLayer-to-collider bridge, then drop a dynamic actor that falls and bounces across the rendered level.", - "backend": "core", - "tags": ["physics", "tilemap", "collision"], - "level": "intermediate" - } - ], - "render-targets": [ - { - "slug": "render-to-texture", - "path": "render-targets/render-to-texture.js", - "language": "typescript", - "title": "Render to Texture", - "description": "Draw a scene into a RenderTarget and sample the result as a texture on another sprite in the same frame.", - "backend": "core", - "tags": ["rendering", "advanced"], - "level": "intro" - }, - { - "slug": "mini-map", - "path": "render-targets/mini-map.js", - "language": "typescript", - "title": "Mini Map", - "description": "Render a second camera view into a RenderTarget and display it as a minimap overlay in the HUD.", - "backend": "core", - "tags": ["rendering", "camera", "advanced"], - "level": "intermediate" - }, - { - "slug": "trail-feedback", - "path": "render-targets/trail-feedback.js", - "language": "typescript", - "title": "Trail Feedback", - "description": "Create motion trails by feeding the previous frame back into the current frame with slight fade and blur.", - "backend": "core", - "tags": ["fx", "rendering", "advanced"], - "level": "intermediate" - }, - { - "slug": "bloom-lite", - "path": "render-targets/bloom-lite.js", - "language": "typescript", - "title": "Bloom Lite", - "description": "Multi-pass bloom: render the scene into a RenderTarget, blur it, and composite back with additive blending.", - "backend": "core", - "tags": ["fx", "rendering", "advanced"], - "level": "advanced" - }, - { - "slug": "water-mirror", - "path": "render-targets/water-mirror.js", - "language": "typescript", - "title": "Water Mirror", - "description": "Reflect the scene in a distorted water surface using a flipped RenderTarget and a ripple displacement shader.", - "backend": "core", - "tags": ["fx", "rendering", "advanced"], - "level": "advanced" - }, - { - "slug": "post-processing-chain", - "path": "render-targets/post-processing-chain.js", - "language": "typescript", - "title": "Post Processing Chain", - "description": "Chain multiple RenderTarget passes to build a full-scene post-processing pipeline (blur → vignette → colour grade).", - "backend": "core", - "tags": ["fx", "rendering", "advanced"], - "level": "advanced" - }, - { - "slug": "render-pipeline", - "path": "render-targets/render-pipeline.js", - "language": "typescript", - "title": "Render Pipeline", - "description": "Compose a frame from named RenderPass steps: render the world off-screen, blur it, composite to the screen, and nest a UI pipeline — with a runtime-toggleable blur step and resize wiring.", - "backend": "core", - "tags": ["rendering", "advanced", "fx"], - "level": "advanced" - } - ], - "scene-graph": [ - { - "slug": "containers", - "path": "scene-graph/containers.js", - "language": "typescript", - "title": "Containers", - "description": "Group sprites into a Container node and transform the group as a unit — translation, scale, and alpha propagate to children.", - "backend": "core", - "tags": ["scene-graph", "rendering"], - "level": "intro" - }, - { - "slug": "retained-container", - "path": "scene-graph/retained-container.js", - "language": "typescript", - "title": "Retained Container", - "description": "Hold a large static sprite field in a RetainedContainer and pan the whole group as a camera: the retained tier replays the subtree as O(batches) with one group-matrix update, while a plain Container walks every child each frame. Toggle the tier and compare the frame-time readout.", - "backend": "core", - "tags": ["scene-graph", "rendering", "performance"], - "level": "advanced" - }, - { - "slug": "pivot-and-anchor", - "path": "scene-graph/pivot-and-anchor.js", - "language": "typescript", - "title": "Pivot and Anchor", - "description": "Set pivot and anchor on a Sprite to control the point around which it rotates and how it aligns to its position.", - "backend": "core", - "tags": ["scene-graph", "sprite"], - "level": "intro" - }, - { - "slug": "nested-transforms", - "path": "scene-graph/nested-transforms.js", - "language": "typescript", - "title": "Nested Transforms", - "description": "Build a solar-system hierarchy of orbiting nodes to practice parent-child transform composition.", - "backend": "core", - "tags": ["scene-graph", "animation"], - "level": "intermediate" - }, - { - "slug": "local-vs-global-transform", - "path": "scene-graph/local-vs-global-transform.js", - "language": "typescript", - "title": "Local Vs Global Transform", - "description": "Compare local and world transforms on nested nodes and see how parent transforms compose with children.", - "backend": "core", - "tags": ["scene-graph", "rendering"], - "level": "intermediate" - }, - { - "slug": "z-ordering", - "path": "scene-graph/z-ordering.js", - "language": "typescript", - "title": "Z Ordering", - "description": "Change zIndex on sprites at runtime and observe draw order update instantly without re-sorting the scene graph.", - "backend": "core", - "tags": ["scene-graph", "rendering"], - "capabilities": ["keyboard"], - "level": "intermediate" - }, - { - "slug": "parallax-starfield", - "path": "scene-graph/parallax-starfield.js", - "language": "typescript", - "title": "Parallax Starfield", - "description": "Layer three Container rows at different scroll speeds to create a parallax depth effect driven by pointer movement.", - "backend": "core", - "tags": ["scene-graph", "rendering"], - "capabilities": ["pointer"], - "level": "intermediate" - }, - { - "slug": "masks", - "path": "scene-graph/masks.js", - "language": "typescript", - "title": "Masks", - "description": "Apply a stencil mask to a Container so children only render inside the mask's geometry.", - "backend": "core", - "tags": ["scene-graph", "fx", "rendering"], - "level": "advanced" - } - ], - "showcase": [ - { - "slug": "orb-dodge", - "path": "showcase/orb-dodge.js", - "language": "typescript", - "title": "Orb Dodge", - "description": "A complete dodge-and-collect loop: move with WASD or arrow keys, collect green orbs for points, avoid red orbs, and restart from the game-over screen.", - "backend": "core", - "featured": true, - "tags": ["game", "input", "collision", "hud"], - "capabilities": ["keyboard"], - "level": "intermediate" - }, - { - "slug": "gamepad-spaceship", - "path": "showcase/gamepad-spaceship.js", - "language": "typescript", - "title": "Gamepad Spaceship", - "description": "Fly a spaceship with a gamepad: left stick steers, right trigger fires, and rumble kicks on impact.", - "backend": "core", - "featured": true, - "tags": ["gamepad", "input", "audio", "scene"], - "capabilities": ["gamepad"], - "level": "intermediate" - }, - { - "slug": "damage-flash", - "path": "showcase/damage-flash.js", - "language": "typescript", - "title": "Damage Flash", - "description": "Click a sprite to trigger a red flash tween — a common game-feel pattern for hit feedback.", - "backend": "core", - "tags": ["animation", "fx"], - "capabilities": ["pointer"], - "level": "intro" - }, - { - "slug": "screen-shake-on-explosion", - "path": "showcase/screen-shake-on-explosion.js", - "language": "typescript", - "title": "Screen Shake on Explosion", - "description": "Click to detonate — trigger an explosion particle burst followed by a camera shake tween.", - "backend": "core", - "tags": ["camera", "fx", "particles"], - "capabilities": ["pointer"], - "level": "intermediate" - }, - { - "slug": "typewriter-text", - "path": "showcase/typewriter-text.js", - "language": "typescript", - "title": "Typewriter Text", - "description": "Reveal text character-by-character with a configurable delay and a key-click sound effect per character.", - "backend": "core", - "tags": ["text", "animation", "audio"], - "level": "intro" - }, - { - "slug": "dialog-system", - "path": "showcase/dialog-system.js", - "language": "typescript", - "title": "Dialog System", - "description": "Click through a typewriter-animated dialog box with portrait, name plate, and choice buttons — game UI recipe.", - "backend": "core", - "tags": ["scene", "text", "audio"], - "capabilities": ["pointer"], - "level": "intermediate" - }, - { - "slug": "mouse-parallax", - "path": "showcase/mouse-parallax.js", + "slug": "fireworks", + "path": "particles/fireworks.js", "language": "typescript", - "title": "Mouse Parallax", - "description": "Move layered background planes at different speeds relative to pointer position for a depth parallax effect.", + "title": "Fireworks", + "description": "Click to launch a firework rocket that bursts into a radial particle shower at its apex.", "backend": "core", - "tags": ["scene-graph", "rendering", "pointer"], + "tags": ["particles", "pointer"], "capabilities": ["pointer"], - "level": "intro" - }, - { - "slug": "rectangles-collision", - "path": "showcase/rectangles-collision.js", - "language": "typescript", - "title": "Rectangles Collision", - "description": "Drag rectangles around the canvas and detect AABB overlap in the update loop — foundational collision recipe.", - "backend": "core", - "tags": ["scene", "rendering"], - "level": "intro" - }, - { - "slug": "pause-blur", - "path": "showcase/pause-blur.js", - "language": "typescript", - "title": "Pause Blur", - "description": "Pause the game, fade up a blur filter over the scene, and show a pause menu — common game polish pattern.", - "backend": "core", - "tags": ["fx", "scene"], - "capabilities": ["pointer", "keyboard"], - "level": "intermediate" - }, - { - "slug": "boss-intro-cinematic", - "path": "showcase/boss-intro-cinematic.js", - "language": "typescript", - "title": "Boss Intro Cinematic", - "description": "Tween-driven cinematic sequence with a dramatic boss reveal, screen shake, and synced audio sting.", - "backend": "core", - "tags": ["animation", "audio", "scene"], - "capabilities": ["pointer", "keyboard", "audio"], "level": "intermediate" }, { - "slug": "loading-progress-with-shader", - "path": "showcase/loading-progress-with-shader.js", - "language": "typescript", - "title": "Loading Progress with Shader", - "description": "Show an animated progress ring during asset loading using a custom GLSL fill-sweep shader.", - "backend": "core", - "tags": ["fx", "shader", "scene"], - "level": "intermediate" - }, - { - "slug": "minimap-with-mask", - "path": "showcase/minimap-with-mask.js", - "language": "typescript", - "title": "Minimap with Mask", - "description": "Render a secondary camera view inside a circular stencil mask to create a round minimap overlay.", - "backend": "core", - "tags": ["rendering", "camera", "fx"], - "level": "intermediate" - }, - { - "slug": "color-grading", - "path": "showcase/color-grading.js", - "language": "typescript", - "title": "Color Grading", - "description": "Click or press Space to cycle procedurally-built 3D LUTs (Sepia, Cool Cinematic, Warm Sunset, Bleach Bypass, Protanopia simulation) plus an Identity bypass, on a single sprite.", - "backend": "core", - "tags": ["lut", "color", "grading"], - "capabilities": ["pointer", "keyboard"], - "level": "intermediate" - }, - { - "slug": "vinyl-record", - "path": "showcase/vinyl-record.js", - "language": "typescript", - "title": "Vinyl Record", - "description": "Spin an animated vinyl record that speeds up or slows down as music plays — an audio-driven visual recipe.", - "backend": "core", - "tags": ["audio", "animation"], - "capabilities": ["audio"], - "level": "intermediate" - }, - { - "slug": "audio-visualisation", - "path": "showcase/audio-visualisation.js", + "slug": "bonfire", + "path": "particles/bonfire.js", "language": "typescript", - "title": "Audio Visualisation", - "description": "Full-screen audio visualiser: frequency bars, waveform, and a beat-pulse ring driven by AudioAnalyser data.", + "title": "Campfire", + "description": "Adjust flame intensity and wind while fire and smoke rise from a visible ring of logs and stones.", "backend": "core", - "tags": ["audio", "reactive", "visualiser"], - "capabilities": ["pointer", "audio"], + "tags": ["particles"], "level": "intermediate" }, { @@ -1294,209 +741,191 @@ "language": "typescript", "title": "Audio Reactive Particles", "description": "Emit particles driven by real-time AudioAnalyser frequency data — bass controls density, treble controls spread.", - "backend": "core", - "featured": true, - "tags": ["audio", "reactive", "particles"], - "capabilities": ["audio"], - "level": "intermediate" - }, - { - "slug": "low-band-camera-shake", - "path": "showcase/low-band-camera-shake.js", - "language": "typescript", - "title": "Low Band Camera Shake", - "description": "Shake the camera in sync with low-frequency beats using AudioAnalyser band energy as a shake amplitude.", - "backend": "core", - "tags": ["audio", "reactive", "camera"], - "capabilities": ["audio"], - "level": "advanced" - } - ], - "spatial-audio": [ - { - "slug": "listener-and-source", - "path": "spatial-audio/listener-and-source.js", - "language": "typescript", - "title": "Listener and Source", - "description": "Drag a sound source around a fixed listener and watch distance-based spatial attenuation update live.", - "backend": "core", - "tags": ["listener", "source", "spatial"], - "capabilities": ["pointer", "audio"], - "level": "intro" + "backend": "core", + "featured": true, + "tags": ["audio", "reactive", "particles"], + "capabilities": ["audio"], + "level": "intermediate" }, { - "slug": "moving-source", - "path": "spatial-audio/moving-source.js", + "slug": "gpu-particles", + "path": "particles/gpu-particles.js", "language": "typescript", - "title": "Moving Source", - "description": "An automatically circling spatial source orbits a fixed listener to demonstrate continuous panning and attenuation.", + "title": "Particle Capacity", + "description": "Adjust the spawn rate and watch live occupancy, frame time, and whether particles run on WebGPU compute or the smaller WebGL2 CPU fallback.", "backend": "core", - "tags": ["moving", "source", "spatial"], - "capabilities": ["audio"], - "level": "intermediate" + "tags": ["particles", "webgpu", "performance"], + "level": "advanced" }, { - "slug": "falloff-curves", - "path": "spatial-audio/falloff-curves.js", + "slug": "custom-wgsl-module", + "path": "particles/custom-wgsl-module.js", "language": "typescript", - "title": "Falloff Curves", - "description": "Compare linear, inverse, and exponential distance attenuation models on three sources around a draggable listener.", + "title": "Custom Wgsl Module", + "description": "Supply a custom WGSL compute module to the GPU particle system to implement non-standard particle behaviour.", "backend": "core", - "tags": ["falloff", "distance", "model", "spatial"], - "capabilities": ["pointer", "audio"], - "level": "intermediate" + "tags": ["particles", "advanced", "webgpu"], + "level": "advanced" } ], - "sprites-textures": [ + "audio": [ { - "slug": "sprite-basics", - "path": "sprites-textures/sprite-basics.js", + "slug": "play-sound", + "path": "audio-basics/play-sound.js", "language": "typescript", - "title": "Sprite Basics", - "description": "Load a texture, create a Sprite, and control position, rotation, scale, and alpha inside a Scene.", + "title": "Play Sound", + "description": "Load a Sound asset and play it on a pointer tap — covers the required user-gesture unlock and basic volume control.", "backend": "core", "featured": true, - "tags": ["sprite", "rendering"], + "tags": ["audio", "sound"], + "capabilities": ["pointer", "audio"], "level": "intro" }, { - "slug": "texture-loader", - "path": "sprites-textures/texture-loader.js", + "slug": "sound-pool", + "path": "audio-basics/sound-pool.js", "language": "typescript", - "title": "Texture Loader", - "description": "Load image assets at startup, reference them by key, and assign the resulting Texture to a Sprite.", + "title": "Polyphonic Sound", + "description": "Hold Space to play overlapping impacts with varied pitch while a pool meter shows active voices and evictions.", "backend": "core", - "tags": ["sprite", "rendering"], - "level": "intro" + "tags": ["audio", "sound", "pool"], + "capabilities": ["keyboard", "audio"], + "level": "intermediate" }, { - "slug": "asset-catalogs", - "path": "sprites-textures/asset-catalogs.js", + "slug": "music-loop", + "path": "audio-basics/music-loop.js", "language": "typescript", - "title": "Asset Catalogs", - "description": "Declare a typed Assets catalog, merge catalogs with compose, derive one with extend, and load them through two parallel queues with per-queue progress.", + "title": "Music Loop", + "description": "Play a seamlessly looping music track with live controls for volume, playback rate, and looping.", "backend": "core", - "tags": ["loader", "assets", "sprite"], - "level": "intermediate", - "capabilities": ["keyboard"] + "tags": ["audio", "music"], + "capabilities": ["audio"], + "level": "intro" }, { - "slug": "spritesheet-frames", - "path": "sprites-textures/spritesheet-frames.js", + "slug": "audio-buses", + "path": "audio-basics/audio-buses.js", "language": "typescript", - "title": "Spritesheet Frames", - "description": "Slice a spritesheet into named frames and step through them to play a simple frame animation.", + "title": "Audio Mixer Buses", + "description": "Route a looping track and click effect to Music and Sound, then mix both through Master with live sliders.", "backend": "core", - "tags": ["sprite", "animation"], + "tags": ["audio", "buses"], + "capabilities": ["pointer", "audio"], "level": "intermediate" }, { - "slug": "blendmodes", - "path": "sprites-textures/blendmodes.js", + "slug": "crossfade-tracks", + "path": "audio-basics/crossfade-tracks.js", "language": "typescript", - "title": "Blendmodes", - "description": "Click through all built-in blend modes applied to an overlapping sprite pair to see how they composite.", + "title": "Crossfade Tracks", + "description": "Select either looping track and watch its gain indicator follow the scheduled two-second voice crossfade.", "backend": "core", - "tags": ["sprite", "rendering", "fx"], - "capabilities": ["pointer"], + "tags": ["audio", "crossfade"], + "capabilities": ["pointer", "audio"], "level": "intermediate" }, { - "slug": "svg-drawable", - "path": "sprites-textures/svg-drawable.js", + "slug": "reverb-and-delay", + "path": "audio-fx/reverb-and-delay.js", "language": "typescript", - "title": "Svg Drawable", - "description": "Rasterise an SVG string into a GPU texture and use it as a Sprite — combine vector art with the sprite pipeline.", + "title": "Reverb and Delay", + "description": "Play one impact through reverb and delay; compare small room, hall, echo, and dry bypass presets.", "backend": "core", - "tags": ["sprite", "rendering"], - "level": "intermediate", - "capabilities": ["audio"] + "tags": ["audio", "fx", "reverb", "delay"], + "capabilities": ["pointer", "audio"], + "level": "intro" }, { - "slug": "video-drawable", - "path": "sprites-textures/video-drawable.js", + "slug": "compressor", + "path": "audio-fx/compressor.js", "language": "typescript", - "title": "Video Drawable", - "description": "Stream a video element as a live GPU texture and composite it with sprites and filters.", + "title": "Compression", + "description": "Switch one music source between quiet and loud, bypass the compressor, and read its live gain reduction.", "backend": "core", - "tags": ["sprite", "rendering"], - "capabilities": ["pointer", "keyboard", "audio"], - "level": "advanced" - } - ], - "text-fonts": [ + "tags": ["audio", "fx", "compressor"], + "capabilities": ["pointer", "audio"], + "level": "intermediate" + }, { - "slug": "basic-text", - "path": "text-fonts/basic-text.js", + "slug": "ducking", + "path": "audio-fx/ducking.js", "language": "typescript", - "title": "Basic Text", - "description": "Render canvas-rasterised text inside a Scene. Set font, size, colour, and alignment via TextStyle.", + "title": "Ducking", + "description": "Automatically lower music volume when a sound effect plays — classic game audio ducking via the DuckingFilter.", "backend": "core", - "tags": ["text"], - "level": "intro" + "tags": ["audio", "fx", "ducking"], + "capabilities": ["pointer", "audio"], + "level": "intermediate" }, { - "slug": "stroke-and-shadow", - "path": "text-fonts/stroke-and-shadow.js", + "slug": "convolution-rooms", + "path": "audio-fx/convolution-rooms.js", "language": "typescript", - "title": "Stroke and Shadow", - "description": "Add outline stroke and drop shadow to text using TextStyle properties — common game UI typography.", + "title": "Convolution Rooms", + "description": "Strike one of four materials, panned to where it sits, and cycle through 30 recorded room impulse responses with a right-click, from a few milliseconds of colouration to a half-second cavern.", "backend": "core", - "tags": ["text"], - "level": "intro" + "tags": ["audio", "fx", "convolution", "reverb", "impulse-response"], + "capabilities": ["pointer", "audio"], + "level": "intermediate" }, { - "slug": "multiline-and-wrap", - "path": "text-fonts/multiline-and-wrap.js", + "slug": "effect-chains", + "path": "audio-fx/effect-chains.js", "language": "typescript", - "title": "Multiline and Wrap", - "description": "Configure word-wrap width, line height, and horizontal alignment for multi-line Text drawables.", + "title": "Audio Effect Rack", + "description": "Select one of four distinct effect chains, inspect its order and spectrum, and bypass it against dry output.", "backend": "core", - "tags": ["text"], + "tags": ["audio", "fx", "chain", "spectrum", "analyser"], + "capabilities": ["pointer", "audio"], "level": "intermediate" }, { - "slug": "web-fonts", - "path": "text-fonts/web-fonts.js", + "slug": "vocoder", + "path": "audio-fx/vocoder.js", "language": "typescript", - "title": "Web Fonts", - "description": "Load a custom web font via FontFace API and render it in a Text drawable — use any Google Font or local .ttf.", + "title": "Vocoder", + "description": "Choose a spoken phrase and compare its dry voice with a vocoder carrier; unlock feedback keeps the first selection visible.", "backend": "core", - "tags": ["text"], - "level": "intermediate" + "tags": ["audio", "fx", "vocoder"], + "capabilities": ["pointer", "audio"], + "level": "advanced" }, { - "slug": "bitmap-text-basic", - "path": "text-fonts/bitmap-text-basic.js", + "slug": "audio-visualisation", + "path": "showcase/audio-visualisation.js", "language": "typescript", - "title": "Bitmap Text Basic", - "description": "BitmapText rendered from an AngelCode .fnt atlas (Kenney Blocks, CC0). Demonstrates pre-built atlas rendering, word wrap, and live text updates without Canvas 2D rasterisation.", + "title": "Spectrum and Waveform", + "description": "Compare live spectrum bars, a waveform, eight frequency bands, and a beat pulse while playing music.", "backend": "core", - "tags": ["bitmap", "text", "bmfont"], + "tags": ["audio", "reactive", "visualiser"], + "capabilities": ["pointer", "audio"], "level": "intermediate" }, { - "slug": "typographic-styling", - "path": "text-fonts/typographic-styling.js", + "slug": "beat-sync-pulse", + "path": "beat-detection/beat-sync-pulse.js", "language": "typescript", - "title": "Typographic Styling", - "description": "Multi-stop gradients with an angle, small caps and oblique, underline and strikethrough, textTransform, line clamping with an ellipsis, and tab stops - the TextStyle sweep in one scene.", + "title": "Beat and Tempo", + "description": "Pulse a sprite and particles on detected beats while showing the live BPM estimate, confidence, and onset strength.", "backend": "core", - "tags": ["text", "typography"], - "level": "intermediate" + "tags": ["audio", "beat", "reactive"], + "capabilities": ["audio"], + "level": "intro" }, { - "slug": "text-glitch", - "path": "text-fonts/text-glitch.js", + "slug": "listener-and-source", + "path": "spatial-audio/listener-and-source.js", "language": "typescript", - "title": "Text Glitch", - "description": "Animate randomised character substitution on a Text drawable to produce a classic digital glitch effect.", + "title": "Spatial Audio", + "description": "Drag or orbit a sound source around a listener, switch distance models, and compare the live pan and attenuation.", "backend": "core", - "tags": ["text", "fx", "animation"], - "level": "advanced" + "tags": ["audio", "spatial", "panning", "falloff"], + "capabilities": ["pointer", "audio"], + "level": "intro" } ], - "tilemap": [ + "worlds-navigation": [ { "slug": "tile-chunks-and-bands", "path": "tilemap/tile-chunks-and-bands.js", @@ -1512,8 +941,8 @@ "slug": "tiled-map-import", "path": "tilemap/tiled-map-import.js", "language": "typescript", - "title": "Tiled Map Import", - "description": "Load a Tiled .tmj map through the advanced parsed-source path (TiledMap.toTileMap()) and query its object layer by type, property, and geometry kind with ObjectLayer.query().", + "title": "Load a Tiled Map", + "description": "Load and render a .tmj room, then click highlighted objects to inspect their editor-authored names, types, and properties.", "backend": "core", "tags": ["tilemap", "tiled", "objects"], "level": "intermediate" @@ -1529,26 +958,49 @@ "level": "advanced" }, { - "slug": "infinite-terrain", - "path": "tilemap/infinite-terrain.js", + "slug": "editor-objects-gameplay", + "path": "tilemap/editor-objects-gameplay.js", "language": "typescript", - "title": "Infinite Procedural Terrain", - "description": "Stream an unbounded TileMap from a deterministic value-noise sampler: a ChunkStreamer loads chunks around the free-flying camera via createSampledChunkSource and evicts what you leave behind — same seed, same world.", + "title": "Editor Objects Become Gameplay", + "description": "Click an authored switch to open its door, then collect a gem created from the same Tiled object layer by MapObjectSpawner.", "backend": "core", - "tags": ["tilemap", "procedural", "streaming"], - "level": "intermediate", - "capabilities": ["keyboard"] + "tags": ["tilemap", "tiled", "objects", "spawner"], + "capabilities": ["pointer"], + "level": "intermediate" }, { - "slug": "worker-streamed-terrain", - "path": "tilemap/worker-streamed-terrain.js", + "slug": "grid-navigation", + "path": "pathfinding/grid-navigation.js", "language": "typescript", - "title": "Worker-Streamed Terrain", - "description": "Move expensive terrain sampling off the main thread with createWorkerSampledChunkSource: the noise lives inside a self-contained workerSource script. Toggle sync vs. worker and raise the sampling cost to feel the main-thread jank disappear.", + "title": "Grid Navigation", + "description": "Click to move an agent across a weighted grid: paint walls and mud, toggle jump-point pruning, path smoothing and a 2x2 agent, and watch the expanded-node counter react.", "backend": "core", - "tags": ["tilemap", "procedural", "worker"], - "level": "advanced", - "capabilities": ["keyboard", "webWorkers"] + "featured": true, + "tags": ["pathfinding", "grid", "pointer"], + "capabilities": ["pointer"], + "level": "intermediate" + }, + { + "slug": "tilemap-navigation", + "path": "pathfinding/tilemap-navigation.js", + "language": "typescript", + "title": "Editable Map Navigation", + "description": "Click to route an agent; switch to wall editing and click a tile to open or close a path while the route updates.", + "backend": "core", + "tags": ["pathfinding", "tilemap", "pointer"], + "capabilities": ["pointer"], + "level": "intermediate" + }, + { + "slug": "paint-autotiled-room", + "path": "tilemap/paint-autotiled-room.js", + "language": "typescript", + "title": "Paint an Autotiled Room", + "description": "Drag to paint or erase grass while Wang neighbor rules choose edge and corner tiles around each edit.", + "backend": "core", + "tags": ["tilemap", "wang", "autotile", "pointer"], + "capabilities": ["pointer"], + "level": "advanced" }, { "slug": "tiled-infinite-map", @@ -1560,91 +1012,118 @@ "tags": ["tilemap", "tiled", "streaming"], "level": "advanced", "capabilities": ["keyboard"] + }, + { + "slug": "worker-streamed-terrain", + "path": "tilemap/worker-streamed-terrain.js", + "language": "typescript", + "title": "Streamed Terrain", + "description": "Explore a deterministic unbounded world, reroll its seed, and compare synchronous and worker-backed terrain sampling at adjustable cost.", + "backend": "core", + "tags": ["tilemap", "procedural", "worker"], + "level": "advanced", + "capabilities": ["keyboard", "webWorkers"] } ], - "tweens-animation": [ + "physics-collision": [ { - "slug": "tween-basics", - "path": "tweens-animation/tween-basics.js", + "slug": "rectangles-collision", + "path": "showcase/rectangles-collision.js", "language": "typescript", - "title": "Tween Basics", - "description": "Animate numeric properties with Tween: set from/to, duration, easing, and chain an onComplete callback.", + "title": "Rectangles Collision", + "description": "Drag rectangles around the canvas and detect AABB overlap in the update loop — foundational collision recipe.", "backend": "core", - "tags": ["animation", "tween"], + "tags": ["scene", "rendering"], "level": "intro" }, { - "slug": "tween-with-yoyo", - "path": "tweens-animation/tween-with-yoyo.js", + "slug": "sprite-follows-body", + "path": "physics/sprite-follows-body.js", "language": "typescript", - "title": "Tween with Yoyo", - "description": "Ping-pong a tween back and forth with the yoyo option — ideal for pulsing, breathing, or idle animations.", + "title": "Drag and Throw Physics", + "description": "Drag a body with MouseJoint and release it onto the floor; world.attach keeps its sprite aligned with the simulation.", "backend": "core", - "tags": ["animation", "tween"], + "tags": ["physics", "binding", "pointer", "joints"], + "capabilities": ["pointer", "keyboard"], "level": "intro" }, { - "slug": "tween-chains", - "path": "tweens-animation/tween-chains.js", + "slug": "tiled-map-physics-actor", + "path": "physics/tiled-map-physics-actor.js", "language": "typescript", - "title": "Tween Chains", - "description": "Build a sequence of tweens that fire one after another using tween.then() chaining.", + "title": "Tiled Collision Map", + "description": "Load an authored .tmj room, build static bodies from its Collision object layer, and toggle outlines while a dynamic actor lands on them.", "backend": "core", - "tags": ["animation", "tween"], + "tags": ["physics", "tilemap", "collision"], "level": "intermediate" }, { - "slug": "interrupt-and-replace", - "path": "tweens-animation/interrupt-and-replace.js", + "slug": "joint-driven-shadows", + "path": "physics/joint-driven-shadows.js", "language": "typescript", - "title": "Interrupt and Replace", - "description": "Click to interrupt a running tween and start a new one from the current position — no jitter or snap.", + "title": "Joint-Driven Shadows", + "description": "Drag a hinged physics arm through a live light and watch its collider reshape the shadow it casts.", "backend": "core", - "tags": ["animation", "tween"], + "tags": ["physics", "joints", "lighting", "shadows"], "capabilities": ["pointer"], + "level": "advanced", + "featured": true + } + ], + "performance-diagnostics": [ + { + "slug": "pointer-and-hittest", + "path": "debug-layer/pointer-and-hittest.js", + "language": "typescript", + "title": "Interaction Inspector", + "description": "Drag overlapping sprites, change their z-order, and inspect their bounds and hit-test targets.", + "backend": "core", + "tags": ["debug", "pointer", "input", "scene-graph"], + "capabilities": ["pointer", "keyboard"], "level": "intermediate" }, { - "slug": "tween-from-array", - "path": "tweens-animation/tween-from-array.js", + "slug": "backend-comparison", + "path": "performance/backend-comparison.js", "language": "typescript", - "title": "Tween From Array", - "description": "Drive a tween along a keyframe path defined as an array of values — useful for motion paths and patrol routes.", + "title": "Sprite Batching Laboratory", + "description": "Change visible sprite count and texture diversity within one seeded workload, inspect the performance overlay, and switch between WebGL2 and WebGPU when available.", "backend": "core", - "tags": ["animation", "tween"], + "featured": true, + "tags": ["performance", "batching", "rendering", "webgpu"], + "capabilities": ["keyboard"], "level": "intermediate" }, { - "slug": "easing-curves", - "path": "tweens-animation/easing-curves.js", + "slug": "retained-container", + "path": "scene-graph/retained-container.js", "language": "typescript", - "title": "Easing Curves", - "description": "Visualise all built-in easing functions side-by-side and pick the curve that fits your animation.", + "title": "Retained Container", + "description": "Hold a large static sprite field in a RetainedContainer and pan the whole group as a camera: the retained tier replays the subtree as O(batches) with one group-matrix update, while a plain Container walks every child each frame. Toggle the tier and compare the frame-time readout.", "backend": "core", - "tags": ["animation", "tween"], - "level": "intermediate" + "tags": ["scene-graph", "rendering", "performance"], + "level": "advanced" }, { - "slug": "frame-animation", - "path": "tweens-animation/frame-animation.js", + "slug": "immediate-mode-rendering", + "path": "geometry-graphics/immediate-mode-rendering.js", "language": "typescript", - "title": "Frame Animation", - "description": "Step through spritesheet frames on a timer to play a looping animation with configurable FPS.", + "title": "Immediate-Mode Rendering", + "description": "Draw procedural geometry without a scene graph: gears via drawGeometry and a 2,400-spark field as a single instanced RenderBatch draw call, with a live drawCalls readout.", "backend": "core", - "tags": ["animation", "sprite"], - "level": "intermediate" - } - ], - "ui": [ + "tags": ["rendering", "geometry", "instancing", "performance"], + "level": "advanced" + }, { - "slug": "hud-and-widgets", - "path": "ui/hud-and-widgets.js", + "slug": "coroutine-loading", + "path": "performance/coroutine-loading.js", "language": "typescript", - "title": "HUD and Widgets", - "description": "Build a screen-fixed HUD on scene.ui: anchored score and health widgets, plus a panel of clickable, keyboard-focusable buttons that update them.", + "title": "Coroutine Loading", + "description": "Spread world building over frames with a coroutine: a progress-driven loading bar, a share-based frame budget, and cancellation.", "backend": "core", - "tags": ["ui", "hud", "button", "widget"], - "level": "intro" + "tags": ["performance", "coroutines"], + "capabilities": ["keyboard"], + "level": "intermediate" } ] } diff --git a/examples/filters/blur-filter.js b/examples/filters/blur-filter.js index ec2388514..c11f7de43 100644 --- a/examples/filters/blur-filter.js +++ b/examples/filters/blur-filter.js @@ -1,39 +1,51 @@ // Auto-generated from blur-filter.ts - edit the .ts source, not this file. -import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { Application, BlurFilter, Color, DropShadowFilter, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; -// High-detail, high-contrast content so the blur visibly softens hard edges. -const PIXEL_GRID = assets.technical.filtering.pixelGrid128; -const MAX_STRENGTH = 8; -class BlurFilterScene extends Scene { - blur; - sprite; +const SHIP = assets.demo.textures.shipA; +class BlurAndShadowScene extends Scene { + reference; + filtered; + blur = new BlurFilter({ strength: 3 }); + shadow = new DropShadowFilter({ offsetX: 16, offsetY: 20, blur: 8, quality: 2, color: new Color(0, 0, 0, 0.8) }); + mode = 'blur'; enabled = true; hud; - panel; - slider; + labels; init() { - const app = this.app; - const { width, height } = app; - this.blur = new BlurFilter({ strength: 2 }); - this.sprite = new Sprite(this.loader.get(PIXEL_GRID)) + const { width, height } = this.app; + const texture = this.loader.get(SHIP); + this.reference = new Sprite(texture) .setAnchor(0.5) - .setScale(4.5) - .setPosition(width / 2, height / 2); - this.sprite.filters = [this.blur]; - this.hud = mountControls({ - title: 'Blur Filter', - controls: [ - { keys: 'Strength', action: 'soften the sprite (Gaussian standard deviation)' }, - { keys: 'Filter', action: 'toggle to compare before / after' }, - ], - status: this.statusText(), - hint: 'Drag the Strength slider — the live value is shown to its right.', + .setScale(8) + .setPosition(width * 0.3, height * 0.53); + this.filtered = new Sprite(texture) + .setAnchor(0.5) + .setScale(8) + .setPosition(width * 0.7, height * 0.53); + this.labels = [ + new Text('Original', { fillColor: Color.white, fontSize: 25 }).setAnchor(0.5).setPosition(width * 0.3, height * 0.78), + new Text('Blur', { fillColor: Color.white, fontSize: 25 }).setAnchor(0.5).setPosition(width * 0.7, height * 0.78), + ]; + this.hud = mountControls({ title: 'Blur and Drop Shadow', hint: 'Compare the original sprite with a blurred sprite or an offset shadow.' }); + const panel = mountControlPanel({ title: 'Filter' }); + panel.addButton({ + label: 'Blur', + onClick: () => { + this.mode = 'blur'; + this.refresh(); + }, + }); + panel.addButton({ + label: 'Drop shadow', + onClick: () => { + this.mode = 'shadow'; + this.refresh(); + }, }); - this.panel = mountControlPanel({ title: 'Blur' }); - this.slider = this.panel.addSlider({ - label: 'Strength', + panel.addSlider({ + label: 'Blur strength', min: 0, - max: MAX_STRENGTH, + max: 8, step: 0.1, value: this.blur.strength, onChange: value => { @@ -41,37 +53,46 @@ class BlurFilterScene extends Scene { this.refresh(); }, }); - this.panel.addToggle({ + panel.addSlider({ + label: 'Shadow offset', + min: -40, + max: 40, + step: 1, + value: this.shadow.offsetX, + onChange: value => { + this.shadow.offsetX = value; + this.shadow.offsetY = value; + this.refresh(); + }, + }); + panel.addToggle({ label: 'Filter', value: true, - onChange: on => { - this.enabled = on; - this.sprite.filters = on ? [this.blur] : []; + onChange: value => { + this.enabled = value; this.refresh(); }, }); - } - statusText() { - if (!this.enabled) { - return 'Filter: OFF (original sprite)'; - } - return `Strength: ${this.blur.strength.toFixed(1)} px`; + this.refresh(); } refresh() { - this.hud.setStatus(this.statusText()); + this.filtered.filters = this.enabled ? [this.mode === 'blur' ? this.blur : this.shadow] : []; + this.labels[1].text = this.enabled ? (this.mode === 'blur' ? 'Blurred' : 'Drop shadow') : 'Bypassed'; + this.hud.setStatus( + this.mode === 'blur' + ? `Blur strength: ${this.blur.strength.toFixed(1)} px${this.enabled ? '' : ' (bypassed)'}` + : `Shadow offset: ${this.shadow.offsetX} px${this.enabled ? '' : ' (bypassed)'}`, + ); } draw(context) { - context.render(this.sprite); + context.render(this.reference); + context.render(this.filtered); + for (const label of this.labels) context.render(label); } } const app = new Application({ - scenes: { BlurFilterScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, + scenes: { BlurAndShadowScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(38, 44, 60), }); -await app.start(BlurFilterScene); +await app.start(BlurAndShadowScene); diff --git a/examples/filters/blur-filter.ts b/examples/filters/blur-filter.ts index b946cf1f4..a5119be8c 100644 --- a/examples/filters/blur-filter.ts +++ b/examples/filters/blur-filter.ts @@ -1,45 +1,54 @@ -import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { Application, BlurFilter, Color, DropShadowFilter, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; -// High-detail, high-contrast content so the blur visibly softens hard edges. -const PIXEL_GRID = assets.technical.filtering.pixelGrid128; +const SHIP = assets.demo.textures.shipA; -const MAX_STRENGTH = 8; - -class BlurFilterScene extends Scene { - private blur!: BlurFilter; - private sprite!: Sprite; +class BlurAndShadowScene extends Scene { + private reference!: Sprite; + private filtered!: Sprite; + private blur = new BlurFilter({ strength: 3 }); + private shadow = new DropShadowFilter({ offsetX: 16, offsetY: 20, blur: 8, quality: 2, color: new Color(0, 0, 0, 0.8) }); + private mode: 'blur' | 'shadow' = 'blur'; private enabled = true; private hud!: ReturnType; - private panel!: ReturnType; - private slider!: ReturnType['addSlider']>; + private labels!: Text[]; override init(): void { - const app = this.app; - const { width, height } = app; - - this.blur = new BlurFilter({ strength: 2 }); - this.sprite = new Sprite(this.loader.get(PIXEL_GRID)) + const { width, height } = this.app; + const texture = this.loader.get(SHIP); + this.reference = new Sprite(texture) + .setAnchor(0.5) + .setScale(8) + .setPosition(width * 0.3, height * 0.53); + this.filtered = new Sprite(texture) .setAnchor(0.5) - .setScale(4.5) - .setPosition(width / 2, height / 2); - this.sprite.filters = [this.blur]; + .setScale(8) + .setPosition(width * 0.7, height * 0.53); + this.labels = [ + new Text('Original', { fillColor: Color.white, fontSize: 25 }).setAnchor(0.5).setPosition(width * 0.3, height * 0.78), + new Text('Blur', { fillColor: Color.white, fontSize: 25 }).setAnchor(0.5).setPosition(width * 0.7, height * 0.78), + ]; - this.hud = mountControls({ - title: 'Blur Filter', - controls: [ - { keys: 'Strength', action: 'soften the sprite (Gaussian standard deviation)' }, - { keys: 'Filter', action: 'toggle to compare before / after' }, - ], - status: this.statusText(), - hint: 'Drag the Strength slider — the live value is shown to its right.', + this.hud = mountControls({ title: 'Blur and Drop Shadow', hint: 'Compare the original sprite with a blurred sprite or an offset shadow.' }); + const panel = mountControlPanel({ title: 'Filter' }); + panel.addButton({ + label: 'Blur', + onClick: () => { + this.mode = 'blur'; + this.refresh(); + }, }); - - this.panel = mountControlPanel({ title: 'Blur' }); - this.slider = this.panel.addSlider({ - label: 'Strength', + panel.addButton({ + label: 'Drop shadow', + onClick: () => { + this.mode = 'shadow'; + this.refresh(); + }, + }); + panel.addSlider({ + label: 'Blur strength', min: 0, - max: MAX_STRENGTH, + max: 8, step: 0.1, value: this.blur.strength, onChange: value => { @@ -47,43 +56,50 @@ class BlurFilterScene extends Scene { this.refresh(); }, }); - this.panel.addToggle({ + panel.addSlider({ + label: 'Shadow offset', + min: -40, + max: 40, + step: 1, + value: this.shadow.offsetX, + onChange: value => { + this.shadow.offsetX = value; + this.shadow.offsetY = value; + this.refresh(); + }, + }); + panel.addToggle({ label: 'Filter', value: true, - onChange: on => { - this.enabled = on; - this.sprite.filters = on ? [this.blur] : []; + onChange: value => { + this.enabled = value; this.refresh(); }, }); - } - - private statusText(): string { - if (!this.enabled) { - return 'Filter: OFF (original sprite)'; - } - - return `Strength: ${this.blur.strength.toFixed(1)} px`; + this.refresh(); } private refresh(): void { - this.hud.setStatus(this.statusText()); + this.filtered.filters = this.enabled ? [this.mode === 'blur' ? this.blur : this.shadow] : []; + this.labels[1].text = this.enabled ? (this.mode === 'blur' ? 'Blurred' : 'Drop shadow') : 'Bypassed'; + this.hud.setStatus( + this.mode === 'blur' + ? `Blur strength: ${this.blur.strength.toFixed(1)} px${this.enabled ? '' : ' (bypassed)'}` + : `Shadow offset: ${this.shadow.offsetX} px${this.enabled ? '' : ' (bypassed)'}`, + ); } override draw(context: RenderingContext): void { - context.render(this.sprite); + context.render(this.reference); + context.render(this.filtered); + for (const label of this.labels) context.render(label); } } const app = new Application({ - scenes: { BlurFilterScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, + scenes: { BlurAndShadowScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(38, 44, 60), }); -await app.start(BlurFilterScene); +await app.start(BlurAndShadowScene); diff --git a/examples/filters/color-matrix-filter.js b/examples/filters/color-matrix-filter.js index a4fd93870..fd2cb4cf9 100644 --- a/examples/filters/color-matrix-filter.js +++ b/examples/filters/color-matrix-filter.js @@ -1,64 +1,121 @@ // Auto-generated from color-matrix-filter.ts - edit the .ts source, not this file. -import { Application, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; -// A full-hue ramp shows every preset on every colour at once. -const HUE_RAMP = assets.technical.color.hueRamp; -// One filter class, one matrix - each preset is a different concatenation onto -// it, not a different filter. `reset()` goes back to the identity first so the -// presets do not stack on each other. -const PRESETS = [ - { label: 'Tint', apply: filter => filter.tint(new Color(255, 160, 120)) }, - { label: 'Desaturate', apply: filter => filter.grayscale() }, - { label: 'Invert', apply: filter => filter.invert() }, - { label: 'Brightness', apply: filter => filter.brightness(1.6) }, - { label: 'Contrast', apply: filter => filter.contrast(1.8) }, - { label: 'Sepia', apply: filter => filter.sepia() }, -]; -class ColorMatrixFilterScene extends Scene { - sprite; - filter; - index = 1; // start on Desaturate - the most visually obvious preset +import { Application, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, LutFilter, Scene, Sprite, Text } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +const RAMP_SIZE = 256; +const LUT_SIZE = 17; +const MODES = ['Matrix: grayscale', 'Matrix: sepia', '1D ramp: channel curves', '3D LUT: cool grade', 'Bypass']; +const makeRamp = () => { + const canvas = document.createElement('canvas'); + canvas.width = RAMP_SIZE; + canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + const image = context.createImageData(RAMP_SIZE, 1); + for (let i = 0; i < RAMP_SIZE; i++) { + const phase = (i / RAMP_SIZE) * Math.PI * 2; + const offset = i * 4; + image.data[offset] = Math.round(127 + 127 * Math.sin(phase)); + image.data[offset + 1] = Math.round(127 + 127 * Math.sin(phase + (Math.PI * 2) / 3)); + image.data[offset + 2] = Math.round(127 + 127 * Math.sin(phase + (Math.PI * 4) / 3)); + image.data[offset + 3] = 255; + } + context.putImageData(image, 0, 0); + return canvas; +}; +const makeCoolLut = () => { + const width = LUT_SIZE * LUT_SIZE; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = LUT_SIZE; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + const image = context.createImageData(width, LUT_SIZE); + const max = LUT_SIZE - 1; + for (let b = 0; b < LUT_SIZE; b++) { + for (let g = 0; g < LUT_SIZE; g++) { + for (let r = 0; r < LUT_SIZE; r++) { + const offset = (g * width + b * LUT_SIZE + r) * 4; + image.data[offset] = Math.round((r / max) * 0.85 * 255); + image.data[offset + 1] = Math.round((g / max) * 0.95 * 255); + image.data[offset + 2] = Math.round(Math.min(1, (b / max) * 1.15 + 0.05) * 255); + image.data[offset + 3] = 255; + } + } + } + context.putImageData(image, 0, 0); + return canvas; +}; +class ColorTransformsScene extends Scene { + reference; + processed; + referenceLabel; + processedLabel; + matrix; + ramp; + rampFilter; + cube; + cubeFilter; hud; init() { - const app = this.app; - const { width, height } = app; - this.sprite = new Sprite(this.loader.get(HUE_RAMP)) + const { width, height } = this.app; + const image = this.loader.get(assets.technical.color.primaryRamp); + this.reference = new Sprite(image) + .setAnchor(0.5) + .setScale(3) + .setPosition(width * 0.28, height / 2); + this.processed = new Sprite(image) .setAnchor(0.5) - .setScale(4) - .setPosition(width / 2, height / 2); - this.filter = new ColorMatrixFilter(); - this.sprite.filters = [this.filter]; - this.applyPreset(); + .setScale(3) + .setPosition(width * 0.72, height / 2); + this.referenceLabel = new Text('ORIGINAL', { fontSize: 22, fillColor: Color.white }).setAnchor(0.5).setPosition(width * 0.28, height * 0.72); + this.processedLabel = new Text('TRANSFORMED', { fontSize: 22, fillColor: Color.white }).setAnchor(0.5).setPosition(width * 0.72, height * 0.72); + this.matrix = new ColorMatrixFilter(); + this.ramp = LutFilter.fromImage(makeRamp()); + this.rampFilter = new LutFilter({ mode: 'rgb1d' }).setLut(this.ramp); + this.cube = LutFilter.fromImage(makeCoolLut()); + this.cubeFilter = new LutFilter({ mode: '3d', size: LUT_SIZE }).setLut(this.cube); this.hud = mountControls({ - title: 'Color Matrix Filter', - controls: [{ keys: 'Click', action: 'next preset: tint · desaturate · invert · brightness · contrast · sepia' }], - status: this.statusText(), - hint: 'Every preset is one affine colour matrix; switching rewrites the matrix in place.', + title: 'Color Transforms', + status: 'Matrix: grayscale', + hint: 'The left image is unchanged. Compare a channel-mixing matrix, independent per-channel curves, and a 3D color lookup on the same right-hand image.', }); - app.input.onPointerTap.add(() => { - this.index = (this.index + 1) % PRESETS.length; - this.applyPreset(); + mountControlPanel({ title: 'Transform' }).addCycle({ + label: 'Method', + options: [...MODES], + index: 0, + onChange: index => this.setMode(index), }); + this.setMode(0); } - applyPreset() { - PRESETS[this.index].apply(this.filter.reset()); - this.hud?.setStatus(this.statusText()); - } - statusText() { - return `Preset: ${PRESETS[this.index].label} (${this.index + 1}/${PRESETS.length})`; + setMode(index) { + if (index === 0) this.processed.filters = [this.matrix.reset().grayscale()]; + else if (index === 1) this.processed.filters = [this.matrix.reset().sepia()]; + else if (index === 2) this.processed.filters = [this.rampFilter]; + else if (index === 3) this.processed.filters = [this.cubeFilter]; + else this.processed.filters = []; + this.hud.setStatus(MODES[index] ?? MODES[4]); } draw(context) { - context.render(this.sprite); + context.render(this.reference); + context.render(this.processed); + context.render(this.referenceLabel); + context.render(this.processedLabel); + } + destroy() { + this.hud.dispose(); + this.matrix.destroy(); + this.rampFilter.destroy(); + this.cubeFilter.destroy(); + this.ramp.destroy(); + this.cube.destroy(); + this.referenceLabel.destroy(); + this.processedLabel.destroy(); + super.destroy(); } } const app = new Application({ - scenes: { ColorMatrixFilterScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { ColorTransformsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); -await app.start(ColorMatrixFilterScene); +await app.start(ColorTransformsScene); diff --git a/examples/filters/color-matrix-filter.ts b/examples/filters/color-matrix-filter.ts index f2f6139f4..fc615a318 100644 --- a/examples/filters/color-matrix-filter.ts +++ b/examples/filters/color-matrix-filter.ts @@ -1,76 +1,142 @@ -import { Application, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { + Application, + Color, + ColorMatrixFilter, + FixedResolutionCanvasSizing, + LutFilter, + type RenderingContext, + Scene, + Sprite, + Text, + Texture, +} from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; -// A full-hue ramp shows every preset on every colour at once. -const HUE_RAMP = assets.technical.color.hueRamp; +const RAMP_SIZE = 256; +const LUT_SIZE = 17; +const MODES = ['Matrix: grayscale', 'Matrix: sepia', '1D ramp: channel curves', '3D LUT: cool grade', 'Bypass'] as const; -// One filter class, one matrix - each preset is a different concatenation onto -// it, not a different filter. `reset()` goes back to the identity first so the -// presets do not stack on each other. -const PRESETS = [ - { label: 'Tint', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.tint(new Color(255, 160, 120)) }, - { label: 'Desaturate', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.grayscale() }, - { label: 'Invert', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.invert() }, - { label: 'Brightness', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.brightness(1.6) }, - { label: 'Contrast', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.contrast(1.8) }, - { label: 'Sepia', apply: (filter: ColorMatrixFilter): ColorMatrixFilter => filter.sepia() }, -] as const; +const makeRamp = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + canvas.width = RAMP_SIZE; + canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + const image = context.createImageData(RAMP_SIZE, 1); + for (let i = 0; i < RAMP_SIZE; i++) { + const phase = (i / RAMP_SIZE) * Math.PI * 2; + const offset = i * 4; + image.data[offset] = Math.round(127 + 127 * Math.sin(phase)); + image.data[offset + 1] = Math.round(127 + 127 * Math.sin(phase + (Math.PI * 2) / 3)); + image.data[offset + 2] = Math.round(127 + 127 * Math.sin(phase + (Math.PI * 4) / 3)); + image.data[offset + 3] = 255; + } + context.putImageData(image, 0, 0); + return canvas; +}; + +const makeCoolLut = (): HTMLCanvasElement => { + const width = LUT_SIZE * LUT_SIZE; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = LUT_SIZE; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + const image = context.createImageData(width, LUT_SIZE); + const max = LUT_SIZE - 1; + for (let b = 0; b < LUT_SIZE; b++) { + for (let g = 0; g < LUT_SIZE; g++) { + for (let r = 0; r < LUT_SIZE; r++) { + const offset = (g * width + b * LUT_SIZE + r) * 4; + image.data[offset] = Math.round((r / max) * 0.85 * 255); + image.data[offset + 1] = Math.round((g / max) * 0.95 * 255); + image.data[offset + 2] = Math.round(Math.min(1, (b / max) * 1.15 + 0.05) * 255); + image.data[offset + 3] = 255; + } + } + } + context.putImageData(image, 0, 0); + return canvas; +}; -class ColorMatrixFilterScene extends Scene { - private sprite!: Sprite; - private filter!: ColorMatrixFilter; - private index = 1; // start on Desaturate - the most visually obvious preset +class ColorTransformsScene extends Scene { + private reference!: Sprite; + private processed!: Sprite; + private referenceLabel!: Text; + private processedLabel!: Text; + private matrix!: ColorMatrixFilter; + private ramp!: Texture; + private rampFilter!: LutFilter; + private cube!: Texture; + private cubeFilter!: LutFilter; private hud!: ReturnType; override init(): void { - const app = this.app; - const { width, height } = app; - - this.sprite = new Sprite(this.loader.get(HUE_RAMP)) + const { width, height } = this.app; + const image = this.loader.get(assets.technical.color.primaryRamp); + this.reference = new Sprite(image) .setAnchor(0.5) - .setScale(4) - .setPosition(width / 2, height / 2); - this.filter = new ColorMatrixFilter(); - this.sprite.filters = [this.filter]; - - this.applyPreset(); + .setScale(3) + .setPosition(width * 0.28, height / 2); + this.processed = new Sprite(image) + .setAnchor(0.5) + .setScale(3) + .setPosition(width * 0.72, height / 2); + this.referenceLabel = new Text('ORIGINAL', { fontSize: 22, fillColor: Color.white }).setAnchor(0.5).setPosition(width * 0.28, height * 0.72); + this.processedLabel = new Text('TRANSFORMED', { fontSize: 22, fillColor: Color.white }).setAnchor(0.5).setPosition(width * 0.72, height * 0.72); + this.matrix = new ColorMatrixFilter(); + this.ramp = LutFilter.fromImage(makeRamp()); + this.rampFilter = new LutFilter({ mode: 'rgb1d' }).setLut(this.ramp); + this.cube = LutFilter.fromImage(makeCoolLut()); + this.cubeFilter = new LutFilter({ mode: '3d', size: LUT_SIZE }).setLut(this.cube); this.hud = mountControls({ - title: 'Color Matrix Filter', - controls: [{ keys: 'Click', action: 'next preset: tint · desaturate · invert · brightness · contrast · sepia' }], - status: this.statusText(), - hint: 'Every preset is one affine colour matrix; switching rewrites the matrix in place.', + title: 'Color Transforms', + status: 'Matrix: grayscale', + hint: 'The left image is unchanged. Compare a channel-mixing matrix, independent per-channel curves, and a 3D color lookup on the same right-hand image.', }); - - app.input.onPointerTap.add(() => { - this.index = (this.index + 1) % PRESETS.length; - this.applyPreset(); + mountControlPanel({ title: 'Transform' }).addCycle({ + label: 'Method', + options: [...MODES], + index: 0, + onChange: index => this.setMode(index), }); + this.setMode(0); } - private applyPreset(): void { - PRESETS[this.index]!.apply(this.filter.reset()); - this.hud?.setStatus(this.statusText()); + private setMode(index: number): void { + if (index === 0) this.processed.filters = [this.matrix.reset().grayscale()]; + else if (index === 1) this.processed.filters = [this.matrix.reset().sepia()]; + else if (index === 2) this.processed.filters = [this.rampFilter]; + else if (index === 3) this.processed.filters = [this.cubeFilter]; + else this.processed.filters = []; + this.hud.setStatus(MODES[index] ?? MODES[4]); } - private statusText(): string { - return `Preset: ${PRESETS[this.index]!.label} (${this.index + 1}/${PRESETS.length})`; + override draw(context: RenderingContext): void { + context.render(this.reference); + context.render(this.processed); + context.render(this.referenceLabel); + context.render(this.processedLabel); } - override draw(context: RenderingContext): void { - context.render(this.sprite); + override destroy(): void { + this.hud.dispose(); + this.matrix.destroy(); + this.rampFilter.destroy(); + this.cubeFilter.destroy(); + this.ramp.destroy(); + this.cube.destroy(); + this.referenceLabel.destroy(); + this.processedLabel.destroy(); + super.destroy(); } } const app = new Application({ - scenes: { ColorMatrixFilterScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { ColorTransformsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); -await app.start(ColorMatrixFilterScene); +await app.start(ColorTransformsScene); diff --git a/examples/filters/crt-scanlines.js b/examples/filters/crt-scanlines.js index 603dea6fc..f17ea01ef 100644 --- a/examples/filters/crt-scanlines.js +++ b/examples/filters/crt-scanlines.js @@ -1,60 +1,128 @@ // Auto-generated from crt-scanlines.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { Application, Color, Container, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; -// A regular pixel grid makes the scanline darkening and barrel warp obvious. -const PIXEL_GRID = assets.technical.filtering.pixelGrid128; const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv*2.0-1.0; uv*=1.0+dot(uv,uv)*0.07; uv=uv*0.5+0.5; vec4 c=texture(uTexture,uv); float scan=0.88+0.12*sin(vUv.y*900.0); float vig=1.0-smoothstep(0.45,0.95,length(vUv-0.5)); fragColor=vec4(c.rgb*scan*vig,c.a);} `; -const wgsl = `@group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ var uv=vUv*2.0-vec2(1.0); uv=uv*(1.0+dot(uv,uv)*0.07); uv=uv*0.5+vec2(0.5); let c=textureSample(uTexture,uSampler,uv); let scan=0.88+0.12*sin(vUv.y*900.0); let vig=1.0-smoothstep(0.45,0.95,length(vUv-vec2(0.5))); return vec4(c.rgb*scan*vig,c.a);} `; -class CrtScanlinesScene extends Scene { - sprite; +precision highp float; +uniform sampler2D uTexture; +uniform float uTime; +uniform float uAmount; +uniform float uSplit; +in vec2 vUv; +out vec4 fragColor; + +float hash(vec2 point) { + return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() { + vec2 centered = vUv * 2.0 - 1.0; + vec2 uv = centered * (1.0 + dot(centered, centered) * 0.07 * uAmount) * 0.5 + 0.5; + vec4 base = texture(uTexture, uv); + float red = texture(uTexture, uv + vec2(uSplit, 0.0)).r; + float blue = texture(uTexture, uv - vec2(uSplit, 0.0)).b; + float scan = 1.0 - 0.14 * uAmount * (0.5 + 0.5 * sin(vUv.y * 900.0)); + float grain = (hash(vUv * vec2(1280.0, 720.0) + uTime * 45.0) - 0.5) * 0.12 * uAmount; + float vignette = 1.0 - smoothstep(0.42, 0.8, length(vUv - 0.5)) * 0.65 * uAmount; + fragColor = vec4(max(vec3(0.0), (vec3(red, base.g, blue) * scan + grain) * vignette), base.a); +}`; +const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +struct Uniforms { uTime: f32, uAmount: f32, uSplit: f32, _pad: f32 }; +@group(1) @binding(0) var uniforms: Uniforms; + +fn hash(point: vec2) -> f32 { + return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); +} + +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let centered = vUv * 2.0 - vec2(1.0); + let uv = centered * (1.0 + dot(centered, centered) * 0.07 * uniforms.uAmount) * 0.5 + vec2(0.5); + let base = textureSample(uTexture, uSampler, uv); + let red = textureSample(uTexture, uSampler, uv + vec2(uniforms.uSplit, 0.0)).r; + let blue = textureSample(uTexture, uSampler, uv - vec2(uniforms.uSplit, 0.0)).b; + let scan = 1.0 - 0.14 * uniforms.uAmount * (0.5 + 0.5 * sin(vUv.y * 900.0)); + let grain = (hash(vUv * vec2(1280.0, 720.0) + uniforms.uTime * 45.0) - 0.5) * 0.12 * uniforms.uAmount; + let vignette = 1.0 - smoothstep(0.42, 0.8, length(vUv - vec2(0.5))) * 0.65 * uniforms.uAmount; + return vec4(max(vec3(0.0), (vec3(red, base.g, blue) * scan + grain) * vignette), base.a); +}`; +class RetroDisplayScene extends Scene { + stage; filter; - enabled = true; hud; - panel; + enabled = true; + amount = 0.8; + split = 0.006; + time = 0; init() { - const app = this.app; - const { width, height } = app; - this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl }); - this.sprite = new Sprite(this.loader.get(PIXEL_GRID)) - .setAnchor(0.5) - .setScale(5) - .setPosition(width / 2, height / 2); - this.sprite.filters = [this.filter]; + const { width, height } = this.app; + const background = new Sprite(this.loader.get(assets.technical.filtering.uvGrid256)).setAnchor(0.5).setPosition(width / 2, height / 2); + background.width = width; + background.height = height; + const title = new Text('SIGNAL LOST', { fillColor: Color.white, fontSize: 96, outlineColor: Color.black, outlineWidth: 4 }); + title.setAnchor(0.5).setPosition(width / 2, height / 2); + this.stage = new Container(); + this.stage.addChild(background, title); + this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0, uAmount: this.amount, uSplit: this.split } }); + this.stage.filters = [this.filter]; this.hud = mountControls({ - title: 'CRT Scanlines', - controls: [{ keys: 'CRT', action: 'toggle the scanline / barrel filter' }], + title: 'Retro Display', status: this.statusText(), - hint: 'Toggle the filter off to compare against the raw sprite.', + hint: 'One display filter combines barrel distortion, scanlines, animated grain, vignette, and RGB separation. Bypass it to compare the clean scene.', }); - this.panel = mountControlPanel({ title: 'Display' }); - this.panel.addToggle({ + const panel = mountControlPanel({ title: 'Display' }); + panel.addToggle({ label: 'CRT', value: true, - onChange: on => { - this.enabled = on; - this.sprite.filters = on ? [this.filter] : []; + onChange: enabled => { + this.enabled = enabled; + this.stage.filters = enabled ? [this.filter] : []; + this.hud.setStatus(this.statusText()); + }, + }); + panel.addSlider({ + label: 'Effect strength', + min: 0, + max: 1, + step: 0.05, + value: this.amount, + onChange: value => { + this.amount = value; + this.filter.setUniform('uAmount', value); + this.hud.setStatus(this.statusText()); + }, + }); + panel.addSlider({ + label: 'RGB separation', + min: 0, + max: 0.02, + step: 0.001, + value: this.split, + onChange: value => { + this.split = value; + this.filter.setUniform('uSplit', value); this.hud.setStatus(this.statusText()); }, }); } statusText() { - return this.enabled ? 'CRT: ON (scanlines + barrel + vignette)' : 'CRT: OFF (original sprite)'; + return this.enabled ? `CRT ${Math.round(this.amount * 100)}% · RGB split ${this.split.toFixed(3)}` : 'CRT off · clean scene'; + } + update(delta) { + this.time += delta; + this.filter.setUniform('uTime', this.time); } draw(context) { - context.render(this.sprite); + context.render(this.stage); + } + destroy() { + this.hud.dispose(); + this.filter.destroy(); + super.destroy(); } } const app = new Application({ - scenes: { CrtScanlinesScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { RetroDisplayScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); -await app.start(CrtScanlinesScene); +await app.start(RetroDisplayScene); diff --git a/examples/filters/crt-scanlines.ts b/examples/filters/crt-scanlines.ts index 9ed2ddb49..35daf6526 100644 --- a/examples/filters/crt-scanlines.ts +++ b/examples/filters/crt-scanlines.ts @@ -1,70 +1,150 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { + Application, + Color, + Container, + FixedResolutionCanvasSizing, + type RenderingContext, + Scene, + type Seconds, + ShaderFilter, + Sprite, + Text, +} from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; -// A regular pixel grid makes the scanline darkening and barrel warp obvious. -const PIXEL_GRID = assets.technical.filtering.pixelGrid128; - const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv*2.0-1.0; uv*=1.0+dot(uv,uv)*0.07; uv=uv*0.5+0.5; vec4 c=texture(uTexture,uv); float scan=0.88+0.12*sin(vUv.y*900.0); float vig=1.0-smoothstep(0.45,0.95,length(vUv-0.5)); fragColor=vec4(c.rgb*scan*vig,c.a);} `; -const wgsl = `@group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ var uv=vUv*2.0-vec2(1.0); uv=uv*(1.0+dot(uv,uv)*0.07); uv=uv*0.5+vec2(0.5); let c=textureSample(uTexture,uSampler,uv); let scan=0.88+0.12*sin(vUv.y*900.0); let vig=1.0-smoothstep(0.45,0.95,length(vUv-vec2(0.5))); return vec4(c.rgb*scan*vig,c.a);} `; +precision highp float; +uniform sampler2D uTexture; +uniform float uTime; +uniform float uAmount; +uniform float uSplit; +in vec2 vUv; +out vec4 fragColor; + +float hash(vec2 point) { + return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); +} -class CrtScanlinesScene extends Scene { - private sprite!: Sprite; +void main() { + vec2 centered = vUv * 2.0 - 1.0; + vec2 uv = centered * (1.0 + dot(centered, centered) * 0.07 * uAmount) * 0.5 + 0.5; + vec4 base = texture(uTexture, uv); + float red = texture(uTexture, uv + vec2(uSplit, 0.0)).r; + float blue = texture(uTexture, uv - vec2(uSplit, 0.0)).b; + float scan = 1.0 - 0.14 * uAmount * (0.5 + 0.5 * sin(vUv.y * 900.0)); + float grain = (hash(vUv * vec2(1280.0, 720.0) + uTime * 45.0) - 0.5) * 0.12 * uAmount; + float vignette = 1.0 - smoothstep(0.42, 0.8, length(vUv - 0.5)) * 0.65 * uAmount; + fragColor = vec4(max(vec3(0.0), (vec3(red, base.g, blue) * scan + grain) * vignette), base.a); +}`; + +const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +struct Uniforms { uTime: f32, uAmount: f32, uSplit: f32, _pad: f32 }; +@group(1) @binding(0) var uniforms: Uniforms; + +fn hash(point: vec2) -> f32 { + return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); +} + +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let centered = vUv * 2.0 - vec2(1.0); + let uv = centered * (1.0 + dot(centered, centered) * 0.07 * uniforms.uAmount) * 0.5 + vec2(0.5); + let base = textureSample(uTexture, uSampler, uv); + let red = textureSample(uTexture, uSampler, uv + vec2(uniforms.uSplit, 0.0)).r; + let blue = textureSample(uTexture, uSampler, uv - vec2(uniforms.uSplit, 0.0)).b; + let scan = 1.0 - 0.14 * uniforms.uAmount * (0.5 + 0.5 * sin(vUv.y * 900.0)); + let grain = (hash(vUv * vec2(1280.0, 720.0) + uniforms.uTime * 45.0) - 0.5) * 0.12 * uniforms.uAmount; + let vignette = 1.0 - smoothstep(0.42, 0.8, length(vUv - vec2(0.5))) * 0.65 * uniforms.uAmount; + return vec4(max(vec3(0.0), (vec3(red, base.g, blue) * scan + grain) * vignette), base.a); +}`; + +class RetroDisplayScene extends Scene { + private stage!: Container; private filter!: ShaderFilter; - private enabled = true; private hud!: ReturnType; - private panel!: ReturnType; + private enabled = true; + private amount = 0.8; + private split = 0.006; + private time = 0; override init(): void { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; + const background = new Sprite(this.loader.get(assets.technical.filtering.uvGrid256)).setAnchor(0.5).setPosition(width / 2, height / 2); + background.width = width; + background.height = height; + const title = new Text('SIGNAL LOST', { fillColor: Color.white, fontSize: 96, outlineColor: Color.black, outlineWidth: 4 }); + title.setAnchor(0.5).setPosition(width / 2, height / 2); - this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl }); - 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.stage = new Container(); + this.stage.addChild(background, title); + this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0, uAmount: this.amount, uSplit: this.split } }); + this.stage.filters = [this.filter]; this.hud = mountControls({ - title: 'CRT Scanlines', - controls: [{ keys: 'CRT', action: 'toggle the scanline / barrel filter' }], + title: 'Retro Display', status: this.statusText(), - hint: 'Toggle the filter off to compare against the raw sprite.', + hint: 'One display filter combines barrel distortion, scanlines, animated grain, vignette, and RGB separation. Bypass it to compare the clean scene.', }); - - this.panel = mountControlPanel({ title: 'Display' }); - this.panel.addToggle({ + const panel = mountControlPanel({ title: 'Display' }); + panel.addToggle({ label: 'CRT', value: true, - onChange: on => { - this.enabled = on; - this.sprite.filters = on ? [this.filter] : []; + onChange: enabled => { + this.enabled = enabled; + this.stage.filters = enabled ? [this.filter] : []; + this.hud.setStatus(this.statusText()); + }, + }); + panel.addSlider({ + label: 'Effect strength', + min: 0, + max: 1, + step: 0.05, + value: this.amount, + onChange: value => { + this.amount = value; + this.filter.setUniform('uAmount', value); + this.hud.setStatus(this.statusText()); + }, + }); + panel.addSlider({ + label: 'RGB separation', + min: 0, + max: 0.02, + step: 0.001, + value: this.split, + onChange: value => { + this.split = value; + this.filter.setUniform('uSplit', value); this.hud.setStatus(this.statusText()); }, }); } private statusText(): string { - return this.enabled ? 'CRT: ON (scanlines + barrel + vignette)' : 'CRT: OFF (original sprite)'; + return this.enabled ? `CRT ${Math.round(this.amount * 100)}% · RGB split ${this.split.toFixed(3)}` : 'CRT off · clean scene'; + } + + override update(delta: Seconds): void { + this.time += delta; + this.filter.setUniform('uTime', this.time); } override draw(context: RenderingContext): void { - context.render(this.sprite); + context.render(this.stage); + } + + override destroy(): void { + this.hud.dispose(); + this.filter.destroy(); + super.destroy(); } } const app = new Application({ - scenes: { CrtScanlinesScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { RetroDisplayScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, }); -await app.start(CrtScanlinesScene); +await app.start(RetroDisplayScene); diff --git a/examples/filters/custom-fragment-shader.js b/examples/filters/custom-fragment-shader.js index e9cc888a4..dfe40fa2f 100644 --- a/examples/filters/custom-fragment-shader.js +++ b/examples/filters/custom-fragment-shader.js @@ -1,7 +1,7 @@ // Auto-generated from custom-fragment-shader.ts - edit the .ts source, not this file. import { Application, Color, createFilterShader, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite, UniformType } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -const HUE_RAMP = assets.technical.color.hueRamp; +const UV_GRID = assets.technical.filtering.uvGrid256; // Neither source declares the uniform: the schema below generates the GLSL // block and the WGSL struct from one layout, and both bodies read it through // the same `uniforms` instance. @@ -10,16 +10,26 @@ precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv; uv.y += sin((uv.x*12.0)+uniforms.uTime*3.0)*0.03; fragColor=texture(uTexture,uv); }`; +void main() { + vec2 delta = vUv - uniforms.uPointer; + float distanceToPointer = length(delta); + float ripple = sin(distanceToPointer * 22.0 - uniforms.uTime * 5.0); + float falloff = exp(-distanceToPointer * 5.0); + vec2 uv = vUv + normalize(delta + vec2(0.001)) * ripple * falloff * 0.045; + fragColor = texture(uTexture, uv); +}`; const wgsl = ` @group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ - var uv=vUv; - uv.y = uv.y + sin((uv.x*12.0)+uniforms.uTime*3.0)*0.03; - return textureSample(uTexture,uSampler,uv); +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let delta = vUv - uniforms.uPointer; + let distanceToPointer = length(delta); + let ripple = sin(distanceToPointer * 22.0 - uniforms.uTime * 5.0); + let falloff = exp(-distanceToPointer * 5.0); + let uv = vUv + normalize(delta + vec2(0.001)) * ripple * falloff * 0.045; + return textureSample(uTexture, uSampler, uv); }`; -const warpShader = createFilterShader({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: UniformType.Float } }); +const warpShader = createFilterShader({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: UniformType.Float, uPointer: UniformType.Vec2 } }); class CustomFragmentShaderScene extends Scene { time = 0; filter; @@ -29,17 +39,23 @@ class CustomFragmentShaderScene extends Scene { const app = this.app; const { width, height } = app; this.filter = ShaderFilter.from(warpShader); - this.sprite = new Sprite(this.loader.get(HUE_RAMP)) + this.sprite = new Sprite(this.loader.get(UV_GRID)) .setAnchor(0.5) .setScale(4) .setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ - title: 'Custom Fragment WebGl2Shader', - status: 'A time-driven sine warp drives the sprite UVs each frame.', - hint: 'One shader source declares `uTime` once; both languages get a matching uniform block generated for them.', + title: 'Write a Fragment Filter', + controls: [{ keys: 'Move pointer', action: 'place the ripple on the UV grid' }], + status: 'A moving ripple distorts the UV grid around the pointer.', + hint: 'The typed uniform schema generates matching GLSL and WGSL blocks for time and pointer position.', }); + this.filter.uniforms.uPointer.set(0.5, 0.5); + app.input.onPointerMove.add(this.onMove); } + onMove = pointer => { + this.filter.uniforms.uPointer.set(pointer.x / this.app.width, pointer.y / this.app.height); + }; update(delta) { this.time += delta; this.filter.uniforms.uTime.set(this.time); @@ -47,6 +63,12 @@ class CustomFragmentShaderScene extends Scene { draw(context) { context.render(this.sprite); } + destroy() { + this.app.input.onPointerMove.remove(this.onMove); + this.hud?.dispose(); + this.sprite?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { CustomFragmentShaderScene }, diff --git a/examples/filters/custom-fragment-shader.ts b/examples/filters/custom-fragment-shader.ts index 934c44377..3c1d03b28 100644 --- a/examples/filters/custom-fragment-shader.ts +++ b/examples/filters/custom-fragment-shader.ts @@ -12,7 +12,7 @@ import { } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -const HUE_RAMP = assets.technical.color.hueRamp; +const UV_GRID = assets.technical.filtering.uvGrid256; // Neither source declares the uniform: the schema below generates the GLSL // block and the WGSL struct from one layout, and both bodies read it through @@ -22,17 +22,27 @@ precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv; uv.y += sin((uv.x*12.0)+uniforms.uTime*3.0)*0.03; fragColor=texture(uTexture,uv); }`; +void main() { + vec2 delta = vUv - uniforms.uPointer; + float distanceToPointer = length(delta); + float ripple = sin(distanceToPointer * 22.0 - uniforms.uTime * 5.0); + float falloff = exp(-distanceToPointer * 5.0); + vec2 uv = vUv + normalize(delta + vec2(0.001)) * ripple * falloff * 0.045; + fragColor = texture(uTexture, uv); +}`; const wgsl = ` @group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ - var uv=vUv; - uv.y = uv.y + sin((uv.x*12.0)+uniforms.uTime*3.0)*0.03; - return textureSample(uTexture,uSampler,uv); +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let delta = vUv - uniforms.uPointer; + let distanceToPointer = length(delta); + let ripple = sin(distanceToPointer * 22.0 - uniforms.uTime * 5.0); + let falloff = exp(-distanceToPointer * 5.0); + let uv = vUv + normalize(delta + vec2(0.001)) * ripple * falloff * 0.045; + return textureSample(uTexture, uSampler, uv); }`; -const warpShader = createFilterShader({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: UniformType.Float } }); +const warpShader = createFilterShader({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: UniformType.Float, uPointer: UniformType.Vec2 } }); class CustomFragmentShaderScene extends Scene { private time = 0; @@ -45,19 +55,26 @@ class CustomFragmentShaderScene extends Scene { const { width, height } = app; this.filter = ShaderFilter.from(warpShader); - this.sprite = new Sprite(this.loader.get(HUE_RAMP)) + this.sprite = new Sprite(this.loader.get(UV_GRID)) .setAnchor(0.5) .setScale(4) .setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; this.hud = mountControls({ - title: 'Custom Fragment WebGl2Shader', - status: 'A time-driven sine warp drives the sprite UVs each frame.', - hint: 'One shader source declares `uTime` once; both languages get a matching uniform block generated for them.', + title: 'Write a Fragment Filter', + controls: [{ keys: 'Move pointer', action: 'place the ripple on the UV grid' }], + status: 'A moving ripple distorts the UV grid around the pointer.', + hint: 'The typed uniform schema generates matching GLSL and WGSL blocks for time and pointer position.', }); + this.filter.uniforms.uPointer.set(0.5, 0.5); + app.input.onPointerMove.add(this.onMove); } + private readonly onMove = (pointer: { x: number; y: number }): void => { + this.filter.uniforms.uPointer.set(pointer.x / this.app.width, pointer.y / this.app.height); + }; + override update(delta: Seconds): void { this.time += delta; this.filter.uniforms.uTime.set(this.time); @@ -66,6 +83,13 @@ class CustomFragmentShaderScene extends Scene { override draw(context: RenderingContext): void { context.render(this.sprite); } + + override destroy(): void { + this.app.input.onPointerMove.remove(this.onMove); + this.hud?.dispose(); + this.sprite?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/filters/metaballs.js b/examples/filters/metaballs.js index dda7416b8..e4b7180b8 100644 --- a/examples/filters/metaballs.js +++ b/examples/filters/metaballs.js @@ -5,27 +5,62 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // enough, with a smooth edge. The blur in front of this builds the scalar field // from the hard circles, so neighbouring blobs merge where their fields sum. const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ float l=texture(uTexture,vUv).r; float m=smoothstep(0.28,0.5,l); fragColor=vec4(vec3(0.2,0.9,1.0)*m,m); }`; -const wgsl = `@group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ let l=textureSample(uTexture,uSampler,vUv).r; let m=smoothstep(0.28,0.5,l); return vec4(vec3(0.2,0.9,1.0)*m,m);} `; +precision mediump float; +uniform sampler2D uTexture; +in vec2 vUv; +out vec4 fragColor; +void main() { + float field = texture(uTexture, vUv).r; + float shape = smoothstep(0.28, 0.5, field); + fragColor = vec4(vec3(0.2, 0.9, 1.0) * shape, shape); +}`; +const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let field = textureSample(uTexture, uSampler, vUv).r; + let shape = smoothstep(0.28, 0.5, field); + return vec4(vec3(0.2, 0.9, 1.0) * shape, shape); +}`; class MetaballsScene extends Scene { balls; points; blur; threshold; + handle; + dragPoint = { x: 0, y: 0 }; + dragging = false; + hud; + onPointerDown = (_pointer, x, y) => { + this.dragging = Math.hypot(x - this.dragPoint.x, y - this.dragPoint.y) < 58; + }; + onPointerMove = (_pointer, x, y) => { + if (this.dragging) this.dragPoint = { x, y }; + }; + onPointerEnd = () => { + this.dragging = false; + }; init() { + this.dragPoint = { x: this.app.width / 2 - 110, y: this.app.height / 2 }; this.balls = new Graphics(); - this.points = Array.from({ length: 8 }, (_, i) => ({ a: (i / 8) * Math.PI * 2, r: 120 + (i % 3) * 56 })); + this.handle = new Graphics(); + this.points = Array.from({ length: 7 }, (_, i) => ({ a: (i / 7) * Math.PI * 2, r: 120 + (i % 3) * 56 })); this.blur = new BlurFilter({ strength: 6 }); this.threshold = new ShaderFilter({ glsl: { fragment: glsl }, wgsl }); - // Order matters: blur first (build the field), threshold second. - this.balls.filters = [this.blur, this.threshold]; - mountControls({ - title: 'Metaballs', - hint: 'Hard circles are blurred into a scalar field, then thresholded — so nearby blobs merge.', + this.setOrder('field'); + this.hud = mountControls({ + title: 'Metaballs and Filter Order', + controls: [{ keys: 'Drag ring', action: 'move a blob into the others' }], + status: 'Blur → Threshold: merged shapes', + hint: 'Blur then threshold merges nearby circles. Reverse the filters or bypass them to compare the result.', }); - mountControlPanel({ title: 'Field' }).addSlider({ + const panel = mountControlPanel({ title: 'Filter order' }); + panel.addCycle({ + label: 'Passes', + options: ['Blur → Threshold', 'Threshold → Blur', 'No filters'], + index: 0, + onChange: index => this.setOrder(['field', 'soft', 'raw'][index]), + }); + panel.addSlider({ label: 'Blur strength', min: 1, max: 12, @@ -35,6 +70,16 @@ class MetaballsScene extends Scene { this.blur.strength = value; }, }); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); + } + setOrder(order) { + this.balls.filters = order === 'field' ? [this.blur, this.threshold] : order === 'soft' ? [this.threshold, this.blur] : []; + this.hud?.setStatus( + order === 'field' ? 'Blur → Threshold: merged shapes' : order === 'soft' ? 'Threshold → Blur: soft circles' : 'No filters: source circles', + ); } update(delta) { const app = this.app; @@ -48,9 +93,23 @@ class MetaballsScene extends Scene { for (const point of this.points) { this.balls.drawCircle(width / 2 + Math.cos(point.a) * point.r * 1.6, height / 2 + Math.sin(point.a * 1.4) * point.r * 0.8, 44); } + this.balls.drawCircle(this.dragPoint.x, this.dragPoint.y, 44); + this.handle.clear(); + this.handle.lineColor = new Color(255, 230, 115); + this.handle.lineWidth = 3; + this.handle.drawCircle(this.dragPoint.x, this.dragPoint.y, 51); } draw(context) { context.render(this.balls); + context.render(this.handle); + } + destroy() { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ diff --git a/examples/filters/metaballs.ts b/examples/filters/metaballs.ts index c3f792dca..6400177f7 100644 --- a/examples/filters/metaballs.ts +++ b/examples/filters/metaballs.ts @@ -5,32 +5,67 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // enough, with a smooth edge. The blur in front of this builds the scalar field // from the hard circles, so neighbouring blobs merge where their fields sum. const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; -void main(){ float l=texture(uTexture,vUv).r; float m=smoothstep(0.28,0.5,l); fragColor=vec4(vec3(0.2,0.9,1.0)*m,m); }`; -const wgsl = `@group(0) @binding(1) var uTexture:texture_2d; @group(0) @binding(2) var uSampler:sampler; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ let l=textureSample(uTexture,uSampler,vUv).r; let m=smoothstep(0.28,0.5,l); return vec4(vec3(0.2,0.9,1.0)*m,m);} `; +precision mediump float; +uniform sampler2D uTexture; +in vec2 vUv; +out vec4 fragColor; +void main() { + float field = texture(uTexture, vUv).r; + float shape = smoothstep(0.28, 0.5, field); + fragColor = vec4(vec3(0.2, 0.9, 1.0) * shape, shape); +}`; +const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +@fragment fn fragmentMain(@location(0) vUv: vec2) -> @location(0) vec4 { + let field = textureSample(uTexture, uSampler, vUv).r; + let shape = smoothstep(0.28, 0.5, field); + return vec4(vec3(0.2, 0.9, 1.0) * shape, shape); +}`; class MetaballsScene extends Scene { private balls!: Graphics; private points!: { a: number; r: number }[]; private blur!: BlurFilter; private threshold!: ShaderFilter; + private handle!: Graphics; + private dragPoint = { x: 0, y: 0 }; + private dragging = false; + private hud!: ReturnType; + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + this.dragging = Math.hypot(x - this.dragPoint.x, y - this.dragPoint.y) < 58; + }; + private readonly onPointerMove = (_pointer: unknown, x: number, y: number): void => { + if (this.dragging) this.dragPoint = { x, y }; + }; + private readonly onPointerEnd = (): void => { + this.dragging = false; + }; override init(): void { + this.dragPoint = { x: this.app.width / 2 - 110, y: this.app.height / 2 }; this.balls = new Graphics(); - this.points = Array.from({ length: 8 }, (_, i) => ({ a: (i / 8) * Math.PI * 2, r: 120 + (i % 3) * 56 })); + this.handle = new Graphics(); + this.points = Array.from({ length: 7 }, (_, i) => ({ a: (i / 7) * Math.PI * 2, r: 120 + (i % 3) * 56 })); this.blur = new BlurFilter({ strength: 6 }); this.threshold = new ShaderFilter({ glsl: { fragment: glsl }, wgsl }); - // Order matters: blur first (build the field), threshold second. - this.balls.filters = [this.blur, this.threshold]; + this.setOrder('field'); - mountControls({ - title: 'Metaballs', - hint: 'Hard circles are blurred into a scalar field, then thresholded — so nearby blobs merge.', + this.hud = mountControls({ + title: 'Metaballs and Filter Order', + controls: [{ keys: 'Drag ring', action: 'move a blob into the others' }], + status: 'Blur → Threshold: merged shapes', + hint: 'Blur then threshold merges nearby circles. Reverse the filters or bypass them to compare the result.', }); - mountControlPanel({ title: 'Field' }).addSlider({ + const panel = mountControlPanel({ title: 'Filter order' }); + panel.addCycle({ + label: 'Passes', + options: ['Blur → Threshold', 'Threshold → Blur', 'No filters'], + index: 0, + onChange: index => this.setOrder((['field', 'soft', 'raw'] as const)[index]), + }); + panel.addSlider({ label: 'Blur strength', min: 1, max: 12, @@ -40,6 +75,17 @@ class MetaballsScene extends Scene { this.blur.strength = value; }, }); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); + } + + private setOrder(order: 'field' | 'soft' | 'raw'): void { + this.balls.filters = order === 'field' ? [this.blur, this.threshold] : order === 'soft' ? [this.threshold, this.blur] : []; + this.hud?.setStatus( + order === 'field' ? 'Blur → Threshold: merged shapes' : order === 'soft' ? 'Threshold → Blur: soft circles' : 'No filters: source circles', + ); } override update(delta: Seconds): void { @@ -57,10 +103,26 @@ class MetaballsScene extends Scene { for (const point of this.points) { this.balls.drawCircle(width / 2 + Math.cos(point.a) * point.r * 1.6, height / 2 + Math.sin(point.a * 1.4) * point.r * 0.8, 44); } + this.balls.drawCircle(this.dragPoint.x, this.dragPoint.y, 44); + + this.handle.clear(); + this.handle.lineColor = new Color(255, 230, 115); + this.handle.lineWidth = 3; + this.handle.drawCircle(this.dragPoint.x, this.dragPoint.y, 51); } override draw(context: RenderingContext): void { context.render(this.balls); + context.render(this.handle); + } + + override destroy(): void { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); + super.destroy(); } } diff --git a/examples/geometry-graphics/graphics-gradient.js b/examples/geometry-graphics/graphics-gradient.js index 9e56a4243..58fe403d0 100644 --- a/examples/geometry-graphics/graphics-gradient.js +++ b/examples/geometry-graphics/graphics-gradient.js @@ -4,6 +4,7 @@ class GraphicsGradientScene extends Scene { sceneRoot; panel; orb; + diamond; ring; badge; init() { @@ -33,6 +34,9 @@ class GraphicsGradientScene extends Scene { 0.5, ); this.orb.drawCircle(-96, -8, 56); + this.diamond = new Graphics(); + this.diamond.fillColor = new Color(35, 45, 85); + this.diamond.drawPolygon([0, -70, 70, 0, 0, 70, -70, 0]); this.ring = new Graphics(); this.ring.lineWidth = 12; this.ring.strokeStyle = new RadialGradient( @@ -50,7 +54,7 @@ class GraphicsGradientScene extends Scene { { offset: 1, color: new Color(40, 160, 120, 1) }, ]); this.badge.drawStar(0, 116, 5, 46, 20); - this.sceneRoot.addChild(this.panel, this.orb, this.ring, this.badge); + this.sceneRoot.addChild(this.panel, this.orb, this.diamond, this.ring, this.badge); } update(delta) { const app = this.app; @@ -63,6 +67,7 @@ class GraphicsGradientScene extends Scene { } destroy() { this.sceneRoot?.destroy(); + super.destroy(); } } const app = new Application({ diff --git a/examples/geometry-graphics/graphics-gradient.ts b/examples/geometry-graphics/graphics-gradient.ts index 598d828e2..30377bf23 100644 --- a/examples/geometry-graphics/graphics-gradient.ts +++ b/examples/geometry-graphics/graphics-gradient.ts @@ -15,6 +15,7 @@ class GraphicsGradientScene extends Scene { private sceneRoot!: Container; private panel!: Graphics; private orb!: Graphics; + private diamond!: Graphics; private ring!: Graphics; private badge!: Graphics; @@ -49,6 +50,10 @@ class GraphicsGradientScene extends Scene { ); this.orb.drawCircle(-96, -8, 56); + this.diamond = new Graphics(); + this.diamond.fillColor = new Color(35, 45, 85); + this.diamond.drawPolygon([0, -70, 70, 0, 0, 70, -70, 0]); + this.ring = new Graphics(); this.ring.lineWidth = 12; this.ring.strokeStyle = new RadialGradient( @@ -68,7 +73,7 @@ class GraphicsGradientScene extends Scene { ]); this.badge.drawStar(0, 116, 5, 46, 20); - this.sceneRoot.addChild(this.panel, this.orb, this.ring, this.badge); + this.sceneRoot.addChild(this.panel, this.orb, this.diamond, this.ring, this.badge); } override update(delta: Seconds): void { @@ -84,6 +89,7 @@ class GraphicsGradientScene extends Scene { override destroy(): void { this.sceneRoot?.destroy(); + super.destroy(); } } diff --git a/examples/geometry-graphics/mesh-textured-quad.js b/examples/geometry-graphics/mesh-textured-quad.js index a7218713f..30d0b2887 100644 --- a/examples/geometry-graphics/mesh-textured-quad.js +++ b/examples/geometry-graphics/mesh-textured-quad.js @@ -1,9 +1,12 @@ // Auto-generated from mesh-textured-quad.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Mesh, Scene } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Mesh, Scene, Text } from '@codexo/exojs'; const UV_GRID = assets.technical.filtering.uvGrid256; -const HALF = 300; -class MeshTexturedQuadScene extends Scene { +const HALF = 160; +class MeshVerticesScene extends Scene { + triangle; quad; + triangleLabel; + quadLabel; init() { const app = this.app; const { width, height } = app; @@ -13,17 +16,30 @@ class MeshTexturedQuadScene extends Scene { indices: new Uint16Array([0, 1, 2, 0, 2, 3]), texture: this.loader.get(UV_GRID), }); - this.quad.setPosition((width / 2) | 0, (height / 2) | 0); + this.triangle = new Mesh({ + vertices: new Float32Array([0, -130, 130, 110, -130, 110]), + colors: new Uint32Array([0xff0000ff, 0xff00ff00, 0xffff0000]), + }); + this.triangle.setPosition(width * 0.28, height * 0.53); + this.quad.setPosition(width * 0.72, height * 0.53); + this.triangleLabel = new Text('Vertex colors', { fillColor: Color.white, fontSize: 22 }); + this.quadLabel = new Text('Texture UVs', { fillColor: Color.white, fontSize: 22 }); + this.triangleLabel.setPosition(width * 0.2, 95); + this.quadLabel.setPosition(width * 0.64, 95); } update(delta) { + this.triangle.rotate(delta * 45); this.quad.rotate(delta * 30); } draw(context) { + context.render(this.triangle); context.render(this.quad); + context.render(this.triangleLabel); + context.render(this.quadLabel); } } const app = new Application({ - scenes: { MeshTexturedQuadScene }, + scenes: { MeshVerticesScene }, canvas: { width: 1280, height: 720, @@ -32,4 +48,4 @@ const app = new Application({ }, clearColor: Color.black, }); -await app.start(MeshTexturedQuadScene); +await app.start(MeshVerticesScene); diff --git a/examples/geometry-graphics/mesh-textured-quad.ts b/examples/geometry-graphics/mesh-textured-quad.ts index 0eef778ec..df8168aa6 100644 --- a/examples/geometry-graphics/mesh-textured-quad.ts +++ b/examples/geometry-graphics/mesh-textured-quad.ts @@ -1,10 +1,13 @@ -import { Application, Color, FixedResolutionCanvasSizing, Mesh, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Mesh, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs'; const UV_GRID = assets.technical.filtering.uvGrid256; -const HALF = 300; +const HALF = 160; -class MeshTexturedQuadScene extends Scene { +class MeshVerticesScene extends Scene { + private triangle!: Mesh; private quad!: Mesh; + private triangleLabel!: Text; + private quadLabel!: Text; override init(): void { const app = this.app; @@ -17,20 +20,33 @@ class MeshTexturedQuadScene extends Scene { texture: this.loader.get(UV_GRID), }); - this.quad.setPosition((width / 2) | 0, (height / 2) | 0); + this.triangle = new Mesh({ + vertices: new Float32Array([0, -130, 130, 110, -130, 110]), + colors: new Uint32Array([0xff0000ff, 0xff00ff00, 0xffff0000]), + }); + this.triangle.setPosition(width * 0.28, height * 0.53); + this.quad.setPosition(width * 0.72, height * 0.53); + this.triangleLabel = new Text('Vertex colors', { fillColor: Color.white, fontSize: 22 }); + this.quadLabel = new Text('Texture UVs', { fillColor: Color.white, fontSize: 22 }); + this.triangleLabel.setPosition(width * 0.2, 95); + this.quadLabel.setPosition(width * 0.64, 95); } override update(delta: Seconds): void { + this.triangle.rotate(delta * 45); this.quad.rotate(delta * 30); } override draw(context: RenderingContext): void { + context.render(this.triangle); context.render(this.quad); + context.render(this.triangleLabel); + context.render(this.quadLabel); } } const app = new Application({ - scenes: { MeshTexturedQuadScene }, + scenes: { MeshVerticesScene }, canvas: { width: 1280, height: 720, @@ -40,4 +56,4 @@ const app = new Application({ clearColor: Color.black, }); -await app.start(MeshTexturedQuadScene); +await app.start(MeshVerticesScene); diff --git a/examples/getting-started/hello-world.js b/examples/getting-started/hello-world.js index 01b0f378b..afd766e0a 100644 --- a/examples/getting-started/hello-world.js +++ b/examples/getting-started/hello-world.js @@ -3,15 +3,27 @@ import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from ' // #region guide:first-scene class HelloWorldScene extends Scene { sprite; + playing = true; init() { const app = this.app; const { width, height } = app; this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); + this.sprite.interactive = true; + this.sprite.onPointerTap.add(() => { + this.playing = !this.playing; + this.sprite.setTint(this.playing ? Color.white : new Color(150, 170, 190)); + }); + this.root.addChild(this.sprite); + } + update(delta) { + if (this.playing) { + this.sprite.rotate(delta * 120); + } } draw(context) { - context.render(this.sprite); + context.render(this.root); } } // #endregion guide:first-scene diff --git a/examples/getting-started/hello-world.ts b/examples/getting-started/hello-world.ts index 9e1b174b0..c94cf7731 100644 --- a/examples/getting-started/hello-world.ts +++ b/examples/getting-started/hello-world.ts @@ -1,8 +1,9 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs'; // #region guide:first-scene class HelloWorldScene extends Scene { private sprite!: Sprite; + private playing = true; override init(): void { const app = this.app; @@ -11,10 +12,22 @@ class HelloWorldScene extends Scene { this.sprite = new Sprite(this.loader.get('image/ship-a.png')); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); + this.sprite.interactive = true; + this.sprite.onPointerTap.add(() => { + this.playing = !this.playing; + this.sprite.setTint(this.playing ? Color.white : new Color(150, 170, 190)); + }); + this.root.addChild(this.sprite); + } + + override update(delta: Seconds): void { + if (this.playing) { + this.sprite.rotate(delta * 120); + } } override draw(context: RenderingContext): void { - context.render(this.sprite); + context.render(this.root); } } // #endregion guide:first-scene diff --git a/examples/input/action-mapping.js b/examples/input/action-mapping.js index e0e6396d4..64df7f678 100644 --- a/examples/input/action-mapping.js +++ b/examples/input/action-mapping.js @@ -1,75 +1,65 @@ // Auto-generated from action-mapping.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, GamepadButton, Keyboard, Scene, Sprite } from '@codexo/exojs'; +import { + ActionMap, + Application, + ButtonAction, + Color, + FixedResolutionCanvasSizing, + GamepadAxis, + GamepadButton, + Keyboard, + Scene, + Sprite, + VectorAction, +} from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -// The lesson: bind several *physical* inputs to a few *named actions*, then read -// only the actions in the update loop. Keyboard and gamepad feed the same -// `moveX` / `moveY` / `jump` values, so the gameplay code never branches on the -// device. Whichever device pushes a control harder this frame wins - so you can -// pick up either input mid-motion without a mode switch. +const PULSE_COLOR = new Color(255, 190, 90); class ActionMappingScene extends Scene { sprite; - keys = { left: 0, right: 0, up: 0, down: 0 }; - stick = { x: 0, y: 0 }; - jumpImpulse = 0; - lastDevice = 'keyboard'; - actions = { moveX: 0, moveY: 0, jump: false }; + actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + pulse: new ButtonAction([Keyboard.Space, GamepadButton.South]), + }); + pulseRemaining = 0; hud; init() { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); - const pad0 = app.input.getGamepad(0); - // --- Move action: keyboard WASD/arrows feed key axes --- - this.inputs.onActive([Keyboard.A, Keyboard.Left], () => (this.keys.left = 1)); - this.inputs.onStop([Keyboard.A, Keyboard.Left], () => (this.keys.left = 0)); - this.inputs.onActive([Keyboard.D, Keyboard.Right], () => (this.keys.right = 1)); - this.inputs.onStop([Keyboard.D, Keyboard.Right], () => (this.keys.right = 0)); - this.inputs.onActive([Keyboard.W, Keyboard.Up], () => (this.keys.up = 1)); - this.inputs.onStop([Keyboard.W, Keyboard.Up], () => (this.keys.up = 0)); - this.inputs.onActive([Keyboard.S, Keyboard.Down], () => (this.keys.down = 1)); - this.inputs.onStop([Keyboard.S, Keyboard.Down], () => (this.keys.down = 0)); - // --- Move action: gamepad left stick feeds the same axes --- - pad0.onActive(GamepadAxis.LeftStickX, value => (this.stick.x = value)); - pad0.onStop(GamepadAxis.LeftStickX, () => (this.stick.x = 0)); - pad0.onActive(GamepadAxis.LeftStickY, value => (this.stick.y = value)); - pad0.onStop(GamepadAxis.LeftStickY, () => (this.stick.y = 0)); - // --- Jump action: Space OR the South button, one shared impulse --- - this.inputs.onStart(Keyboard.Space, () => this.queueJump('keyboard')); - pad0.onStart(GamepadButton.South, () => this.queueJump('gamepad')); + this.inputs.attach(this.actions); this.hud = mountControls({ - title: 'Action Mapping', + title: 'Input Actions', controls: [ - { keys: ['W', 'A', 'S', 'D'], action: 'Move (keyboard)' }, - { keys: 'L-Stick', action: 'Move (gamepad)' }, - { keys: ['Space', 'A'], action: 'Jump (either device)' }, + { keys: 'WASD / Arrows / Left stick', action: 'move' }, + { keys: 'Space / South button', action: 'pulse' }, ], - status: 'Move 0.00, 0.00 · Jump idle', - hint: 'Driven by: keyboard', + status: '', + hint: 'Keyboard and controller feed the same move and pulse actions.', }); } - queueJump(device) { - this.jumpImpulse = -220; - this.lastDevice = device; - } update(delta) { - const keyX = this.keys.right - this.keys.left; - const keyY = this.keys.down - this.keys.up; - // Resolve each named action from whichever device is pushing hardest. - this.actions.moveX = Math.abs(this.stick.x) > Math.abs(keyX) ? this.stick.x : keyX; - this.actions.moveY = Math.abs(this.stick.y) > Math.abs(keyY) ? this.stick.y : keyY; - this.actions.jump = this.jumpImpulse < 0; - if (this.actions.moveX !== 0 || this.actions.moveY !== 0) { - this.lastDevice = Math.abs(this.stick.x) > Math.abs(keyX) || Math.abs(this.stick.y) > Math.abs(keyY) ? 'gamepad' : 'keyboard'; + const { x, y } = this.actions.move.value; + const { width, height } = this.app; + this.sprite.setPosition( + Math.max(24, Math.min(width - 24, this.sprite.x + x * 260 * delta)), + Math.max(24, Math.min(height - 24, this.sprite.y + y * 260 * delta)), + ); + if (this.actions.pulse.pressed) { + this.pulseRemaining = 0.35; } - this.sprite.move(this.actions.moveX * 260 * delta, this.actions.moveY * 260 * delta); - this.sprite.move(0, this.jumpImpulse * delta); - this.jumpImpulse = Math.min(0, this.jumpImpulse + 800 * delta); - this.hud.setStatus(`Move ${this.actions.moveX.toFixed(2)}, ${this.actions.moveY.toFixed(2)} · Jump ${this.actions.jump ? 'active' : 'idle'}`); - this.hud.setHint(`Driven by: ${this.lastDevice}`); + this.pulseRemaining = Math.max(0, this.pulseRemaining - delta); + this.sprite.setTint(this.pulseRemaining > 0 ? PULSE_COLOR : Color.white); + this.hud.setStatus(`Move ${x.toFixed(2)}, ${y.toFixed(2)} · Pulse ${this.pulseRemaining > 0 ? 'active' : 'ready'}`); } draw(context) { context.render(this.sprite); } + destroy() { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { ActionMappingScene }, diff --git a/examples/input/action-mapping.ts b/examples/input/action-mapping.ts index 3b5a71835..d17250c33 100644 --- a/examples/input/action-mapping.ts +++ b/examples/input/action-mapping.ts @@ -1,5 +1,7 @@ import { + ActionMap, Application, + ButtonAction, Color, FixedResolutionCanvasSizing, GamepadAxis, @@ -9,92 +11,66 @@ import { Scene, type Seconds, Sprite, + VectorAction, } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -// The lesson: bind several *physical* inputs to a few *named actions*, then read -// only the actions in the update loop. Keyboard and gamepad feed the same -// `moveX` / `moveY` / `jump` values, so the gameplay code never branches on the -// device. Whichever device pushes a control harder this frame wins - so you can -// pick up either input mid-motion without a mode switch. +const PULSE_COLOR = new Color(255, 190, 90); + class ActionMappingScene extends Scene { private sprite!: Sprite; - private keys = { left: 0, right: 0, up: 0, down: 0 }; - private stick = { x: 0, y: 0 }; - private jumpImpulse = 0; - private lastDevice = 'keyboard'; - private actions = { moveX: 0, moveY: 0, jump: false }; + private readonly actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + pulse: new ButtonAction([Keyboard.Space, GamepadButton.South]), + }); + private pulseRemaining = 0; private hud!: ReturnType; override init(): void { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); - - const pad0 = app.input.getGamepad(0); - - // --- Move action: keyboard WASD/arrows feed key axes --- - this.inputs.onActive([Keyboard.A, Keyboard.Left], () => (this.keys.left = 1)); - this.inputs.onStop([Keyboard.A, Keyboard.Left], () => (this.keys.left = 0)); - this.inputs.onActive([Keyboard.D, Keyboard.Right], () => (this.keys.right = 1)); - this.inputs.onStop([Keyboard.D, Keyboard.Right], () => (this.keys.right = 0)); - this.inputs.onActive([Keyboard.W, Keyboard.Up], () => (this.keys.up = 1)); - this.inputs.onStop([Keyboard.W, Keyboard.Up], () => (this.keys.up = 0)); - this.inputs.onActive([Keyboard.S, Keyboard.Down], () => (this.keys.down = 1)); - this.inputs.onStop([Keyboard.S, Keyboard.Down], () => (this.keys.down = 0)); - - // --- Move action: gamepad left stick feeds the same axes --- - pad0.onActive(GamepadAxis.LeftStickX, value => (this.stick.x = value)); - pad0.onStop(GamepadAxis.LeftStickX, () => (this.stick.x = 0)); - pad0.onActive(GamepadAxis.LeftStickY, value => (this.stick.y = value)); - pad0.onStop(GamepadAxis.LeftStickY, () => (this.stick.y = 0)); - - // --- Jump action: Space OR the South button, one shared impulse --- - this.inputs.onStart(Keyboard.Space, () => this.queueJump('keyboard')); - pad0.onStart(GamepadButton.South, () => this.queueJump('gamepad')); - + this.inputs.attach(this.actions); this.hud = mountControls({ - title: 'Action Mapping', + title: 'Input Actions', controls: [ - { keys: ['W', 'A', 'S', 'D'], action: 'Move (keyboard)' }, - { keys: 'L-Stick', action: 'Move (gamepad)' }, - { keys: ['Space', 'A'], action: 'Jump (either device)' }, + { keys: 'WASD / Arrows / Left stick', action: 'move' }, + { keys: 'Space / South button', action: 'pulse' }, ], - status: 'Move 0.00, 0.00 · Jump idle', - hint: 'Driven by: keyboard', + status: '', + hint: 'Keyboard and controller feed the same move and pulse actions.', }); } - private queueJump(device: string): void { - this.jumpImpulse = -220; - this.lastDevice = device; - } - override update(delta: Seconds): void { - const keyX = this.keys.right - this.keys.left; - const keyY = this.keys.down - this.keys.up; + const { x, y } = this.actions.move.value; + const { width, height } = this.app; - // Resolve each named action from whichever device is pushing hardest. - this.actions.moveX = Math.abs(this.stick.x) > Math.abs(keyX) ? this.stick.x : keyX; - this.actions.moveY = Math.abs(this.stick.y) > Math.abs(keyY) ? this.stick.y : keyY; - this.actions.jump = this.jumpImpulse < 0; + this.sprite.setPosition( + Math.max(24, Math.min(width - 24, this.sprite.x + x * 260 * delta)), + Math.max(24, Math.min(height - 24, this.sprite.y + y * 260 * delta)), + ); - if (this.actions.moveX !== 0 || this.actions.moveY !== 0) { - this.lastDevice = Math.abs(this.stick.x) > Math.abs(keyX) || Math.abs(this.stick.y) > Math.abs(keyY) ? 'gamepad' : 'keyboard'; + if (this.actions.pulse.pressed) { + this.pulseRemaining = 0.35; } - this.sprite.move(this.actions.moveX * 260 * delta, this.actions.moveY * 260 * delta); - this.sprite.move(0, this.jumpImpulse * delta); - this.jumpImpulse = Math.min(0, this.jumpImpulse + 800 * delta); - - this.hud.setStatus(`Move ${this.actions.moveX.toFixed(2)}, ${this.actions.moveY.toFixed(2)} · Jump ${this.actions.jump ? 'active' : 'idle'}`); - this.hud.setHint(`Driven by: ${this.lastDevice}`); + this.pulseRemaining = Math.max(0, this.pulseRemaining - delta); + this.sprite.setTint(this.pulseRemaining > 0 ? PULSE_COLOR : Color.white); + this.hud.setStatus(`Move ${x.toFixed(2)}, ${y.toFixed(2)} · Pulse ${this.pulseRemaining > 0 ? 'active' : 'ready'}`); } override draw(context: RenderingContext): void { context.render(this.sprite); } + + override destroy(): void { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/input/gamepad.js b/examples/input/gamepad.js index af9acf280..c1bebf2ef 100644 --- a/examples/input/gamepad.js +++ b/examples/input/gamepad.js @@ -1,235 +1,79 @@ // Auto-generated from gamepad.ts - edit the .ts source, not this file. -import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, GamepadAxis, GamepadButton, lerp, Scene, Spritesheet, Vector } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, GamepadButton, Graphics, Scene, Text } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; +const AXES = [ + { name: 'Left X', channel: GamepadAxis.LeftStickX }, + { name: 'Left Y', channel: GamepadAxis.LeftStickY }, + { name: 'Right X', channel: GamepadAxis.RightStickX }, + { name: 'Right Y', channel: GamepadAxis.RightStickY }, +]; +const BUTTONS = [ + { name: 'South', channel: GamepadButton.South }, + { name: 'East', channel: GamepadButton.East }, + { name: 'West', channel: GamepadButton.West }, + { name: 'North', channel: GamepadButton.North }, + { name: 'Left shoulder', channel: GamepadButton.LeftShoulder }, + { name: 'Right shoulder', channel: GamepadButton.RightShoulder }, + { name: 'Left trigger', channel: GamepadButton.LeftTrigger }, + { name: 'Right trigger', channel: GamepadButton.RightTrigger }, + { name: 'Start', channel: GamepadButton.Start }, + { name: 'Select', channel: GamepadButton.Select }, +]; class GamepadScene extends Scene { - activePad = null; - buttons; - buttonColor = new Color(255, 255, 255, 0.25); - mappingButtons = new Map(); - mappingFunctions = new Map(); - resetFunctions = []; - padBindings = []; + pad; + axes = []; + buttons = []; status; - container; - async load() { - const app = this.app; - const buttonsData = await this.loader.load(Asset.type('json', 'json/buttons.json')); - this.buttons = new Spritesheet(this.loader.get('image/buttons.png'), buttonsData); - const { width, height } = app; - this.status = this.createStatus(width, height); - this.container = this.createGamepad(width, height); - for (const sprite of this.mappingButtons.values()) { - sprite.setTint(this.buttonColor); - } - app.input.onGamepadConnected.add(pad => this.handleGamepadConnected(pad)); - app.input.onGamepadDisconnected.add(pad => this.handleGamepadDisconnected(pad)); - for (const pad of app.input.gamepads) { - if (pad.connected) { - this.setActivePad(pad); - break; - } - } + axisText; + buttonText; + graphics = new Graphics(); + hud; + init() { + this.pad = this.app.input.getGamepad(0); + this.axes = AXES.map(({ name, channel }) => ({ name, binding: this.pad.onActive(channel) })); + this.buttons = BUTTONS.map(({ name, channel }) => ({ name, binding: this.pad.onActive(channel) })); + this.status = new Text('', { fillColor: Color.white, fontSize: 23 }).setPosition(260, 130); + this.axisText = new Text('', { fillColor: new Color(125, 215, 255), fontSize: 23 }).setPosition(270, 450); + this.buttonText = new Text('', { fillColor: new Color(255, 195, 125), fontSize: 21 }).setPosition(800, 240); + this.hud = mountControls({ + title: 'Controller Inspector', + status: 'Slot 1 is waiting for a controller.', + hint: 'Axes and button values update live; disconnected state stays visible.', + }); + } + update() { + this.status.text = this.pad.connected + ? `Slot 1: ${this.pad.info?.label ?? 'Connected controller'}` + : 'No controller connected. Attach one and press a button.'; + this.axisText.text = this.axes.map(axis => `${axis.name}: ${axis.binding.value.toFixed(2)}`).join('\n'); + this.buttonText.text = this.buttons.map(button => `${button.name}: ${button.binding.value.toFixed(2)}`).join('\n'); } draw(context) { + this.graphics.clear(); + this.graphics.fillColor = new Color(44, 53, 67); + this.graphics.drawCircle(390, 310, 100); + this.graphics.drawCircle(650, 310, 100); + this.graphics.fillColor = new Color(100, 205, 245); + this.graphics.drawCircle(390 + this.axes[0].binding.value * 75, 310 + this.axes[1].binding.value * 75, 22); + this.graphics.drawCircle(650 + this.axes[2].binding.value * 75, 310 + this.axes[3].binding.value * 75, 22); + context.render(this.graphics); context.render(this.status); - context.render(this.container); - } - handleGamepadConnected(pad) { - if (!this.activePad) { - this.setActivePad(pad); - } - } - handleGamepadDisconnected(pad) { - if (this.activePad !== pad) { - return; - } - const app = this.app; - const next = app.input.gamepads.find(other => other !== pad && other.connected) || null; - this.setActivePad(next); - } - setActivePad(pad) { - for (const binding of this.padBindings) { - binding.unbind(); - } - this.padBindings.length = 0; - this.activePad = pad; - if (!pad) { - this.status.setTint(this.buttonColor); - this.resetVisualState(); - return; - } - this.status.setTint(Color.white); - for (const [channel, sprite] of this.mappingButtons.entries()) { - this.padBindings.push( - pad.onActive(channel, v => { - sprite.tint.a = lerp(0.25, 1, v); - }), - pad.onStop(channel, () => { - sprite.tint.a = this.buttonColor.a; - }), - ); - } - for (const [channel, fn] of this.mappingFunctions.entries()) { - this.padBindings.push( - pad.onActive(channel, v => { - fn(v); - }), - pad.onStop(channel, () => { - fn(0); - }), - ); - } - } - resetVisualState() { - for (const sprite of this.mappingButtons.values()) { - sprite.tint.a = this.buttonColor.a; - } - for (const reset of this.resetFunctions) { - reset(); - } - } - createStatus(width, height) { - const status = this.buttons.getFrameSprite('status'); - status.setAnchor(0.5); - status.setPosition(width / 2, height / 5); - status.setTint(this.buttonColor); - return status; + context.render(this.axisText); + context.render(this.buttonText); } - createGamepad(width, height) { - const container = new Container(); - container.addChild(this.createDPadField(width, height)); - container.addChild(this.createFaceButtons(width, height)); - container.addChild(this.createShoulderButtons(width, height)); - container.addChild(this.createMenuButtons(width, height)); - container.addChild(this.createJoysticks(width, height)); - return container; - } - createDPadField(width, height) { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const dPad = this.buttons.getFrameSprite('dpad'); - const dPadUp = this.buttons.getFrameSprite('DPadUp'); - const dPadDown = this.buttons.getFrameSprite('DPadDown'); - const dPadLeft = this.buttons.getFrameSprite('DPadLeft'); - const dPadRight = this.buttons.getFrameSprite('DPadRight'); - mappedButtons.set(GamepadButton.DPadUp, dPadUp); - mappedButtons.set(GamepadButton.DPadDown, dPadDown); - mappedButtons.set(GamepadButton.DPadLeft, dPadLeft); - mappedButtons.set(GamepadButton.DPadRight, dPadRight); - dPad.setTint(this.buttonColor); - dPad.setScale(1.75); - dPadUp.setScale(1.75); - dPadDown.setScale(1.75); - dPadLeft.setScale(1.75); - dPadRight.setScale(1.75); - container.addChild(dPad); - container.addChild(dPadUp); - container.addChild(dPadDown); - container.addChild(dPadLeft); - container.addChild(dPadRight); - container.setPosition(width / 5, height / 2); - return container; - } - createFaceButtons(width, height) { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const buttonTop = this.buttons.getFrameSprite('FaceTop'); - const buttonLeft = this.buttons.getFrameSprite('FaceLeft'); - const buttonRight = this.buttons.getFrameSprite('FaceRight'); - const buttonBottom = this.buttons.getFrameSprite('FaceBottom'); - mappedButtons.set(GamepadButton.North, buttonTop); - mappedButtons.set(GamepadButton.West, buttonLeft); - mappedButtons.set(GamepadButton.East, buttonRight); - mappedButtons.set(GamepadButton.South, buttonBottom); - buttonTop.setScale(0.75); - buttonTop.setPosition(50, 0); - buttonLeft.setScale(0.75); - buttonLeft.setPosition(0, 50); - buttonRight.setScale(0.75); - buttonRight.setPosition(100, 50); - buttonBottom.setScale(0.75); - buttonBottom.setPosition(50, 100); - container.addChild(buttonTop); - container.addChild(buttonLeft); - container.addChild(buttonRight); - container.addChild(buttonBottom); - container.setPosition(width * 0.8, height / 2); - return container; - } - createShoulderButtons(width, height) { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const leftButton = this.buttons.getFrameSprite('ShoulderLeftBottom'); - const rightButton = this.buttons.getFrameSprite('ShoulderRightBottom'); - const leftTrigger = this.buttons.getFrameSprite('ShoulderLeftTop'); - const rightTrigger = this.buttons.getFrameSprite('ShoulderRightTop'); - mappedButtons.set(GamepadButton.LeftShoulder, leftButton); - mappedButtons.set(GamepadButton.RightShoulder, rightButton); - mappedButtons.set(GamepadButton.LeftTrigger, leftTrigger); - mappedButtons.set(GamepadButton.RightTrigger, rightTrigger); - leftButton.setPosition(0, 75); - rightButton.setAnchor(0.5, 0); - rightButton.setPosition(width * 0.65, 75); - rightTrigger.setAnchor(0.5, 0); - rightTrigger.setPosition(width * 0.65, 0); - container.addChild(leftButton); - container.addChild(rightButton); - container.addChild(leftTrigger); - container.addChild(rightTrigger); - container.setPosition(width / 2, height / 5); - return container; - } - createMenuButtons(width, height) { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const selectButton = this.buttons.getFrameSprite('Select'); - const startButton = this.buttons.getFrameSprite('Start'); - mappedButtons.set(GamepadButton.Select, selectButton); - mappedButtons.set(GamepadButton.Start, startButton); - startButton.setAnchor(1, 0); - startButton.setPosition(width * 0.3, 0); - container.addChild(selectButton); - container.addChild(startButton); - container.setPosition(width / 2, height / 2); - return container; - } - createJoysticks(width, height) { - const mappedButtons = this.mappingButtons; - const mappingFunctions = this.mappingFunctions; - const container = new Container(); - const leftStick = this.buttons.getFrameSprite('LeftStick'); - const rightStick = this.buttons.getFrameSprite('RightStick'); - const startLeft = new Vector(0, 0); - const startRight = new Vector(width * 0.3, 0); - const range = 35; - mappedButtons.set(GamepadButton.LeftStick, leftStick); - mappedButtons.set(GamepadButton.RightStick, rightStick); - mappingFunctions.set(GamepadAxis.LeftStickX, value => (leftStick.x = startLeft.x + value * range)); - mappingFunctions.set(GamepadAxis.LeftStickY, value => (leftStick.y = startLeft.y + value * range)); - mappingFunctions.set(GamepadAxis.RightStickX, value => (rightStick.x = startRight.x + value * range)); - mappingFunctions.set(GamepadAxis.RightStickY, value => (rightStick.y = startRight.y + value * range)); - this.resetFunctions.push(() => { - leftStick.setPosition(startLeft.x, startLeft.y); - rightStick.setPosition(startRight.x, startRight.y); - }); - leftStick.setPosition(startLeft.x, startLeft.y); - rightStick.setPosition(startRight.x, startRight.y); - container.addChild(leftStick); - container.addChild(rightStick); - container.setPosition(width / 2, height * 0.65); - return container; + destroy() { + for (const entry of [...this.axes, ...this.buttons]) entry.binding.unbind(); + this.hud?.dispose(); + this.status?.destroy(); + this.axisText?.destroy(); + this.buttonText?.destroy(); + this.graphics.destroy(); + super.destroy(); } } const app = new Application({ scenes: { GamepadScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 18, 28), }); await app.start(GamepadScene); diff --git a/examples/input/gamepad.ts b/examples/input/gamepad.ts index f979479ee..3d13730db 100644 --- a/examples/input/gamepad.ts +++ b/examples/input/gamepad.ts @@ -1,315 +1,98 @@ import { Application, - Asset, Color, - Container, FixedResolutionCanvasSizing, type Gamepad, GamepadAxis, GamepadButton, - type InputChannel, - lerp, + Graphics, type RenderingContext, Scene, - Sprite, - Spritesheet, - type SpritesheetData, - Vector, + Text, } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; + +const AXES = [ + { name: 'Left X', channel: GamepadAxis.LeftStickX }, + { name: 'Left Y', channel: GamepadAxis.LeftStickY }, + { name: 'Right X', channel: GamepadAxis.RightStickX }, + { name: 'Right Y', channel: GamepadAxis.RightStickY }, +]; + +const BUTTONS = [ + { name: 'South', channel: GamepadButton.South }, + { name: 'East', channel: GamepadButton.East }, + { name: 'West', channel: GamepadButton.West }, + { name: 'North', channel: GamepadButton.North }, + { name: 'Left shoulder', channel: GamepadButton.LeftShoulder }, + { name: 'Right shoulder', channel: GamepadButton.RightShoulder }, + { name: 'Left trigger', channel: GamepadButton.LeftTrigger }, + { name: 'Right trigger', channel: GamepadButton.RightTrigger }, + { name: 'Start', channel: GamepadButton.Start }, + { name: 'Select', channel: GamepadButton.Select }, +]; class GamepadScene extends Scene { - private activePad: Gamepad | null = null; - private buttons!: Spritesheet; - private buttonColor = new Color(255, 255, 255, 0.25); - private mappingButtons = new Map(); - private mappingFunctions = new Map void>(); - private resetFunctions: (() => void)[] = []; - private padBindings: { unbind(): void }[] = []; - private status!: Sprite; - private container!: Container; - - override async load(): Promise { - const app = this.app; - const buttonsData = (await this.loader.load(Asset.type('json', 'json/buttons.json'))) as SpritesheetData; - - this.buttons = new Spritesheet(this.loader.get('image/buttons.png'), buttonsData); - const { width, height } = app; - this.status = this.createStatus(width, height); - this.container = this.createGamepad(width, height); - - for (const sprite of this.mappingButtons.values()) { - sprite.setTint(this.buttonColor); - } - - app.input.onGamepadConnected.add(pad => this.handleGamepadConnected(pad)); - app.input.onGamepadDisconnected.add(pad => this.handleGamepadDisconnected(pad)); + private pad!: Gamepad; + private axes: { name: string; binding: ReturnType }[] = []; + private buttons: { name: string; binding: ReturnType }[] = []; + private status!: Text; + private axisText!: Text; + private buttonText!: Text; + private graphics = new Graphics(); + private hud!: ReturnType; + + override init(): void { + this.pad = this.app.input.getGamepad(0); + this.axes = AXES.map(({ name, channel }) => ({ name, binding: this.pad.onActive(channel) })); + this.buttons = BUTTONS.map(({ name, channel }) => ({ name, binding: this.pad.onActive(channel) })); + this.status = new Text('', { fillColor: Color.white, fontSize: 23 }).setPosition(260, 130); + this.axisText = new Text('', { fillColor: new Color(125, 215, 255), fontSize: 23 }).setPosition(270, 450); + this.buttonText = new Text('', { fillColor: new Color(255, 195, 125), fontSize: 21 }).setPosition(800, 240); + this.hud = mountControls({ + title: 'Controller Inspector', + status: 'Slot 1 is waiting for a controller.', + hint: 'Axes and button values update live; disconnected state stays visible.', + }); + } - for (const pad of app.input.gamepads) { - if (pad.connected) { - this.setActivePad(pad); - break; - } - } + override update(): void { + this.status.text = this.pad.connected + ? `Slot 1: ${this.pad.info?.label ?? 'Connected controller'}` + : 'No controller connected. Attach one and press a button.'; + this.axisText.text = this.axes.map(axis => `${axis.name}: ${axis.binding.value.toFixed(2)}`).join('\n'); + this.buttonText.text = this.buttons.map(button => `${button.name}: ${button.binding.value.toFixed(2)}`).join('\n'); } override draw(context: RenderingContext): void { + this.graphics.clear(); + this.graphics.fillColor = new Color(44, 53, 67); + this.graphics.drawCircle(390, 310, 100); + this.graphics.drawCircle(650, 310, 100); + this.graphics.fillColor = new Color(100, 205, 245); + this.graphics.drawCircle(390 + this.axes[0]!.binding.value * 75, 310 + this.axes[1]!.binding.value * 75, 22); + this.graphics.drawCircle(650 + this.axes[2]!.binding.value * 75, 310 + this.axes[3]!.binding.value * 75, 22); + context.render(this.graphics); context.render(this.status); - context.render(this.container); - } - - private handleGamepadConnected(pad: Gamepad): void { - if (!this.activePad) { - this.setActivePad(pad); - } - } - - private handleGamepadDisconnected(pad: Gamepad): void { - if (this.activePad !== pad) { - return; - } - - const app = this.app; - const next = app.input.gamepads.find(other => other !== pad && other.connected) || null; - this.setActivePad(next); - } - - private setActivePad(pad: Gamepad | null): void { - for (const binding of this.padBindings) { - binding.unbind(); - } - this.padBindings.length = 0; - - this.activePad = pad; - - if (!pad) { - this.status.setTint(this.buttonColor); - this.resetVisualState(); - return; - } - - this.status.setTint(Color.white); - - for (const [channel, sprite] of this.mappingButtons.entries()) { - this.padBindings.push( - pad.onActive(channel, v => { - sprite.tint.a = lerp(0.25, 1, v); - }), - pad.onStop(channel, () => { - sprite.tint.a = this.buttonColor.a; - }), - ); - } - - for (const [channel, fn] of this.mappingFunctions.entries()) { - this.padBindings.push( - pad.onActive(channel, v => { - fn(v); - }), - pad.onStop(channel, () => { - fn(0); - }), - ); - } + context.render(this.axisText); + context.render(this.buttonText); } - private resetVisualState(): void { - for (const sprite of this.mappingButtons.values()) { - sprite.tint.a = this.buttonColor.a; - } - - for (const reset of this.resetFunctions) { - reset(); - } - } - - private createStatus(width: number, height: number): Sprite { - const status = this.buttons.getFrameSprite('status'); - - status.setAnchor(0.5); - status.setPosition(width / 2, height / 5); - status.setTint(this.buttonColor); - - return status; - } - - private createGamepad(width: number, height: number): Container { - const container = new Container(); - - container.addChild(this.createDPadField(width, height)); - container.addChild(this.createFaceButtons(width, height)); - container.addChild(this.createShoulderButtons(width, height)); - container.addChild(this.createMenuButtons(width, height)); - container.addChild(this.createJoysticks(width, height)); - - return container; - } - - private createDPadField(width: number, height: number): Container { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const dPad = this.buttons.getFrameSprite('dpad'); - const dPadUp = this.buttons.getFrameSprite('DPadUp'); - const dPadDown = this.buttons.getFrameSprite('DPadDown'); - const dPadLeft = this.buttons.getFrameSprite('DPadLeft'); - const dPadRight = this.buttons.getFrameSprite('DPadRight'); - - mappedButtons.set(GamepadButton.DPadUp, dPadUp); - mappedButtons.set(GamepadButton.DPadDown, dPadDown); - mappedButtons.set(GamepadButton.DPadLeft, dPadLeft); - mappedButtons.set(GamepadButton.DPadRight, dPadRight); - - dPad.setTint(this.buttonColor); - - dPad.setScale(1.75); - dPadUp.setScale(1.75); - dPadDown.setScale(1.75); - dPadLeft.setScale(1.75); - dPadRight.setScale(1.75); - - container.addChild(dPad); - container.addChild(dPadUp); - container.addChild(dPadDown); - container.addChild(dPadLeft); - container.addChild(dPadRight); - - container.setPosition(width / 5, height / 2); - - return container; - } - - private createFaceButtons(width: number, height: number): Container { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const buttonTop = this.buttons.getFrameSprite('FaceTop'); - const buttonLeft = this.buttons.getFrameSprite('FaceLeft'); - const buttonRight = this.buttons.getFrameSprite('FaceRight'); - const buttonBottom = this.buttons.getFrameSprite('FaceBottom'); - - mappedButtons.set(GamepadButton.North, buttonTop); - mappedButtons.set(GamepadButton.West, buttonLeft); - mappedButtons.set(GamepadButton.East, buttonRight); - mappedButtons.set(GamepadButton.South, buttonBottom); - - buttonTop.setScale(0.75); - buttonTop.setPosition(50, 0); - - buttonLeft.setScale(0.75); - buttonLeft.setPosition(0, 50); - - buttonRight.setScale(0.75); - buttonRight.setPosition(100, 50); - - buttonBottom.setScale(0.75); - buttonBottom.setPosition(50, 100); - - container.addChild(buttonTop); - container.addChild(buttonLeft); - container.addChild(buttonRight); - container.addChild(buttonBottom); - - container.setPosition(width * 0.8, height / 2); - - return container; - } - - private createShoulderButtons(width: number, height: number): Container { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const leftButton = this.buttons.getFrameSprite('ShoulderLeftBottom'); - const rightButton = this.buttons.getFrameSprite('ShoulderRightBottom'); - const leftTrigger = this.buttons.getFrameSprite('ShoulderLeftTop'); - const rightTrigger = this.buttons.getFrameSprite('ShoulderRightTop'); - - mappedButtons.set(GamepadButton.LeftShoulder, leftButton); - mappedButtons.set(GamepadButton.RightShoulder, rightButton); - mappedButtons.set(GamepadButton.LeftTrigger, leftTrigger); - mappedButtons.set(GamepadButton.RightTrigger, rightTrigger); - - leftButton.setPosition(0, 75); - - rightButton.setAnchor(0.5, 0); - rightButton.setPosition(width * 0.65, 75); - - rightTrigger.setAnchor(0.5, 0); - rightTrigger.setPosition(width * 0.65, 0); - - container.addChild(leftButton); - container.addChild(rightButton); - container.addChild(leftTrigger); - container.addChild(rightTrigger); - - container.setPosition(width / 2, height / 5); - - return container; - } - - private createMenuButtons(width: number, height: number): Container { - const mappedButtons = this.mappingButtons; - const container = new Container(); - const selectButton = this.buttons.getFrameSprite('Select'); - const startButton = this.buttons.getFrameSprite('Start'); - - mappedButtons.set(GamepadButton.Select, selectButton); - mappedButtons.set(GamepadButton.Start, startButton); - - startButton.setAnchor(1, 0); - startButton.setPosition(width * 0.3, 0); - - container.addChild(selectButton); - container.addChild(startButton); - - container.setPosition(width / 2, height / 2); - - return container; - } - - private createJoysticks(width: number, height: number): Container { - const mappedButtons = this.mappingButtons; - const mappingFunctions = this.mappingFunctions; - const container = new Container(); - const leftStick = this.buttons.getFrameSprite('LeftStick'); - const rightStick = this.buttons.getFrameSprite('RightStick'); - const startLeft = new Vector(0, 0); - const startRight = new Vector(width * 0.3, 0); - const range = 35; - - mappedButtons.set(GamepadButton.LeftStick, leftStick); - mappedButtons.set(GamepadButton.RightStick, rightStick); - - mappingFunctions.set(GamepadAxis.LeftStickX, (value: number) => (leftStick.x = startLeft.x + value * range)); - mappingFunctions.set(GamepadAxis.LeftStickY, (value: number) => (leftStick.y = startLeft.y + value * range)); - mappingFunctions.set(GamepadAxis.RightStickX, (value: number) => (rightStick.x = startRight.x + value * range)); - mappingFunctions.set(GamepadAxis.RightStickY, (value: number) => (rightStick.y = startRight.y + value * range)); - - this.resetFunctions.push(() => { - leftStick.setPosition(startLeft.x, startLeft.y); - rightStick.setPosition(startRight.x, startRight.y); - }); - - leftStick.setPosition(startLeft.x, startLeft.y); - rightStick.setPosition(startRight.x, startRight.y); - - container.addChild(leftStick); - container.addChild(rightStick); - - container.setPosition(width / 2, height * 0.65); - - return container; + override destroy(): void { + for (const entry of [...this.axes, ...this.buttons]) entry.binding.unbind(); + this.hud?.dispose(); + this.status?.destroy(); + this.axisText?.destroy(); + this.buttonText?.destroy(); + this.graphics.destroy(); + super.destroy(); } } const app = new Application({ scenes: { GamepadScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 18, 28), }); await app.start(GamepadScene); diff --git a/examples/input/mouse-and-pointer.js b/examples/input/mouse-and-pointer.js index 7aaed87c4..5b39cf615 100644 --- a/examples/input/mouse-and-pointer.js +++ b/examples/input/mouse-and-pointer.js @@ -25,6 +25,7 @@ class MouseAndPointerScene extends Scene { 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.root.addChild(this.ship); this.crosshair = new Graphics(); app.input.onPointerMove.add(pointer => { this.pointer.x = pointer.x; @@ -65,7 +66,7 @@ class MouseAndPointerScene extends Scene { ); } draw(context) { - context.render(this.ship); + context.render(this.root); this.crosshair.clear(); this.crosshair.lineWidth = 2; this.crosshair.lineColor = new Color(255, 220, 80); diff --git a/examples/input/mouse-and-pointer.ts b/examples/input/mouse-and-pointer.ts index c59bf338b..92ae6f0b1 100644 --- a/examples/input/mouse-and-pointer.ts +++ b/examples/input/mouse-and-pointer.ts @@ -28,6 +28,7 @@ class MouseAndPointerScene extends Scene { 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.root.addChild(this.ship); this.crosshair = new Graphics(); app.input.onPointerMove.add(pointer => { @@ -75,7 +76,7 @@ class MouseAndPointerScene extends Scene { } override draw(context: RenderingContext): void { - context.render(this.ship); + context.render(this.root); this.crosshair.clear(); this.crosshair.lineWidth = 2; diff --git a/examples/input/pointer-to-world.js b/examples/input/pointer-to-world.js index 1c4e67fc3..1a889388f 100644 --- a/examples/input/pointer-to-world.js +++ b/examples/input/pointer-to-world.js @@ -68,7 +68,6 @@ class PointerToWorldScene extends Scene { const centerY = height / 2 + Math.sin(this.elapsed * 1.0) * 140; this.view.setCenter(centerX, centerY); this.view.setZoom(this.userZoom * (1 + Math.sin(this.elapsed * 0.35) * 0.25)); - this.view.update(delta * 1000); // Live world coordinate under the cursor - recomputed every frame because // the mapping changes as the camera moves. this.world = this.view.screenToWorld(this.cursor.x, this.cursor.y); @@ -77,8 +76,7 @@ class PointerToWorldScene extends Scene { ); } draw(context) { - context.backend.setView(this.view); - context.render(this.grid); + context.render(this.grid, { view: this.view }); // Rebuild markers each frame in their fixed world positions. this.markers.clear(); this.markers.fillColor = new Color(255, 160, 80); @@ -88,8 +86,7 @@ class PointerToWorldScene extends Scene { // Highlight the live cursor→world point. this.markers.fillColor = new Color(120, 230, 255); this.markers.drawCircle(this.world.x, this.world.y, 5); - context.render(this.markers); - context.backend.setView(null); + context.render(this.markers, { view: this.view }); } } const app = new Application({ diff --git a/examples/input/pointer-to-world.ts b/examples/input/pointer-to-world.ts index 1527351ea..0bf7860d4 100644 --- a/examples/input/pointer-to-world.ts +++ b/examples/input/pointer-to-world.ts @@ -82,7 +82,6 @@ class PointerToWorldScene extends Scene { this.view.setCenter(centerX, centerY); this.view.setZoom(this.userZoom * (1 + Math.sin(this.elapsed * 0.35) * 0.25)); - this.view.update(delta * 1000); // Live world coordinate under the cursor - recomputed every frame because // the mapping changes as the camera moves. @@ -94,9 +93,7 @@ class PointerToWorldScene extends Scene { } override draw(context: RenderingContext): void { - context.backend.setView(this.view); - - context.render(this.grid); + context.render(this.grid, { view: this.view }); // Rebuild markers each frame in their fixed world positions. this.markers.clear(); @@ -110,8 +107,7 @@ class PointerToWorldScene extends Scene { this.markers.fillColor = new Color(120, 230, 255); this.markers.drawCircle(this.world.x, this.world.y, 5); - context.render(this.markers); - context.backend.setView(null); + context.render(this.markers, { view: this.view }); } } diff --git a/examples/lighting/many-lights.js b/examples/lighting/many-lights.js index e38724c97..2bd75f603 100644 --- a/examples/lighting/many-lights.js +++ b/examples/lighting/many-lights.js @@ -76,8 +76,23 @@ class ManyLightsScene extends Scene { lighting; orbits; visibleLights = 24; + draggingFirst = false; + manualFirst = true; elapsed = 0; hud; + onPointerDown = (_pointer, x, y) => { + const marker = this.orbits[0]?.marker; + this.draggingFirst = !!marker && Math.hypot(x - marker.x, y - marker.y) < 36; + if (this.draggingFirst) this.manualFirst = true; + }; + onPointerMove = (_pointer, x, y) => { + if (!this.draggingFirst) return; + this.orbits[0].light.setPosition(x, y); + this.orbits[0].marker.setPosition(x, y); + }; + onPointerEnd = () => { + this.draggingFirst = false; + }; init() { const { width, height } = this.app; this.floor = new Container(); @@ -96,7 +111,7 @@ class ManyLightsScene extends Scene { this.orbits = Array.from({ length: MAX_LIGHTS }, (_, index) => { const color = lightColor(index); const light = new PointLight({ radius: 190, intensity: 1.6, height: 46, color }); - const marker = new Sprite(Texture.fromColor(color, 6)).setAnchor(0.5); + const marker = new Sprite(Texture.fromColor(color, index === 0 ? 32 : 6)).setAnchor(0.5); this.markerLayer.addChild(marker); return { light, @@ -107,10 +122,12 @@ class ManyLightsScene extends Scene { radiusY: height * (0.16 + ((index % 3) / 3) * 0.3), }; }); + this.orbits[0].light.setPosition(width * 0.25, height / 2); + this.orbits[0].marker.setPosition(width * 0.25, height / 2); this.setVisibleLights(this.visibleLights); this.hud = mountControls({ - title: 'Many Lights (forward renderer)', - hint: `Up to ${MAX_LIGHTS} point lights over ${this.floor.children.length} tiles, all in one batch. The light list is a data texture, so the count is a loop bound - not a recompile. No shadows and no transport here: this shows what the forward renderer costs, not what the package can do.`, + title: 'Normal-Mapped Lighting', + hint: `Drag the large light marker across the normal-mapped tiles or change the count. Up to ${MAX_LIGHTS} lights share one material; this forward-lighting scene has no shadows.`, status: '', }); const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); @@ -122,6 +139,11 @@ class ManyLightsScene extends Scene { value: this.visibleLights, onChange: value => this.setVisibleLights(value), }); + panel.addButton({ label: 'Resume first orbit', onClick: () => (this.manualFirst = false) }); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); } setVisibleLights(count) { this.visibleLights = count; @@ -137,6 +159,7 @@ class ManyLightsScene extends Scene { const { width, height } = this.app; this.elapsed += delta; for (let index = 0; index < this.visibleLights; index++) { + if (index === 0 && this.manualFirst) continue; const orbit = this.orbits[index]; const angle = this.elapsed * orbit.speed + orbit.phase; const x = width / 2 + Math.cos(angle) * orbit.radiusX; @@ -150,6 +173,14 @@ class ManyLightsScene extends Scene { context.render(this.markerLayer); this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); } + destroy() { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { ManyLightsScene }, diff --git a/examples/lighting/many-lights.ts b/examples/lighting/many-lights.ts index 1c9c7cf44..8f5fafde8 100644 --- a/examples/lighting/many-lights.ts +++ b/examples/lighting/many-lights.ts @@ -103,8 +103,23 @@ class ManyLightsScene extends Scene { private lighting!: Lighting; private orbits!: Orbit[]; private visibleLights = 24; + private draggingFirst = false; + private manualFirst = true; private elapsed = 0; private hud!: ReturnType; + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + const marker = this.orbits[0]?.marker; + this.draggingFirst = !!marker && Math.hypot(x - marker.x, y - marker.y) < 36; + if (this.draggingFirst) this.manualFirst = true; + }; + private readonly onPointerMove = (_pointer: unknown, x: number, y: number): void => { + if (!this.draggingFirst) return; + this.orbits[0]!.light.setPosition(x, y); + this.orbits[0]!.marker.setPosition(x, y); + }; + private readonly onPointerEnd = (): void => { + this.draggingFirst = false; + }; override init(): void { const { width, height } = this.app; @@ -128,7 +143,7 @@ class ManyLightsScene extends Scene { this.orbits = Array.from({ length: MAX_LIGHTS }, (_, index) => { const color = lightColor(index); const light = new PointLight({ radius: 190, intensity: 1.6, height: 46, color }); - const marker = new Sprite(Texture.fromColor(color, 6)).setAnchor(0.5); + const marker = new Sprite(Texture.fromColor(color, index === 0 ? 32 : 6)).setAnchor(0.5); this.markerLayer.addChild(marker); @@ -142,11 +157,13 @@ class ManyLightsScene extends Scene { }; }); + this.orbits[0]!.light.setPosition(width * 0.25, height / 2); + this.orbits[0]!.marker.setPosition(width * 0.25, height / 2); this.setVisibleLights(this.visibleLights); this.hud = mountControls({ - title: 'Many Lights (forward renderer)', - hint: `Up to ${MAX_LIGHTS} point lights over ${this.floor.children.length} tiles, all in one batch. The light list is a data texture, so the count is a loop bound - not a recompile. No shadows and no transport here: this shows what the forward renderer costs, not what the package can do.`, + title: 'Normal-Mapped Lighting', + hint: `Drag the large light marker across the normal-mapped tiles or change the count. Up to ${MAX_LIGHTS} lights share one material; this forward-lighting scene has no shadows.`, status: '', }); @@ -160,6 +177,12 @@ class ManyLightsScene extends Scene { value: this.visibleLights, onChange: value => this.setVisibleLights(value), }); + panel.addButton({ label: 'Resume first orbit', onClick: () => (this.manualFirst = false) }); + + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); } private setVisibleLights(count: number): void { @@ -181,6 +204,7 @@ class ManyLightsScene extends Scene { this.elapsed += delta; for (let index = 0; index < this.visibleLights; index++) { + if (index === 0 && this.manualFirst) continue; const orbit = this.orbits[index]!; const angle = this.elapsed * orbit.speed + orbit.phase; const x = width / 2 + Math.cos(angle) * orbit.radiusX; @@ -196,6 +220,15 @@ class ManyLightsScene extends Scene { context.render(this.markerLayer); this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); } + + override destroy(): void { + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/particles/bonfire.js b/examples/particles/bonfire.js index 862677163..e86cfc0d2 100644 --- a/examples/particles/bonfire.js +++ b/examples/particles/bonfire.js @@ -1,6 +1,7 @@ // Auto-generated from bonfire.ts - edit the .ts source, not this file. -import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Scene } from '@codexo/exojs'; +import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; import { + ApplyForce, ColorGradient, ColorOverLifetime, ConeDirection, @@ -11,20 +12,38 @@ import { RateSpawn, VectorRange, } from '@codexo/exojs-particles'; +import { mountControlPanel, mountControls } from '@examples/runtime'; class BonfireScene extends Scene { fireSystem; smokeSystem; + ground = new Graphics(); + fireRate = new Constant(50); + wind = new ApplyForce(0, 0); + smokeWind = new ApplyForce(0, 0); + panel; + hud; init() { const app = this.app; const { width, height } = app; + const centerX = width / 2; + const fireY = height * 0.75; + this.ground.fillColor = new Color(31, 42, 36); + this.ground.drawRectangle(0, fireY + 75, width, height - fireY - 75); + this.ground.fillColor = new Color(255, 143, 59, 0.18); + this.ground.drawCircle(centerX, fireY + 25, 155); + this.ground.fillColor = new Color(91, 90, 80); + for (const x of [-125, -80, 80, 125]) this.ground.drawCircle(centerX + x, fireY + 28, 25); + this.ground.fillColor = new Color(92, 54, 35); + this.ground.drawPolygon([centerX - 110, fireY + 35, centerX - 98, fireY + 4, centerX + 105, fireY + 43, centerX + 95, fireY + 70]); + this.ground.drawPolygon([centerX - 105, fireY + 45, centerX + 97, fireY + 5, centerX + 110, fireY + 35, centerX - 95, fireY + 72]); this.fireSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleFlame)); this.systems.add(this.fireSystem); this.fireSystem.setPosition(width * 0.5, height * 0.75); this.fireSystem.setBlendMode(BlendModes.Additive); this.fireSystem.addSpawnModule( new RateSpawn({ - rate: new Constant(50), - lifetime: new Range(5, 10), + rate: this.fireRate, + lifetime: new Range(0.6, 1.2), position: new VectorRange(-50, 50, -10, 10), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80), }), @@ -32,11 +51,12 @@ class BonfireScene extends Scene { this.fireSystem.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(194, 64, 30, 1) }, - { t: 1, color: new Color(0, 0, 0, 0) }, + { t: 0, color: new Color(255, 218, 95, 1) }, + { t: 1, color: new Color(206, 53, 26, 0) }, ]), ), ); + this.fireSystem.addUpdateModule(this.wind); this.smokeSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleSmoke)); this.systems.add(this.smokeSystem); this.smokeSystem.setPosition(width * 0.5, height * 0.75 - 40); @@ -44,7 +64,7 @@ class BonfireScene extends Scene { this.smokeSystem.addSpawnModule( new RateSpawn({ rate: new Constant(8), - lifetime: new Range(8, 14), + lifetime: new Range(2.5, 4), position: new VectorRange(-30, 30, -5, 5), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 12, 20, 35), }), @@ -57,11 +77,46 @@ class BonfireScene extends Scene { ]), ), ); + this.smokeSystem.addUpdateModule(this.smokeWind); + this.hud = mountControls({ + title: 'Campfire', + status: 'Flame and smoke respond to wind and intensity.', + hint: 'The logs and stones give the particles a visible source.', + }); + this.panel = mountControlPanel({ title: 'Campfire' }); + this.panel.addSlider({ + label: 'Intensity', + min: 20, + max: 100, + step: 1, + value: 50, + onChange: value => { + this.fireRate.value = value; + }, + }); + this.panel.addSlider({ + label: 'Wind', + min: -180, + max: 180, + step: 5, + value: 0, + onChange: value => { + this.wind.accelerationX = value; + this.smokeWind.accelerationX = value; + }, + }); } draw(context) { + context.render(this.ground); context.render(this.smokeSystem); context.render(this.fireSystem); } + destroy() { + this.panel?.dispose(); + this.hud?.dispose(); + this.ground.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { BonfireScene }, diff --git a/examples/particles/bonfire.ts b/examples/particles/bonfire.ts index 7647647e0..45c4f8911 100644 --- a/examples/particles/bonfire.ts +++ b/examples/particles/bonfire.ts @@ -1,5 +1,6 @@ -import { Application, BlendModes, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene } from '@codexo/exojs'; +import { Application, BlendModes, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; import { + ApplyForce, ColorGradient, ColorOverLifetime, ConeDirection, @@ -10,14 +11,33 @@ import { RateSpawn, VectorRange, } from '@codexo/exojs-particles'; +import { mountControlPanel, mountControls } from '@examples/runtime'; class BonfireScene extends Scene { private fireSystem!: ParticleSystem; private smokeSystem!: ParticleSystem; + private ground = new Graphics(); + private fireRate = new Constant(50); + private wind = new ApplyForce(0, 0); + private smokeWind = new ApplyForce(0, 0); + private panel!: ReturnType; + private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; + const centerX = width / 2; + const fireY = height * 0.75; + + this.ground.fillColor = new Color(31, 42, 36); + this.ground.drawRectangle(0, fireY + 75, width, height - fireY - 75); + this.ground.fillColor = new Color(255, 143, 59, 0.18); + this.ground.drawCircle(centerX, fireY + 25, 155); + this.ground.fillColor = new Color(91, 90, 80); + for (const x of [-125, -80, 80, 125]) this.ground.drawCircle(centerX + x, fireY + 28, 25); + this.ground.fillColor = new Color(92, 54, 35); + this.ground.drawPolygon([centerX - 110, fireY + 35, centerX - 98, fireY + 4, centerX + 105, fireY + 43, centerX + 95, fireY + 70]); + this.ground.drawPolygon([centerX - 105, fireY + 45, centerX + 97, fireY + 5, centerX + 110, fireY + 35, centerX - 95, fireY + 72]); this.fireSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleFlame)); this.systems.add(this.fireSystem); @@ -26,8 +46,8 @@ class BonfireScene extends Scene { this.fireSystem.addSpawnModule( new RateSpawn({ - rate: new Constant(50), - lifetime: new Range(5, 10), + rate: this.fireRate, + lifetime: new Range(0.6, 1.2), position: new VectorRange(-50, 50, -10, 10), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80), }), @@ -36,11 +56,12 @@ class BonfireScene extends Scene { this.fireSystem.addUpdateModule( new ColorOverLifetime( new ColorGradient([ - { t: 0, color: new Color(194, 64, 30, 1) }, - { t: 1, color: new Color(0, 0, 0, 0) }, + { t: 0, color: new Color(255, 218, 95, 1) }, + { t: 1, color: new Color(206, 53, 26, 0) }, ]), ), ); + this.fireSystem.addUpdateModule(this.wind); this.smokeSystem = new ParticleSystem(this.loader.get(assets.demo.textures.particleSmoke)); this.systems.add(this.smokeSystem); @@ -50,7 +71,7 @@ class BonfireScene extends Scene { this.smokeSystem.addSpawnModule( new RateSpawn({ rate: new Constant(8), - lifetime: new Range(8, 14), + lifetime: new Range(2.5, 4), position: new VectorRange(-30, 30, -5, 5), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 12, 20, 35), }), @@ -64,12 +85,49 @@ class BonfireScene extends Scene { ]), ), ); + this.smokeSystem.addUpdateModule(this.smokeWind); + + this.hud = mountControls({ + title: 'Campfire', + status: 'Flame and smoke respond to wind and intensity.', + hint: 'The logs and stones give the particles a visible source.', + }); + this.panel = mountControlPanel({ title: 'Campfire' }); + this.panel.addSlider({ + label: 'Intensity', + min: 20, + max: 100, + step: 1, + value: 50, + onChange: value => { + this.fireRate.value = value; + }, + }); + this.panel.addSlider({ + label: 'Wind', + min: -180, + max: 180, + step: 5, + value: 0, + onChange: value => { + this.wind.accelerationX = value; + this.smokeWind.accelerationX = value; + }, + }); } override draw(context: RenderingContext): void { + context.render(this.ground); context.render(this.smokeSystem); context.render(this.fireSystem); } + + override destroy(): void { + this.panel?.dispose(); + this.hud?.dispose(); + this.ground.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/particles/gpu-particles.js b/examples/particles/gpu-particles.js index 513203cfe..79024e4a3 100644 --- a/examples/particles/gpu-particles.js +++ b/examples/particles/gpu-particles.js @@ -1,7 +1,7 @@ // Auto-generated from gpu-particles.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, Scene, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // WebGPU runs the whole simulation on a compute shader, so it sustains hundreds // of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so // it uses a much smaller budget to stay at a comfortable frame rate. Both stay @@ -14,6 +14,8 @@ class GpuParticlesScene extends Scene { system; hud; capacity = 0; + spawnRate; + frameMs = 0; init() { const app = this.app; const { width, height } = app; @@ -24,12 +26,13 @@ class GpuParticlesScene extends Scene { const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu; const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2; this.capacity = capacity; + this.spawnRate = new Constant(rate); this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( new RateSpawn({ - rate: new Constant(rate), + rate: this.spawnRate, lifetime: new Range(2.6, 3.8), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340), scale: new Constant(new Vector(0.22, 0.22)), @@ -38,15 +41,28 @@ class GpuParticlesScene extends Scene { this.system.addUpdateModule(new ApplyForce(0, 320)); this.system.addUpdateModule(new AlphaFadeOverLifetime()); this.hud = mountControls({ - title: 'GPU Particles', + title: 'Particle Capacity', hint: isWebGpu ? 'WebGPU compute simulation — hundreds of thousands of particles, no CPU per-particle work.' : 'WebGL2 CPU fallback — a smaller budget keeps the CPU integrator smooth.', }); + mountControlPanel({ title: 'Load' }).addSlider({ + label: 'Spawn / second', + min: 0, + max: rate, + step: isWebGpu ? 5000 : 250, + value: rate, + onChange: value => { + this.spawnRate.value = value; + }, + }); } - update(_delta) { + update(delta) { const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)'; - this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`); + this.frameMs = this.frameMs * 0.9 + delta * 1000 * 0.1; + this.hud.setStatus( + `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${this.spawnRate.value.toLocaleString()}/s · ${this.frameMs.toFixed(1)} ms/frame · ${backend}`, + ); } draw(context) { context.render(this.system); diff --git a/examples/particles/gpu-particles.ts b/examples/particles/gpu-particles.ts index b1df61bb2..0eaa40a59 100644 --- a/examples/particles/gpu-particles.ts +++ b/examples/particles/gpu-particles.ts @@ -1,6 +1,6 @@ import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, type RenderingContext, Scene, type Seconds, Vector } from '@codexo/exojs'; import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // WebGPU runs the whole simulation on a compute shader, so it sustains hundreds // of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so @@ -15,6 +15,8 @@ class GpuParticlesScene extends Scene { private system!: ParticleSystem; private hud!: ReturnType; private capacity = 0; + private spawnRate!: Constant; + private frameMs = 0; override init(): void { const app = this.app; @@ -27,12 +29,13 @@ class GpuParticlesScene extends Scene { const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2; this.capacity = capacity; + this.spawnRate = new Constant(rate); this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( new RateSpawn({ - rate: new Constant(rate), + rate: this.spawnRate, lifetime: new Range(2.6, 3.8), velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340), scale: new Constant(new Vector(0.22, 0.22)), @@ -42,17 +45,29 @@ class GpuParticlesScene extends Scene { this.system.addUpdateModule(new AlphaFadeOverLifetime()); this.hud = mountControls({ - title: 'GPU Particles', + title: 'Particle Capacity', hint: isWebGpu ? 'WebGPU compute simulation — hundreds of thousands of particles, no CPU per-particle work.' : 'WebGL2 CPU fallback — a smaller budget keeps the CPU integrator smooth.', }); + mountControlPanel({ title: 'Load' }).addSlider({ + label: 'Spawn / second', + min: 0, + max: rate, + step: isWebGpu ? 5000 : 250, + value: rate, + onChange: value => { + this.spawnRate.value = value; + }, + }); } - override update(_delta: Seconds): void { + override update(delta: Seconds): void { const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)'; - - this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`); + this.frameMs = this.frameMs * 0.9 + delta * 1000 * 0.1; + this.hud.setStatus( + `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${this.spawnRate.value.toLocaleString()}/s · ${this.frameMs.toFixed(1)} ms/frame · ${backend}`, + ); } override draw(context: RenderingContext): void { diff --git a/examples/pathfinding/tilemap-navigation.js b/examples/pathfinding/tilemap-navigation.js index de781ca75..7232ab408 100644 --- a/examples/pathfinding/tilemap-navigation.js +++ b/examples/pathfinding/tilemap-navigation.js @@ -56,7 +56,9 @@ class TilemapNavigationScene extends Scene { agent = { x: 1.5 * TILE, y: 1.5 * TILE }; waypoint = 0; avoidRough = true; + editing = false; hud; + panel; async load() { const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); const tileset = new TileSet({ @@ -78,24 +80,18 @@ class TilemapNavigationScene extends Scene { this.worldRoot = new Container(); this.worldRoot.addChild(this.mapView.band('ground')); this.buildGrid(); - this.app.input.onPointerTap.add(pointer => { - const x = Math.floor(pointer.x / TILE); - const y = Math.floor(pointer.y / TILE); - if (this.grid.nodeAt(x, y) < 0) return; - this.goal = { x, y }; - this.replan(); - }); + this.app.input.onPointerTap.add(this.onTap); this.hud = mountControls({ title: 'Tilemap Navigation', controls: [ { keys: 'Click', action: 'send the agent to a tile' }, - { keys: 'panel', action: 'terrain cost / carve a door' }, + { keys: 'Edit walls + Click', action: 'open or close the selected tile' }, ], status: '', hint: 'The grid is built from a cost callback over the tile layer — the pathfinding package never sees the tilemap.', }); - const panel = mountControlPanel({ title: 'Navigation' }); - panel.addToggle({ + this.panel = mountControlPanel({ title: 'Navigation' }); + this.panel.addToggle({ label: 'Rough ground costs more', value: true, onChange: value => { @@ -104,9 +100,12 @@ class TilemapNavigationScene extends Scene { this.replan(); }, }); - panel.addButton({ - label: 'Carve a door in the next wall', - onClick: () => this.carveDoor(), + this.panel.addToggle({ + label: 'Edit walls', + value: false, + onChange: value => { + this.editing = value; + }, }); this.replan(); } @@ -161,19 +160,23 @@ class TilemapNavigationScene extends Scene { { cellSize: TILE }, ); } - /** Edits map and grid together, which is what `setCost` and `revision` exist for. */ - carveDoor() { - for (let x = 1; x < COLUMNS - 1; x++) { - for (let y = 1; y < ROWS - 1; y++) { - if (this.layer.getTileAt(x, y)?.localTileId !== WALL_TILE) continue; - const tileset = this.layer.tilesets[0]; - this.layer.setTileAt(x, y, { tileset, localTileId: FLOOR_TILE, transform: TILE_TRANSFORM_IDENTITY }); - this.grid.setCost(x, y, 1); - this.replan(); - return; - } + onTap = pointer => { + const x = Math.floor(pointer.x / TILE); + const y = Math.floor(pointer.y / TILE); + if (x < 0 || x >= COLUMNS || y < 0 || y >= ROWS) return; + if (this.editing) { + if (isBorder(x, y)) return; + const tile = this.layer.getTileAt(x, y); + const blocked = tile?.localTileId === WALL_TILE; + const tileset = this.layer.tilesets[0]; + this.layer.setTileAt(x, y, { tileset, localTileId: blocked ? FLOOR_TILE : WALL_TILE, transform: TILE_TRANSFORM_IDENTITY }); + this.grid.setCost(x, y, blocked ? 1 : 0); + } else { + if (this.grid.nodeAt(x, y) < 0) return; + this.goal = { x, y }; } - } + this.replan(); + }; replan() { this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, { smooth: true, @@ -185,6 +188,14 @@ class TilemapNavigationScene extends Scene { `${status} · cost ${cost.toFixed(1)} · ${expandedNodes} nodes expanded · grid revision ${this.grid.revision} · ${this.grid.uniformCost ? 'jump-point' : 'weighted A*'}`, ); } + destroy() { + this.app.input.onPointerTap.remove(this.onTap); + this.panel?.dispose(); + this.hud?.dispose(); + this.worldRoot?.destroy(); + this.overlay.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { TilemapNavigationScene }, diff --git a/examples/pathfinding/tilemap-navigation.ts b/examples/pathfinding/tilemap-navigation.ts index 5588479a8..8bc6ca6a1 100644 --- a/examples/pathfinding/tilemap-navigation.ts +++ b/examples/pathfinding/tilemap-navigation.ts @@ -75,7 +75,9 @@ class TilemapNavigationScene extends Scene { private agent = { x: 1.5 * TILE, y: 1.5 * TILE }; private waypoint = 0; private avoidRough = true; + private editing = false; private hud!: ReturnType; + private panel!: ReturnType; override async load(): Promise { const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); @@ -104,29 +106,21 @@ class TilemapNavigationScene extends Scene { this.buildGrid(); - this.app.input.onPointerTap.add(pointer => { - const x = Math.floor(pointer.x / TILE); - const y = Math.floor(pointer.y / TILE); - - if (this.grid.nodeAt(x, y) < 0) return; - - this.goal = { x, y }; - this.replan(); - }); + this.app.input.onPointerTap.add(this.onTap); this.hud = mountControls({ title: 'Tilemap Navigation', controls: [ { keys: 'Click', action: 'send the agent to a tile' }, - { keys: 'panel', action: 'terrain cost / carve a door' }, + { keys: 'Edit walls + Click', action: 'open or close the selected tile' }, ], status: '', hint: 'The grid is built from a cost callback over the tile layer — the pathfinding package never sees the tilemap.', }); - const panel = mountControlPanel({ title: 'Navigation' }); + this.panel = mountControlPanel({ title: 'Navigation' }); - panel.addToggle({ + this.panel.addToggle({ label: 'Rough ground costs more', value: true, onChange: value => { @@ -135,9 +129,12 @@ class TilemapNavigationScene extends Scene { this.replan(); }, }); - panel.addButton({ - label: 'Carve a door in the next wall', - onClick: () => this.carveDoor(), + this.panel.addToggle({ + label: 'Edit walls', + value: false, + onChange: value => { + this.editing = value; + }, }); this.replan(); @@ -208,22 +205,24 @@ class TilemapNavigationScene extends Scene { ); } - /** Edits map and grid together, which is what `setCost` and `revision` exist for. */ - private carveDoor(): void { - for (let x = 1; x < COLUMNS - 1; x++) { - for (let y = 1; y < ROWS - 1; y++) { - if (this.layer.getTileAt(x, y)?.localTileId !== WALL_TILE) continue; - - const tileset = this.layer.tilesets[0]!; - - this.layer.setTileAt(x, y, { tileset, localTileId: FLOOR_TILE, transform: TILE_TRANSFORM_IDENTITY }); - this.grid.setCost(x, y, 1); - this.replan(); - - return; - } + private readonly onTap = (pointer: { x: number; y: number }): void => { + const x = Math.floor(pointer.x / TILE); + const y = Math.floor(pointer.y / TILE); + if (x < 0 || x >= COLUMNS || y < 0 || y >= ROWS) return; + + if (this.editing) { + if (isBorder(x, y)) return; + const tile = this.layer.getTileAt(x, y); + const blocked = tile?.localTileId === WALL_TILE; + const tileset = this.layer.tilesets[0]!; + this.layer.setTileAt(x, y, { tileset, localTileId: blocked ? FLOOR_TILE : WALL_TILE, transform: TILE_TRANSFORM_IDENTITY }); + this.grid.setCost(x, y, blocked ? 1 : 0); + } else { + if (this.grid.nodeAt(x, y) < 0) return; + this.goal = { x, y }; } - } + this.replan(); + }; private replan(): void { this.result = this.pathfinder.findPathBetween(this.grid, this.agent.x, this.agent.y, (this.goal.x + 0.5) * TILE, (this.goal.y + 0.5) * TILE, { @@ -238,6 +237,15 @@ class TilemapNavigationScene extends Scene { `${status} · cost ${cost.toFixed(1)} · ${expandedNodes} nodes expanded · grid revision ${this.grid.revision} · ${this.grid.uniformCost ? 'jump-point' : 'weighted A*'}`, ); } + + override destroy(): void { + this.app.input.onPointerTap.remove(this.onTap); + this.panel?.dispose(); + this.hud?.dispose(); + this.worldRoot?.destroy(); + this.overlay.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/performance/backend-comparison.js b/examples/performance/backend-comparison.js index ce2ed98f0..e45f07d0e 100644 --- a/examples/performance/backend-comparison.js +++ b/examples/performance/backend-comparison.js @@ -1,81 +1,132 @@ // Auto-generated from backend-comparison.ts - edit the .ts source, not this file. -import { Application, Capabilities, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; +import { Application, Capabilities, Color, Container, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; -const options = { - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, -}; -let app = null; -let overlay = null; +import { mountControls } from '@examples/runtime'; +const MAX_SPRITES = 2400; +const COUNT_STEP = 400; +let spriteCount = 800; +let textureCount = 1; let backendType = 'webgl2'; let webGpuAvailable = false; -class DemoScene extends Scene { - sprites; +let app = null; +let overlay = null; +let booting = false; +const showStartupError = error => { + const message = document.createElement('p'); + message.textContent = `Could not start the renderer: ${String(error)}`; + document.body.append(message); +}; +const makeTexture = () => { + const canvas = document.createElement('canvas'); + canvas.width = 48; + canvas.height = 48; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#19324b'; + ctx.fillRect(0, 0, 48, 48); + ctx.fillStyle = '#79d8ff'; + ctx.beginPath(); + ctx.arc(24, 24, 17, 0, Math.PI * 2); + ctx.fill(); + return new Texture(canvas); +}; +class BatchingScene extends Scene { + layer; + sprites = []; + textures = []; + hud; init() { - const app = this.app; - const { width, height } = app; - this.sprites = Array.from({ length: 2200 }, () => { - 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); - return { - sprite, - vx: (Math.random() - 0.5) * 180, - vy: (Math.random() - 0.5) * 180, - }; + this.layer = new Container(); + this.addChild(this.layer); + this.textures = Array.from({ length: 4 }, () => makeTexture()); + for (let i = 0; i < MAX_SPRITES; i++) { + const sprite = new Sprite(this.textures[i % textureCount]) + .setAnchor(0.5) + .setScale(0.42) + .setPosition((i * 97) % this.app.width, (i * 193) % this.app.height); + sprite.visible = i < spriteCount; + this.layer.addChild(sprite); + this.sprites.push(sprite); + } + this.hud = mountControls({ + title: 'Sprite Batching Laboratory', + corner: 'top-right', + controls: [ + { keys: 'Up / Down', action: 'change visible sprite count' }, + { keys: 'T', action: 'use one or four textures' }, + { keys: 'B', action: 'switch WebGL2 / WebGPU' }, + ], + hint: 'The seeded positions and motion stay the same across settings. The overlay reports this workload, not a general backend benchmark.', + }); + this.updateHud(); + this.inputs.onTrigger(Keyboard.Up, () => this.setCount(Math.min(MAX_SPRITES, spriteCount + COUNT_STEP))); + this.inputs.onTrigger(Keyboard.Down, () => this.setCount(Math.max(COUNT_STEP, spriteCount - COUNT_STEP))); + this.inputs.onTrigger(Keyboard.T, () => { + textureCount = textureCount === 1 ? 4 : 1; + this.sprites.forEach((sprite, index) => sprite.setTexture(this.textures[index % textureCount])); + this.updateHud(); }); this.inputs.onTrigger(Keyboard.B, () => { - // Without an adapter the WebGPU backend produces no context and the - // canvas stays blank, so the comparison only toggles where both - // backends can actually run. - if (!webGpuAvailable) return; - backendType = backendType === 'webgpu' ? 'webgl2' : 'webgpu'; - boot(backendType); + if (!webGpuAvailable) { + this.hud.setStatus('WebGPU adapter unavailable; using WebGL2'); + return; + } + void boot(backendType === 'webgpu' ? 'webgl2' : 'webgpu').catch(showStartupError); + }); + } + setCount(next) { + spriteCount = next; + this.sprites.forEach((sprite, index) => { + sprite.visible = index < spriteCount; }); + this.updateHud(); + } + updateHud() { + this.hud.setStatus(`${backendType} · ${spriteCount} sprites · ${textureCount} texture${textureCount === 1 ? '' : 's'}`); } - update(delta) { - const app = this.app; - const { width, height } = app; - for (const item of this.sprites) { - item.sprite.move(item.vx * delta, item.vy * delta); - if (item.sprite.position.x < 0 || item.sprite.position.x > width) item.vx *= -1; - if (item.sprite.position.y < 0 || item.sprite.position.y > height) item.vy *= -1; + update(_delta) { + const time = this.app.activeSeconds; + for (let i = 0; i < spriteCount; i++) { + const sprite = this.sprites[i]; + sprite.setPosition(((i * 97) % this.app.width) + Math.sin(time + i * 0.13) * 12, ((i * 193) % this.app.height) + Math.cos(time + i * 0.17) * 12); + sprite.rotation = time * ((i % 2 === 0 ? 1 : -1) * 25); } } draw(context) { - for (const { sprite } of this.sprites) context.render(sprite); + context.render(this.layer); + } + destroy() { + this.hud.dispose(); + super.destroy(); + this.textures.forEach(texture => texture.destroy()); } } -const boot = type => { - if (overlay !== null) { - overlay.destroy(); +const boot = async type => { + if (booting) return; + booting = true; + try { + overlay?.destroy(); overlay = null; + if (app) { + const previous = app; + app = null; + await previous.destroy(); + previous.element?.remove(); + } + backendType = type; + app = new Application({ + scenes: { BatchingScene }, + backend: { type }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(8, 12, 20), + }); + overlay = new DebugOverlay(app); + overlay.layers.performance.visible = true; + await app.start(BatchingScene); + } finally { + booting = false; } - if (app !== null) { - void app.destroy(); - app.element?.remove(); - app = null; - } - app = new Application({ ...options, scenes: { DemoScene }, backend: { type } }); - overlay = new DebugOverlay(app); - overlay.layers.performance.visible = true; - void app.start(DemoScene); }; -// The adapter query is async, so the first boot waits for it: starting on -// WebGPU where no adapter exists leaves the canvas blank with no error. -// `webgpu` only reports the API surface - a browser can expose `navigator.gpu` -// and still hand out no adapter, so the adapter itself is the deciding fact. void Capabilities.ready.then(capabilities => { webGpuAvailable = capabilities.webgpuAdapter !== null; - backendType = webGpuAvailable ? 'webgpu' : 'webgl2'; - boot(backendType); + void boot(webGpuAvailable ? 'webgpu' : 'webgl2').catch(showStartupError); }); diff --git a/examples/performance/backend-comparison.ts b/examples/performance/backend-comparison.ts index 7140c8ded..8678830d8 100644 --- a/examples/performance/backend-comparison.ts +++ b/examples/performance/backend-comparison.ts @@ -1,89 +1,156 @@ -import { Application, Capabilities, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs'; +import { + Application, + Capabilities, + Color, + Container, + FixedResolutionCanvasSizing, + Keyboard, + type RenderingContext, + Scene, + type Seconds, + Sprite, + Texture, +} from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { mountControls } from '@examples/runtime'; -const options = { - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, -}; - -let app: Application | null = null; -let overlay: DebugOverlay | null = null; +const MAX_SPRITES = 2400; +const COUNT_STEP = 400; +let spriteCount = 800; +let textureCount = 1; let backendType: 'webgl2' | 'webgpu' = 'webgl2'; let webGpuAvailable = false; +let app: Application | null = null; +let overlay: DebugOverlay | null = null; +let booting = false; -class DemoScene extends Scene { - private sprites!: { sprite: Sprite; vx: number; vy: number }[]; +const showStartupError = (error: unknown): void => { + const message = document.createElement('p'); + message.textContent = `Could not start the renderer: ${String(error)}`; + document.body.append(message); +}; + +const makeTexture = (): Texture => { + const canvas = document.createElement('canvas'); + canvas.width = 48; + canvas.height = 48; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#19324b'; + ctx.fillRect(0, 0, 48, 48); + ctx.fillStyle = '#79d8ff'; + ctx.beginPath(); + ctx.arc(24, 24, 17, 0, Math.PI * 2); + ctx.fill(); + return new Texture(canvas); +}; + +class BatchingScene extends Scene { + private layer!: Container; + private sprites: Sprite[] = []; + private textures: Texture[] = []; + private hud!: ReturnType; override init(): void { - const app = this.app; - const { width, height } = app; + this.layer = new Container(); + this.addChild(this.layer); + this.textures = Array.from({ length: 4 }, () => makeTexture()); + for (let i = 0; i < MAX_SPRITES; i++) { + const sprite = new Sprite(this.textures[i % textureCount]) + .setAnchor(0.5) + .setScale(0.42) + .setPosition((i * 97) % this.app.width, (i * 193) % this.app.height); + sprite.visible = i < spriteCount; + this.layer.addChild(sprite); + this.sprites.push(sprite); + } - this.sprites = Array.from({ length: 2200 }, () => { - 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); - return { - sprite, - vx: (Math.random() - 0.5) * 180, - vy: (Math.random() - 0.5) * 180, - }; + this.hud = mountControls({ + title: 'Sprite Batching Laboratory', + corner: 'top-right', + controls: [ + { keys: 'Up / Down', action: 'change visible sprite count' }, + { keys: 'T', action: 'use one or four textures' }, + { keys: 'B', action: 'switch WebGL2 / WebGPU' }, + ], + hint: 'The seeded positions and motion stay the same across settings. The overlay reports this workload, not a general backend benchmark.', + }); + this.updateHud(); + this.inputs.onTrigger(Keyboard.Up, () => this.setCount(Math.min(MAX_SPRITES, spriteCount + COUNT_STEP))); + this.inputs.onTrigger(Keyboard.Down, () => this.setCount(Math.max(COUNT_STEP, spriteCount - COUNT_STEP))); + this.inputs.onTrigger(Keyboard.T, () => { + textureCount = textureCount === 1 ? 4 : 1; + this.sprites.forEach((sprite, index) => sprite.setTexture(this.textures[index % textureCount])); + this.updateHud(); }); this.inputs.onTrigger(Keyboard.B, () => { - // Without an adapter the WebGPU backend produces no context and the - // canvas stays blank, so the comparison only toggles where both - // backends can actually run. - if (!webGpuAvailable) return; - backendType = backendType === 'webgpu' ? 'webgl2' : 'webgpu'; - boot(backendType); + if (!webGpuAvailable) { + this.hud.setStatus('WebGPU adapter unavailable; using WebGL2'); + return; + } + void boot(backendType === 'webgpu' ? 'webgl2' : 'webgpu').catch(showStartupError); + }); + } + + private setCount(next: number): void { + spriteCount = next; + this.sprites.forEach((sprite, index) => { + sprite.visible = index < spriteCount; }); + this.updateHud(); + } + + private updateHud(): void { + this.hud.setStatus(`${backendType} · ${spriteCount} sprites · ${textureCount} texture${textureCount === 1 ? '' : 's'}`); } - override update(delta: Seconds): void { - const app = this.app; - const { width, height } = app; - for (const item of this.sprites) { - item.sprite.move(item.vx * delta, item.vy * delta); - if (item.sprite.position.x < 0 || item.sprite.position.x > width) item.vx *= -1; - if (item.sprite.position.y < 0 || item.sprite.position.y > height) item.vy *= -1; + override update(_delta: Seconds): void { + const time = this.app.activeSeconds; + for (let i = 0; i < spriteCount; i++) { + const sprite = this.sprites[i]; + sprite.setPosition(((i * 97) % this.app.width) + Math.sin(time + i * 0.13) * 12, ((i * 193) % this.app.height) + Math.cos(time + i * 0.17) * 12); + sprite.rotation = time * ((i % 2 === 0 ? 1 : -1) * 25); } } override draw(context: RenderingContext): void { - for (const { sprite } of this.sprites) context.render(sprite); + context.render(this.layer); + } + + override destroy(): void { + this.hud.dispose(); + super.destroy(); + this.textures.forEach(texture => texture.destroy()); } } -const boot = (type: 'webgl2' | 'webgpu'): void => { - if (overlay !== null) { - overlay.destroy(); +const boot = async (type: 'webgl2' | 'webgpu'): Promise => { + if (booting) return; + booting = true; + try { + overlay?.destroy(); overlay = null; + if (app) { + const previous = app; + app = null; + await previous.destroy(); + previous.element?.remove(); + } + backendType = type; + app = new Application({ + scenes: { BatchingScene }, + backend: { type }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(8, 12, 20), + }); + overlay = new DebugOverlay(app); + overlay.layers.performance.visible = true; + await app.start(BatchingScene); + } finally { + booting = false; } - if (app !== null) { - void app.destroy(); - app.element?.remove(); - app = null; - } - app = new Application({ ...options, scenes: { DemoScene }, backend: { type } }); - overlay = new DebugOverlay(app); - overlay.layers.performance.visible = true; - void app.start(DemoScene); }; -// The adapter query is async, so the first boot waits for it: starting on -// WebGPU where no adapter exists leaves the canvas blank with no error. -// `webgpu` only reports the API surface - a browser can expose `navigator.gpu` -// and still hand out no adapter, so the adapter itself is the deciding fact. void Capabilities.ready.then(capabilities => { webGpuAvailable = capabilities.webgpuAdapter !== null; - backendType = webGpuAvailable ? 'webgpu' : 'webgl2'; - boot(backendType); + void boot(webGpuAvailable ? 'webgpu' : 'webgl2').catch(showStartupError); }); diff --git a/examples/physics/joint-driven-shadows.js b/examples/physics/joint-driven-shadows.js new file mode 100644 index 000000000..55893bc04 --- /dev/null +++ b/examples/physics/joint-driven-shadows.js @@ -0,0 +1,117 @@ +// Auto-generated from joint-driven-shadows.ts - edit the .ts source, not this file. +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, SystemOrder } from '@codexo/exojs'; +import { LightmapLighting, PhysicsOccluder, PointLight } from '@codexo/exojs-lighting'; +import { BoxShape, CircleShape, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; +import { mountControls } from '@examples/runtime'; +const makeBar = (width, color) => { + const bar = new Graphics(); + bar.fillColor = color; + bar.drawRoundedRectangle(-width / 2, -17, width, 34, 16); + bar.fillColor = new Color(255, 232, 177); + bar.drawCircle(-width / 2 + 15, 0, 7); + bar.drawCircle(width / 2 - 15, 0, 7); + return bar; +}; +class JointShadowsScene extends Scene { + backdrop = new Graphics(); + anchor = new Graphics(); + arm = makeBar(300, new Color(92, 197, 213)); + world; + lighting; + endBody; + hinge; + dragging = false; + targetAngle = 0; + hud; + statusClock = 0; + init() { + this.backdrop.fillColor = new Color(27, 39, 56); + this.backdrop.drawRectangle(0, 0, 1280, 720); + this.backdrop.lineColor = new Color(72, 100, 128, 0.25); + this.backdrop.lineWidth = 2; + for (let x = 80; x < 1280; x += 80) this.backdrop.drawLine(x, 0, x, 720); + for (let y = 80; y < 720; y += 80) this.backdrop.drawLine(0, y, 1280, y); + this.world = new PhysicsWorld({ gravity: { x: 0, y: 560 } }); + this.systems.add(this.world, { order: SystemOrder.Physics }); + this.lighting = new LightmapLighting(this.app, { ambient: new Color(42, 46, 66), lightResolution: 0.5, shadowResolution: 256 }); + this.systems.add(this.lighting); + this.lighting.occludeFrom(new PhysicsOccluder(this.world, { staticOnly: false })); + this.lighting.add(new PointLight({ radius: 1000, intensity: 3.2, softness: 0.18, color: new Color(255, 211, 144) })).setPosition(430, 350); + this.anchor.fillColor = new Color(245, 217, 161); + this.anchor.drawCircle(0, 0, 23); + const fixed = this.world.attach(this.anchor, { type: 'static', position: { x: 650, y: 220 }, shape: new CircleShape(23) }); + this.endBody = this.world.attach(this.arm, { + type: 'dynamic', + position: { x: 800, y: 220 }, + shape: new BoxShape(300, 34), + density: 0.08, + friction: 0.4, + }); + this.hinge = this.world.addJoint(new RevoluteJoint({ bodyA: fixed, bodyB: this.endBody, anchor: { x: 650, y: 220 }, maxMotorTorque: 10_000_000 })); + this.hud = mountControls({ + title: 'Joint-Driven Shadows', + controls: [{ keys: 'Drag', action: 'pull the hinged arm through the light' }], + status: 'The collider casts a shadow as its revolute joint moves.', + hint: 'PhysicsOccluder includes dynamic bodies here; its static-only default would omit the moving arm.', + }); + this.app.input.onPointerDown.add(this.onDown); + this.app.input.onPointerMove.add(this.onMove); + this.app.input.onPointerUp.add(this.onEnd); + this.app.input.onPointerCancel.add(this.onEnd); + } + onDown = pointer => { + if (Math.hypot(pointer.x - this.endBody.x, pointer.y - this.endBody.y) > 170) return; + this.onEnd(); + this.dragging = true; + this.hinge.enableMotor = true; + this.onMove(pointer); + this.hud.setStatus('Dragging the arm; its physics collider drives the changing shadow.'); + }; + onMove = pointer => { + if (!this.dragging) return; + this.targetAngle = Math.atan2(pointer.y - 220, pointer.x - 650); + this.endBody.wake(); + this.hud.setStatus(`Aim ${Math.round((this.targetAngle * 180) / Math.PI)} degrees; arm ${Math.round((this.endBody.angle * 180) / Math.PI)} degrees.`); + }; + onEnd = () => { + if (!this.dragging) return; + this.dragging = false; + this.hinge.enableMotor = false; + this.hinge.motorSpeed = 0; + this.hud.setStatus('Released. The hinged arm settles under gravity.'); + }; + update(delta) { + if (this.dragging) this.hinge.motorSpeed = Math.max(-6, Math.min(6, (this.targetAngle - this.endBody.angle) * 10)); + this.statusClock += delta; + if (this.statusClock < 0.25) return; + this.statusClock = 0; + this.hud.setStatus( + this.dragging + ? `Aim ${Math.round((this.targetAngle * 180) / Math.PI)} degrees; arm ${Math.round((this.endBody.angle * 180) / Math.PI)} degrees.` + : `Arm at (${this.endBody.x.toFixed(0)}, ${this.endBody.y.toFixed(0)}). Drag it to change its shadow.`, + ); + } + draw(context) { + context.render(this.backdrop); + context.render(this.anchor); + context.render(this.arm); + } + destroy() { + this.onEnd(); + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.backdrop.destroy(); + this.anchor.destroy(); + this.arm.destroy(); + super.destroy(); + } +} +const app = new Application({ + scenes: { JointShadowsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(10, 16, 27), +}); +await app.start(JointShadowsScene); diff --git a/examples/physics/joint-driven-shadows.ts b/examples/physics/joint-driven-shadows.ts new file mode 100644 index 000000000..50ad53a84 --- /dev/null +++ b/examples/physics/joint-driven-shadows.ts @@ -0,0 +1,128 @@ +import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, SystemOrder } from '@codexo/exojs'; +import { LightmapLighting, PhysicsOccluder, PointLight } from '@codexo/exojs-lighting'; +import { BoxShape, CircleShape, type PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; +import { mountControls } from '@examples/runtime'; + +const makeBar = (width: number, color: Color): Graphics => { + const bar = new Graphics(); + bar.fillColor = color; + bar.drawRoundedRectangle(-width / 2, -17, width, 34, 16); + bar.fillColor = new Color(255, 232, 177); + bar.drawCircle(-width / 2 + 15, 0, 7); + bar.drawCircle(width / 2 - 15, 0, 7); + return bar; +}; + +class JointShadowsScene extends Scene { + private readonly backdrop = new Graphics(); + private readonly anchor = new Graphics(); + private readonly arm = makeBar(300, new Color(92, 197, 213)); + private world!: PhysicsWorld; + private lighting!: LightmapLighting; + private endBody!: PhysicsBody; + private hinge!: RevoluteJoint; + private dragging = false; + private targetAngle = 0; + private hud!: ReturnType; + private statusClock = 0; + + override init(): void { + this.backdrop.fillColor = new Color(27, 39, 56); + this.backdrop.drawRectangle(0, 0, 1280, 720); + this.backdrop.lineColor = new Color(72, 100, 128, 0.25); + this.backdrop.lineWidth = 2; + for (let x = 80; x < 1280; x += 80) this.backdrop.drawLine(x, 0, x, 720); + for (let y = 80; y < 720; y += 80) this.backdrop.drawLine(0, y, 1280, y); + + this.world = new PhysicsWorld({ gravity: { x: 0, y: 560 } }); + this.systems.add(this.world, { order: SystemOrder.Physics }); + this.lighting = new LightmapLighting(this.app, { ambient: new Color(42, 46, 66), lightResolution: 0.5, shadowResolution: 256 }); + this.systems.add(this.lighting); + this.lighting.occludeFrom(new PhysicsOccluder(this.world, { staticOnly: false })); + this.lighting.add(new PointLight({ radius: 1000, intensity: 3.2, softness: 0.18, color: new Color(255, 211, 144) })).setPosition(430, 350); + + this.anchor.fillColor = new Color(245, 217, 161); + this.anchor.drawCircle(0, 0, 23); + const fixed = this.world.attach(this.anchor, { type: 'static', position: { x: 650, y: 220 }, shape: new CircleShape(23) }); + this.endBody = this.world.attach(this.arm, { + type: 'dynamic', + position: { x: 800, y: 220 }, + shape: new BoxShape(300, 34), + density: 0.08, + friction: 0.4, + }); + this.hinge = this.world.addJoint(new RevoluteJoint({ bodyA: fixed, bodyB: this.endBody, anchor: { x: 650, y: 220 }, maxMotorTorque: 10_000_000 })); + + this.hud = mountControls({ + title: 'Joint-Driven Shadows', + controls: [{ keys: 'Drag', action: 'pull the hinged arm through the light' }], + status: 'The collider casts a shadow as its revolute joint moves.', + hint: 'PhysicsOccluder includes dynamic bodies here; its static-only default would omit the moving arm.', + }); + this.app.input.onPointerDown.add(this.onDown); + this.app.input.onPointerMove.add(this.onMove); + this.app.input.onPointerUp.add(this.onEnd); + this.app.input.onPointerCancel.add(this.onEnd); + } + + private readonly onDown = (pointer: { x: number; y: number }): void => { + if (Math.hypot(pointer.x - this.endBody.x, pointer.y - this.endBody.y) > 170) return; + this.onEnd(); + this.dragging = true; + this.hinge.enableMotor = true; + this.onMove(pointer); + this.hud.setStatus('Dragging the arm; its physics collider drives the changing shadow.'); + }; + private readonly onMove = (pointer: { x: number; y: number }): void => { + if (!this.dragging) return; + this.targetAngle = Math.atan2(pointer.y - 220, pointer.x - 650); + this.endBody.wake(); + this.hud.setStatus(`Aim ${Math.round((this.targetAngle * 180) / Math.PI)} degrees; arm ${Math.round((this.endBody.angle * 180) / Math.PI)} degrees.`); + }; + private readonly onEnd = (): void => { + if (!this.dragging) return; + this.dragging = false; + this.hinge.enableMotor = false; + this.hinge.motorSpeed = 0; + this.hud.setStatus('Released. The hinged arm settles under gravity.'); + }; + + override update(delta: Seconds): void { + if (this.dragging) this.hinge.motorSpeed = Math.max(-6, Math.min(6, (this.targetAngle - this.endBody.angle) * 10)); + this.statusClock += delta; + if (this.statusClock < 0.25) return; + this.statusClock = 0; + this.hud.setStatus( + this.dragging + ? `Aim ${Math.round((this.targetAngle * 180) / Math.PI)} degrees; arm ${Math.round((this.endBody.angle * 180) / Math.PI)} degrees.` + : `Arm at (${this.endBody.x.toFixed(0)}, ${this.endBody.y.toFixed(0)}). Drag it to change its shadow.`, + ); + } + + override draw(context: RenderingContext): void { + context.render(this.backdrop); + context.render(this.anchor); + context.render(this.arm); + } + + override destroy(): void { + this.onEnd(); + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.backdrop.destroy(); + this.anchor.destroy(); + this.arm.destroy(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { JointShadowsScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(10, 16, 27), +}); + +await app.start(JointShadowsScene); diff --git a/examples/physics/sprite-follows-body.js b/examples/physics/sprite-follows-body.js index 1a5b308dc..a0dca4cab 100644 --- a/examples/physics/sprite-follows-body.js +++ b/examples/physics/sprite-follows-body.js @@ -1,18 +1,12 @@ // Auto-generated from sprite-follows-body.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Sprite, Spritesheet, SystemOrder, Vector } from '@codexo/exojs'; -import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; +import { Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Spritesheet, SystemOrder, Vector } from '@codexo/exojs'; +import { BoxShape, MouseJoint, PhysicsWorld } from '@codexo/exojs-physics'; import { mountControls } from '@examples/runtime'; -// The minimal physics binding: `world.attach(node, { ... })` builds a body + -// collider and binds it to the node in one call. Registering the world as a -// system (`this.systems.add(this.world, { order: SystemOrder.Physics })`) -// drives it from the engine's fixed-timestep scheduler - no manual step() -// call needed. After every fixed step the body's position and rotation are -// written onto the bound sprite, so the sprite simply "follows the body". A -// static floor stops the falling actor. class SpriteFollowsBodyScene extends Scene { world; actor; actorBody; + dragJoint = null; floor; floorY = 0; settled = 0; @@ -55,15 +49,41 @@ class SpriteFollowsBodyScene extends Scene { friction: 0.4, restitution: 0.15, }); - // A small sideways nudge so the landing is visibly dynamic. - this.actorBody.applyImpulse(900, 0); this.hud = mountControls({ - title: 'Sprite Follows Body', - controls: [{ keys: 'Auto', action: 'actor falls and lands on the floor' }], + title: 'Drag and Throw Physics', + controls: [ + { keys: 'Drag', action: 'pull and throw the actor' }, + { keys: 'R', action: 'reset the actor' }, + ], status: 'Dropping…', - hint: 'world.attach(sprite, { … }) creates a body + collider and binds it; the world, registered as a system, writes the body transform onto the sprite every fixed step.', + hint: 'world.attach binds the sprite to a body. MouseJoint pulls the body, and the sprite follows the simulated transform.', }); + app.input.onPointerDown.add(this.onDown); + app.input.onPointerMove.add(this.onMove); + app.input.onPointerUp.add(this.onEnd); + app.input.onPointerCancel.add(this.onEnd); + this.inputs.onTrigger(Keyboard.R, this.resetActor); } + onDown = pointer => { + if (Math.abs(pointer.x - this.actorBody.x) > 42 || Math.abs(pointer.y - this.actorBody.y) > 55) return; + this.onEnd(); + this.dragJoint = this.world.addJoint(new MouseJoint({ body: this.actorBody, target: pointer, hertz: 7, dampingRatio: 0.8, maxForce: 400_000 })); + }; + onMove = pointer => { + if (this.dragJoint) this.dragJoint.target = pointer; + }; + onEnd = () => { + if (!this.dragJoint) return; + this.world.removeJoint(this.dragJoint); + this.dragJoint = null; + }; + resetActor = () => { + this.onEnd(); + this.actorBody.setTransform(new Vector(this.app.width / 2, 140), 0); + this.actorBody.linearVelocityX = 0; + this.actorBody.linearVelocityY = 0; + this.actorBody.angularVelocity = 0; + }; update(delta) { const app = this.app; const { width, height } = app; @@ -74,21 +94,30 @@ class SpriteFollowsBodyScene extends Scene { } else { this.settled = 0; } - this.hud.setStatus(this.settled > 0 ? `Resting on the floor (${restingSpeed.toFixed(0)} px/s)` : `Falling… y=${body.y.toFixed(0)} px`); - // Loop: after a short rest (or if it tumbles off-screen) drop it again. - if (this.settled > 1.2 || body.y > height + 200 || Math.abs(body.x - width / 2) > width) { - this.settled = 0; - body.setTransform(new Vector(width / 2, 140), (Math.random() - 0.5) * 0.6); - body.linearVelocityX = 0; - body.linearVelocityY = 0; - body.angularVelocity = 0; - body.applyImpulse((Math.random() - 0.5) * 1800, 0); - } + this.hud.setStatus( + this.dragJoint + ? 'Dragging body with MouseJoint.' + : this.settled > 0 + ? `Resting on the floor (${restingSpeed.toFixed(0)} px/s)` + : `Moving at ${restingSpeed.toFixed(0)} px/s.`, + ); + if (!this.dragJoint && (body.y > height + 200 || Math.abs(body.x - width / 2) > width)) this.resetActor(); } draw(context) { context.render(this.floor); context.render(this.actor); } + destroy() { + this.onEnd(); + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.actor?.destroy(); + this.floor?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { SpriteFollowsBodyScene }, diff --git a/examples/physics/sprite-follows-body.ts b/examples/physics/sprite-follows-body.ts index 144e1d766..7d8748db4 100644 --- a/examples/physics/sprite-follows-body.ts +++ b/examples/physics/sprite-follows-body.ts @@ -3,6 +3,7 @@ import { Asset, Color, FixedResolutionCanvasSizing, + Keyboard, type RenderingContext, Scene, type Seconds, @@ -12,21 +13,14 @@ import { SystemOrder, Vector, } from '@codexo/exojs'; -import { BoxShape, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, MouseJoint, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; import { mountControls } from '@examples/runtime'; -// The minimal physics binding: `world.attach(node, { ... })` builds a body + -// collider and binds it to the node in one call. Registering the world as a -// system (`this.systems.add(this.world, { order: SystemOrder.Physics })`) -// drives it from the engine's fixed-timestep scheduler - no manual step() -// call needed. After every fixed step the body's position and rotation are -// written onto the bound sprite, so the sprite simply "follows the body". A -// static floor stops the falling actor. - class SpriteFollowsBodyScene extends Scene { private world!: PhysicsWorld; private actor!: Sprite; private actorBody!: PhysicsBody; + private dragJoint: MouseJoint | null = null; private floor!: Sprite; private floorY = 0; private settled = 0; @@ -79,17 +73,46 @@ class SpriteFollowsBodyScene extends Scene { restitution: 0.15, }); - // A small sideways nudge so the landing is visibly dynamic. - this.actorBody.applyImpulse(900, 0); - this.hud = mountControls({ - title: 'Sprite Follows Body', - controls: [{ keys: 'Auto', action: 'actor falls and lands on the floor' }], + title: 'Drag and Throw Physics', + controls: [ + { keys: 'Drag', action: 'pull and throw the actor' }, + { keys: 'R', action: 'reset the actor' }, + ], status: 'Dropping…', - hint: 'world.attach(sprite, { … }) creates a body + collider and binds it; the world, registered as a system, writes the body transform onto the sprite every fixed step.', + hint: 'world.attach binds the sprite to a body. MouseJoint pulls the body, and the sprite follows the simulated transform.', }); + app.input.onPointerDown.add(this.onDown); + app.input.onPointerMove.add(this.onMove); + app.input.onPointerUp.add(this.onEnd); + app.input.onPointerCancel.add(this.onEnd); + this.inputs.onTrigger(Keyboard.R, this.resetActor); } + private readonly onDown = (pointer: { x: number; y: number }): void => { + if (Math.abs(pointer.x - this.actorBody.x) > 42 || Math.abs(pointer.y - this.actorBody.y) > 55) return; + this.onEnd(); + this.dragJoint = this.world.addJoint(new MouseJoint({ body: this.actorBody, target: pointer, hertz: 7, dampingRatio: 0.8, maxForce: 400_000 })); + }; + + private readonly onMove = (pointer: { x: number; y: number }): void => { + if (this.dragJoint) this.dragJoint.target = pointer; + }; + + private readonly onEnd = (): void => { + if (!this.dragJoint) return; + this.world.removeJoint(this.dragJoint); + this.dragJoint = null; + }; + + private readonly resetActor = (): void => { + this.onEnd(); + this.actorBody.setTransform(new Vector(this.app.width / 2, 140), 0); + this.actorBody.linearVelocityX = 0; + this.actorBody.linearVelocityY = 0; + this.actorBody.angularVelocity = 0; + }; + override update(delta: Seconds): void { const app = this.app; @@ -103,23 +126,33 @@ class SpriteFollowsBodyScene extends Scene { this.settled = 0; } - this.hud.setStatus(this.settled > 0 ? `Resting on the floor (${restingSpeed.toFixed(0)} px/s)` : `Falling… y=${body.y.toFixed(0)} px`); + this.hud.setStatus( + this.dragJoint + ? 'Dragging body with MouseJoint.' + : this.settled > 0 + ? `Resting on the floor (${restingSpeed.toFixed(0)} px/s)` + : `Moving at ${restingSpeed.toFixed(0)} px/s.`, + ); - // Loop: after a short rest (or if it tumbles off-screen) drop it again. - if (this.settled > 1.2 || body.y > height + 200 || Math.abs(body.x - width / 2) > width) { - this.settled = 0; - body.setTransform(new Vector(width / 2, 140), (Math.random() - 0.5) * 0.6); - body.linearVelocityX = 0; - body.linearVelocityY = 0; - body.angularVelocity = 0; - body.applyImpulse((Math.random() - 0.5) * 1800, 0); - } + if (!this.dragJoint && (body.y > height + 200 || Math.abs(body.x - width / 2) > width)) this.resetActor(); } override draw(context: RenderingContext): void { context.render(this.floor); context.render(this.actor); } + + override destroy(): void { + this.onEnd(); + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.actor?.destroy(); + this.floor?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/physics/tiled-map-physics-actor.js b/examples/physics/tiled-map-physics-actor.js index 55ca37de4..d50602949 100644 --- a/examples/physics/tiled-map-physics-actor.js +++ b/examples/physics/tiled-map-physics-actor.js @@ -1,28 +1,10 @@ // Auto-generated from tiled-map-physics-actor.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Spritesheet, SystemOrder, TextureRegion, Vector } from '@codexo/exojs'; +import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Spritesheet, SystemOrder, 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'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; import { buildObjectLayerColliders } from '@codexo/exojs-tilemap-physics'; -import { mountControls } from '@examples/runtime'; -// Combined Tiled + physics demo. -// -// 1. A hand-built `TileMap` is rendered with a `TileMapNode` (tilemap -// extension installed below). -// 2. An `ObjectLayer` carries the level's solid regions - exactly the data a -// Tiled "collision" object layer would hold. -// 3. `buildObjectLayerColliders` from `@codexo/exojs-tilemap-physics` walks -// that layer and adds one static `PhysicsBody` per region to the world. -// (For a tile layer whose chunks stream in and out, the same package's -// `TileColliderStreamer` keeps the colliders in sync instead.) -// 4. A dynamic actor is dropped in with `world.attach` and falls onto the -// generated colliders, bouncing between the walls. -// -// The green outlines are the physics debug overlay - every outline was built -// from an object-layer rectangle by the bridge, so they line up with the tiles. -const TILE = 64; -const COLUMNS = 20; -const ROWS = 11; +import { mountControlPanel, mountControls } from '@examples/runtime'; class TiledMapPhysicsActorScene extends Scene { world; mapNode; @@ -30,149 +12,86 @@ class TiledMapPhysicsActorScene extends Scene { actorBody; debug; hud; - tilesTexture; + panel; + showOutlines = false; + settled = 0; + map; + charactersTexture; spritesheetData; async load() { - this.tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); - this.spritesheetData = await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)); + const [map, charactersTexture, spritesheetData] = await Promise.all([ + this.loader.load(Asset.type('tileMap', 'json/maps/physics-room.tmj')), + this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), + this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)), + ]); + this.map = map; + this.charactersTexture = charactersTexture; + this.spritesheetData = spritesheetData; } init() { - const app = this.app; this.world = new PhysicsWorld({ gravity: { x: 0, y: 1500 } }); this.systems.add(this.world, { order: SystemOrder.Physics }); - // ── 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. - // Await the atlas load: TileSet needs a TextureRegion with real - // dimensions, so a not-yet-hydrated `loader.get()` handle is not enough. - const tilesTexture = this.tilesTexture; - const tileset = new TileSet({ - name: 'map', - texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), - tileWidth: TILE, - tileHeight: TILE, - tileCount: 204, - columns: 17, - }); - // Stone centre from mapPack_tilesheet.png (17 columns, localTileId = - // row * 17 + column). The block corners and edges around it are terrain - // borders with transparent margins - they do not read as solid ground. - const groundTile = 28; - const layer = new TileLayer({ id: 1, name: 'ground', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset] }); - // Paint a floor row + two side walls + two floating platforms. - for (let tx = 0; tx < COLUMNS; tx++) { - layer.setTileAt(tx, ROWS - 1, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let ty = 0; ty < ROWS; ty++) { - layer.setTileAt(0, ty, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - layer.setTileAt(COLUMNS - 1, ty, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let tx = 4; tx <= 7; tx++) { - layer.setTileAt(tx, 6, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let tx = 12; tx <= 15; tx++) { - layer.setTileAt(tx, 4, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - // ── Object layer: the solid regions, exactly mirroring the tiles ── - // In a Tiled project these rectangles would be drawn in a "collision" - // object layer; here we author them by hand to match the painted tiles. - const map = new TileMap({ - name: 'level', - width: COLUMNS, - height: ROWS, - tileWidth: TILE, - tileHeight: TILE, - tilesets: [tileset], - layers: [layer], - objectLayers: [ - new ObjectLayer({ - id: 10, - name: 'collision', - objects: [ - rect(1, 0, (ROWS - 1) * TILE, COLUMNS * TILE, TILE, 'floor'), - rect(2, 0, 0, TILE, ROWS * TILE, 'wall-left'), - rect(3, (COLUMNS - 1) * TILE, 0, TILE, ROWS * TILE, 'wall-right'), - rect(4, 4 * TILE, 6 * TILE, 4 * TILE, TILE, 'platform-a'), - rect(5, 12 * TILE, 4 * TILE, 4 * TILE, TILE, 'platform-b'), - ], - }), - ], - }); - this.mapNode = new TileMapNode(map); - // ── The bridge: ObjectLayer → static physics colliders ──────────── - const collision = map.getObjectLayer('collision'); - if (collision) { - const built = buildObjectLayerColliders(this.world, collision, { friction: 0.7, restitution: 0.05 }); - this.hud = mountControls({ - title: 'Tiled Map + Physics Actor', - controls: [{ keys: 'Auto', action: 'actor falls and bounces across the level' }], - status: `${built.length} static colliders built from the object layer`, - hint: 'buildObjectLayerColliders() turns a Tiled object layer into static bodies; the actor falls onto them via world.attach.', - }); - } - // ── Dynamic actor ───────────────────────────────────────────────── - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), this.spritesheetData); + this.mapNode = new TileMapNode(this.map); + const collision = this.map.getObjectLayer('Collision'); + if (!collision) throw new Error('physics-room.tmj needs a Collision object layer.'); + const colliders = buildObjectLayerColliders(this.world, collision, { friction: 0.7, restitution: 0.05 }); + const characters = new Spritesheet(this.charactersTexture, this.spritesheetData); this.actor = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.actorBody = this.world.attach(this.actor, { type: 'dynamic', - position: { x: 5 * TILE, y: 2 * TILE }, + position: { x: 320, y: 130 }, shape: new BoxShape(48, 64), friction: 0.3, restitution: 0.25, }); this.actorBody.applyImpulse(2600, 0); - // Physics debug overlay: outlines every collider so the bridge output - // is visible on top of the rendered tiles. - this.debug = new PhysicsDebugDraw(app, this.world, { drawShapes: true, drawCenters: true }); + this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true, drawCenters: true }); + this.hud = mountControls({ + title: 'Tiled Collision Map', + controls: [{ keys: 'Panel', action: 'toggle authored collision outlines' }], + status: `${colliders.length} static colliders from the loaded Tiled map`, + hint: 'buildObjectLayerColliders() turns rectangles placed in the .tmj Collision layer into static physics bodies.', + }); + this.panel = mountControlPanel({ title: 'Collision' }); + this.panel.addToggle({ + label: 'Show outlines', + value: false, + onChange: value => { + this.showOutlines = value; + }, + }); } - update(_delta) { - const app = this.app; - const { width, height } = app; + update(delta) { const body = this.actorBody; - // Loop the demo: nudge the actor again once it settles, and rescue it if - // it ever escapes the bounds. const speed = Math.hypot(body.linearVelocityX, body.linearVelocityY); - if ((speed < 8 && body.y > height - 3 * TILE) || body.x < 0 || body.x > width || body.y > height + 200) { - body.setTransform(new Vector(5 * TILE, 2 * TILE), 0); - body.linearVelocityX = 0; - body.linearVelocityY = 0; - body.angularVelocity = 0; - body.applyImpulse((Math.random() * 2 + 1) * 1400 * (Math.random() < 0.5 ? -1 : 1), -600); - } + this.settled = speed < 8 && body.y > 300 ? this.settled + delta : 0; + if (this.settled < 1.2 && body.y < 900 && body.x > 0 && body.x < 1280) return; + this.settled = 0; + body.setTransform(new Vector(320, 130), 0); + body.linearVelocityX = 0; + body.linearVelocityY = 0; + body.angularVelocity = 0; + body.applyImpulse(2600, 0); } draw(context) { context.render(this.mapNode); context.render(this.actor); - this.debug.render(context.backend); + if (this.showOutlines) this.debug.render(context.backend); + } + destroy() { + this.hud?.dispose(); + this.panel?.dispose(); + this.debug?.destroy(); + this.mapNode?.destroy(); + this.actor?.destroy(); + super.destroy(); } -} -/** Author a rectangle collision object (top-left origin, like Tiled). */ -function rect(id, x, y, width, height, name) { - return { - kind: ObjectKind.Rectangle, - id, - name, - type: 'solid', - x, - y, - width, - height, - rotation: 0, - visible: true, - properties: {}, - }; } const app = new Application({ scenes: { TiledMapPhysicsActorScene }, - canvas: { - width: COLUMNS * TILE, - height: ROWS * TILE, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 704, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(38, 46, 66), - // The tilemap extension wires the per-backend tile chunk renderers so - // TileMapNode can draw. Physics is a plain library - no extension needed. - extensions: [tilemapExtension], + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, }); await app.start(TiledMapPhysicsActorScene); diff --git a/examples/physics/tiled-map-physics-actor.ts b/examples/physics/tiled-map-physics-actor.ts index 05ee19c39..87c138a30 100644 --- a/examples/physics/tiled-map-physics-actor.ts +++ b/examples/physics/tiled-map-physics-actor.ts @@ -11,44 +11,14 @@ import { type SpritesheetData, SystemOrder, Texture, - 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'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; +import type { TileMap } from '@codexo/exojs-tilemap'; import { buildObjectLayerColliders } from '@codexo/exojs-tilemap-physics'; -import { mountControls } from '@examples/runtime'; - -// Combined Tiled + physics demo. -// -// 1. A hand-built `TileMap` is rendered with a `TileMapNode` (tilemap -// extension installed below). -// 2. An `ObjectLayer` carries the level's solid regions - exactly the data a -// Tiled "collision" object layer would hold. -// 3. `buildObjectLayerColliders` from `@codexo/exojs-tilemap-physics` walks -// that layer and adds one static `PhysicsBody` per region to the world. -// (For a tile layer whose chunks stream in and out, the same package's -// `TileColliderStreamer` keeps the colliders in sync instead.) -// 4. A dynamic actor is dropped in with `world.attach` and falls onto the -// generated colliders, bouncing between the walls. -// -// The green outlines are the physics debug overlay - every outline was built -// from an object-layer rectangle by the bridge, so they line up with the tiles. - -const TILE = 64; -const COLUMNS = 20; -const ROWS = 11; +import { mountControlPanel, mountControls } from '@examples/runtime'; class TiledMapPhysicsActorScene extends Scene { private world!: PhysicsWorld; @@ -57,170 +27,96 @@ class TiledMapPhysicsActorScene extends Scene { private actorBody!: PhysicsBody; private debug!: PhysicsDebugDraw; private hud!: ReturnType; - private tilesTexture!: Texture; + private panel!: ReturnType; + private showOutlines = false; + private settled = 0; + private map!: TileMap; + private charactersTexture!: Texture; private spritesheetData!: SpritesheetData; override async load(): Promise { - this.tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); - this.spritesheetData = (await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData; + const [map, charactersTexture, spritesheetData] = await Promise.all([ + this.loader.load(Asset.type('tileMap', 'json/maps/physics-room.tmj')), + this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), + this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)), + ]); + this.map = map; + this.charactersTexture = charactersTexture; + this.spritesheetData = spritesheetData as SpritesheetData; } override init(): void { - const app = this.app; this.world = new PhysicsWorld({ gravity: { x: 0, y: 1500 } }); this.systems.add(this.world, { order: SystemOrder.Physics }); + this.mapNode = new TileMapNode(this.map); - // ── 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. - // Await the atlas load: TileSet needs a TextureRegion with real - // dimensions, so a not-yet-hydrated `loader.get()` handle is not enough. - const tilesTexture = this.tilesTexture; - const tileset = new TileSet({ - name: 'map', - texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), - tileWidth: TILE, - tileHeight: TILE, - tileCount: 204, - columns: 17, - }); - - // Stone centre from mapPack_tilesheet.png (17 columns, localTileId = - // row * 17 + column). The block corners and edges around it are terrain - // borders with transparent margins - they do not read as solid ground. - const groundTile = 28; - const layer = new TileLayer({ id: 1, name: 'ground', width: COLUMNS, height: ROWS, tileWidth: TILE, tileHeight: TILE, tilesets: [tileset] }); - - // Paint a floor row + two side walls + two floating platforms. - for (let tx = 0; tx < COLUMNS; tx++) { - layer.setTileAt(tx, ROWS - 1, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let ty = 0; ty < ROWS; ty++) { - layer.setTileAt(0, ty, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - layer.setTileAt(COLUMNS - 1, ty, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let tx = 4; tx <= 7; tx++) { - layer.setTileAt(tx, 6, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - for (let tx = 12; tx <= 15; tx++) { - layer.setTileAt(tx, 4, { tileset, localTileId: groundTile, transform: TILE_TRANSFORM_IDENTITY }); - } - - // ── Object layer: the solid regions, exactly mirroring the tiles ── - // In a Tiled project these rectangles would be drawn in a "collision" - // object layer; here we author them by hand to match the painted tiles. - const map = new TileMap({ - name: 'level', - width: COLUMNS, - height: ROWS, - tileWidth: TILE, - tileHeight: TILE, - tilesets: [tileset], - layers: [layer], - objectLayers: [ - new ObjectLayer({ - id: 10, - name: 'collision', - objects: [ - rect(1, 0, (ROWS - 1) * TILE, COLUMNS * TILE, TILE, 'floor'), - rect(2, 0, 0, TILE, ROWS * TILE, 'wall-left'), - rect(3, (COLUMNS - 1) * TILE, 0, TILE, ROWS * TILE, 'wall-right'), - rect(4, 4 * TILE, 6 * TILE, 4 * TILE, TILE, 'platform-a'), - rect(5, 12 * TILE, 4 * TILE, 4 * TILE, TILE, 'platform-b'), - ], - }), - ], - }); - - this.mapNode = new TileMapNode(map); - - // ── The bridge: ObjectLayer → static physics colliders ──────────── - const collision = map.getObjectLayer('collision'); - - if (collision) { - const built = buildObjectLayerColliders(this.world, collision, { friction: 0.7, restitution: 0.05 }); - - this.hud = mountControls({ - title: 'Tiled Map + Physics Actor', - controls: [{ keys: 'Auto', action: 'actor falls and bounces across the level' }], - status: `${built.length} static colliders built from the object layer`, - hint: 'buildObjectLayerColliders() turns a Tiled object layer into static bodies; the actor falls onto them via world.attach.', - }); - } - - // ── Dynamic actor ───────────────────────────────────────────────── - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), this.spritesheetData); + const collision = this.map.getObjectLayer('Collision'); + if (!collision) throw new Error('physics-room.tmj needs a Collision object layer.'); + const colliders = buildObjectLayerColliders(this.world, collision, { friction: 0.7, restitution: 0.05 }); + const characters = new Spritesheet(this.charactersTexture, this.spritesheetData); this.actor = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.actorBody = this.world.attach(this.actor, { type: 'dynamic', - position: { x: 5 * TILE, y: 2 * TILE }, + position: { x: 320, y: 130 }, shape: new BoxShape(48, 64), friction: 0.3, restitution: 0.25, }); this.actorBody.applyImpulse(2600, 0); - // Physics debug overlay: outlines every collider so the bridge output - // is visible on top of the rendered tiles. - this.debug = new PhysicsDebugDraw(app, this.world, { drawShapes: true, drawCenters: true }); + this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true, drawCenters: true }); + this.hud = mountControls({ + title: 'Tiled Collision Map', + controls: [{ keys: 'Panel', action: 'toggle authored collision outlines' }], + status: `${colliders.length} static colliders from the loaded Tiled map`, + hint: 'buildObjectLayerColliders() turns rectangles placed in the .tmj Collision layer into static physics bodies.', + }); + this.panel = mountControlPanel({ title: 'Collision' }); + this.panel.addToggle({ + label: 'Show outlines', + value: false, + onChange: value => { + this.showOutlines = value; + }, + }); } - override update(_delta: Seconds): void { - const app = this.app; - - const { width, height } = app; + override update(delta: Seconds): void { const body = this.actorBody; - - // Loop the demo: nudge the actor again once it settles, and rescue it if - // it ever escapes the bounds. const speed = Math.hypot(body.linearVelocityX, body.linearVelocityY); - - if ((speed < 8 && body.y > height - 3 * TILE) || body.x < 0 || body.x > width || body.y > height + 200) { - body.setTransform(new Vector(5 * TILE, 2 * TILE), 0); - body.linearVelocityX = 0; - body.linearVelocityY = 0; - body.angularVelocity = 0; - body.applyImpulse((Math.random() * 2 + 1) * 1400 * (Math.random() < 0.5 ? -1 : 1), -600); - } + this.settled = speed < 8 && body.y > 300 ? this.settled + delta : 0; + if (this.settled < 1.2 && body.y < 900 && body.x > 0 && body.x < 1280) return; + this.settled = 0; + body.setTransform(new Vector(320, 130), 0); + body.linearVelocityX = 0; + body.linearVelocityY = 0; + body.angularVelocity = 0; + body.applyImpulse(2600, 0); } override draw(context: RenderingContext): void { context.render(this.mapNode); context.render(this.actor); - this.debug.render(context.backend); + if (this.showOutlines) this.debug.render(context.backend); } -} -/** Author a rectangle collision object (top-left origin, like Tiled). */ -function rect(id: number, x: number, y: number, width: number, height: number, name: string): RectangleObject { - return { - kind: ObjectKind.Rectangle, - id, - name, - type: 'solid', - x, - y, - width, - height, - rotation: 0, - visible: true, - properties: {}, - }; + override destroy(): void { + this.hud?.dispose(); + this.panel?.dispose(); + this.debug?.destroy(); + this.mapNode?.destroy(); + this.actor?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { TiledMapPhysicsActorScene }, - canvas: { - width: COLUMNS * TILE, - height: ROWS * TILE, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 704, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(38, 46, 66), - // The tilemap extension wires the per-backend tile chunk renderers so - // TileMapNode can draw. Physics is a plain library - no extension needed. - extensions: [tilemapExtension], + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, }); await app.start(TiledMapPhysicsActorScene); diff --git a/examples/render-targets/mini-map.js b/examples/render-targets/mini-map.js index d5cf08599..68847b714 100644 --- a/examples/render-targets/mini-map.js +++ b/examples/render-targets/mini-map.js @@ -16,44 +16,49 @@ class MiniMapScene extends Scene { worldContainer; world; player; + miniWorld; + miniPlayer; + miniContent; miniRt; miniSprite; miniFrame; + miniMask; overlay; miniView; pipeline; time = 0; init() { const app = this.app; - const { width, height } = app; + const { width } = app; const miniX = width - 220 - 20; const miniY = 20; - // Grid + player live in one container so the same subtree can be drawn at - // full size to the canvas and shrunk into the minimap texture. this.worldContainer = new Container(); this.world = new Graphics(); this.player = new Graphics(); this.worldContainer.addChild(this.world); this.worldContainer.addChild(this.player); + this.miniContent = new Container(); + this.miniWorld = new Graphics(); + this.miniPlayer = new Graphics(); + this.miniContent.addChild(this.miniWorld); + this.miniContent.addChild(this.miniPlayer); this.miniRt = new RenderTexture(220, 160); this.miniSprite = new Sprite(this.miniRt).setPosition(miniX, miniY); + this.miniMask = new Graphics(); + this.miniMask.fillColor = Color.white; + this.miniMask.drawCircle(miniX + 110, miniY + 80, 76); + this.miniSprite.mask = this.miniMask; this.miniFrame = new Graphics(); this.miniFrame.lineWidth = 2; this.miniFrame.lineColor = Color.white; - this.miniFrame.drawRectangle(miniX, miniY, 220, 160); - // Sprite + frame composited in one pass; draw order is now independent (RT sampling is order-safe). + this.miniFrame.drawCircle(miniX + 110, miniY + 80, 76); this.overlay = new Container(); this.overlay.addChild(this.miniSprite); this.overlay.addChild(this.miniFrame); - // A dedicated view that frames the whole world, scaled down into the - // 220×160 minimap texture so the entire grid stays visible. - this.miniView = new View(width / 2, height / 2, width, height); - // Every stage is a RenderNodePass so the off-screen target redirect and - // its clear stay inside the pass machinery - mixing in a manual - // `context.backend.clear()` (immediate-mode) here leaks the off-screen - // pass's clear onto the canvas and leaves the texture empty. + this.miniView = new View(110, 80, 220, 160); + // The render-target pass owns its clear; a manual backend clear here would clear the canvas instead. this.pipeline = new RenderPipeline() - .addPass(new RenderNodePass(this.worldContainer, { target: this.miniRt, view: this.miniView, clear: Color.black })) + .addPass(new RenderNodePass(this.miniContent, { target: this.miniRt, view: this.miniView, clear: Color.black })) .addPass(new RenderNodePass(this.worldContainer, { clear: Color.black })) .addPass(new RenderNodePass(this.overlay)); } @@ -64,8 +69,6 @@ class MiniMapScene extends Scene { const marginY = 60; this.time += delta; this.world.clear(); - // Filled play-area: gives the minimap a recognizable region. Sub-pixel grid - // lines alone vanish when the world is shrunk into the 220×160 texture. this.world.fillColor = new Color(50, 90, 160); this.world.drawRectangle(marginX, marginY, width - 2 * marginX, height - 2 * marginY); this.world.lineWidth = 2; @@ -77,6 +80,19 @@ class MiniMapScene extends Scene { this.player.clear(); this.player.fillColor = new Color(255, 180, 100); this.player.drawCircle(px, py, 18); + this.miniWorld.clear(); + this.miniWorld.fillColor = new Color(50, 90, 160); + this.miniWorld.drawRectangle(0, 0, 220, 160); + this.miniWorld.lineWidth = 1; + this.miniWorld.lineColor = new Color(150, 185, 230); + for (let x = 0; x <= 220; x += 20) this.miniWorld.drawLine(x, 0, x, 160); + for (let y = 0; y <= 160; y += 20) this.miniWorld.drawLine(0, y, 220, y); + this.miniPlayer.clear(); + this.miniPlayer.fillColor = new Color(255, 180, 100); + const markerX = (px / width) * 220 - 110; + const markerY = (py / height) * 160 - 80; + const markerScale = Math.min(1, 65 / Math.hypot(markerX, markerY)); + this.miniPlayer.drawCircle(110 + markerX * markerScale, 80 + markerY * markerScale, 5); } draw(context) { this.pipeline.execute(context); diff --git a/examples/render-targets/mini-map.ts b/examples/render-targets/mini-map.ts index c70e55076..32f67c78c 100644 --- a/examples/render-targets/mini-map.ts +++ b/examples/render-targets/mini-map.ts @@ -18,9 +18,13 @@ class MiniMapScene extends Scene { private worldContainer!: Container; private world!: Graphics; private player!: Graphics; + private miniWorld!: Graphics; + private miniPlayer!: Graphics; + private miniContent!: Container; private miniRt!: RenderTexture; private miniSprite!: Sprite; private miniFrame!: Graphics; + private miniMask!: Graphics; private overlay!: Container; private miniView!: View; private pipeline!: RenderPipeline; @@ -28,40 +32,41 @@ class MiniMapScene extends Scene { override init(): void { const app = this.app; - const { width, height } = app; + const { width } = app; const miniX = width - 220 - 20; const miniY = 20; - // Grid + player live in one container so the same subtree can be drawn at - // full size to the canvas and shrunk into the minimap texture. this.worldContainer = new Container(); this.world = new Graphics(); this.player = new Graphics(); this.worldContainer.addChild(this.world); this.worldContainer.addChild(this.player); + this.miniContent = new Container(); + this.miniWorld = new Graphics(); + this.miniPlayer = new Graphics(); + this.miniContent.addChild(this.miniWorld); + this.miniContent.addChild(this.miniPlayer); this.miniRt = new RenderTexture(220, 160); this.miniSprite = new Sprite(this.miniRt).setPosition(miniX, miniY); + this.miniMask = new Graphics(); + this.miniMask.fillColor = Color.white; + this.miniMask.drawCircle(miniX + 110, miniY + 80, 76); + this.miniSprite.mask = this.miniMask; this.miniFrame = new Graphics(); this.miniFrame.lineWidth = 2; this.miniFrame.lineColor = Color.white; - this.miniFrame.drawRectangle(miniX, miniY, 220, 160); + this.miniFrame.drawCircle(miniX + 110, miniY + 80, 76); - // Sprite + frame composited in one pass; draw order is now independent (RT sampling is order-safe). this.overlay = new Container(); this.overlay.addChild(this.miniSprite); this.overlay.addChild(this.miniFrame); - // A dedicated view that frames the whole world, scaled down into the - // 220×160 minimap texture so the entire grid stays visible. - this.miniView = new View(width / 2, height / 2, width, height); + this.miniView = new View(110, 80, 220, 160); - // Every stage is a RenderNodePass so the off-screen target redirect and - // its clear stay inside the pass machinery - mixing in a manual - // `context.backend.clear()` (immediate-mode) here leaks the off-screen - // pass's clear onto the canvas and leaves the texture empty. + // The render-target pass owns its clear; a manual backend clear here would clear the canvas instead. this.pipeline = new RenderPipeline() - .addPass(new RenderNodePass(this.worldContainer, { target: this.miniRt, view: this.miniView, clear: Color.black })) + .addPass(new RenderNodePass(this.miniContent, { target: this.miniRt, view: this.miniView, clear: Color.black })) .addPass(new RenderNodePass(this.worldContainer, { clear: Color.black })) .addPass(new RenderNodePass(this.overlay)); } @@ -75,8 +80,6 @@ class MiniMapScene extends Scene { this.time += delta; this.world.clear(); - // Filled play-area: gives the minimap a recognizable region. Sub-pixel grid - // lines alone vanish when the world is shrunk into the 220×160 texture. this.world.fillColor = new Color(50, 90, 160); this.world.drawRectangle(marginX, marginY, width - 2 * marginX, height - 2 * marginY); this.world.lineWidth = 2; @@ -89,6 +92,20 @@ class MiniMapScene extends Scene { this.player.clear(); this.player.fillColor = new Color(255, 180, 100); this.player.drawCircle(px, py, 18); + + this.miniWorld.clear(); + this.miniWorld.fillColor = new Color(50, 90, 160); + this.miniWorld.drawRectangle(0, 0, 220, 160); + this.miniWorld.lineWidth = 1; + this.miniWorld.lineColor = new Color(150, 185, 230); + for (let x = 0; x <= 220; x += 20) this.miniWorld.drawLine(x, 0, x, 160); + for (let y = 0; y <= 160; y += 20) this.miniWorld.drawLine(0, y, 220, y); + this.miniPlayer.clear(); + this.miniPlayer.fillColor = new Color(255, 180, 100); + const markerX = (px / width) * 220 - 110; + const markerY = (py / height) * 160 - 80; + const markerScale = Math.min(1, 65 / Math.hypot(markerX, markerY)); + this.miniPlayer.drawCircle(110 + markerX * markerScale, 80 + markerY * markerScale, 5); } override draw(context: RenderingContext): void { diff --git a/examples/render-targets/render-pipeline.js b/examples/render-targets/render-pipeline.js index a5ddb6daa..5cd04c69b 100644 --- a/examples/render-targets/render-pipeline.js +++ b/examples/render-targets/render-pipeline.js @@ -13,6 +13,7 @@ import { Scene, Sprite, } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // A composable frame, configured once: the world renders off-screen, a blur step turns it into its // blurred version, a composite step draws that to the screen, and a nested UI pipeline overlays a HUD. // The blur step toggles on and off via `pass.enabled`; the off-screen targets track the canvas size. @@ -26,7 +27,9 @@ class RenderPipelineScene extends Scene { blurPass; hud; hudBar; + overlay; frame; + controls; detachResize = null; time = 0; init() { @@ -42,13 +45,27 @@ class RenderPipelineScene extends Scene { this.world.addChild(this.orb); this.hud = new Container(); this.hudBar = new Graphics(); + this.overlay = new Graphics(); this.hud.addChild(this.hudBar); // A filter step: read the off-screen scene, write its blurred version. this.blurPass = new CallbackRenderPass(context => this.blur.apply(context.backend, this.sceneRt, this.blurredRt), { label: 'blur', }); // The HUD is its own nested pipeline, rendered in screen space. - const ui = new RenderPipeline({ label: 'ui' }).addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); + const ui = new RenderPipeline({ label: 'ui' }) + .addPass( + new CallbackRenderPass( + context => { + this.overlay.clear(); + this.overlay.lineWidth = 7; + this.overlay.lineColor = new Color(130, 235, 180); + this.overlay.drawArc(app.width / 2, app.height / 2, 130, this.time, this.time + Math.PI * 1.3); + context.render(this.overlay, { view: screenView }); + }, + { label: 'custom-arc' }, + ), + ) + .addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); // world → off-screen → blur → composite → UI overlay. A RenderPipeline is itself a RenderPass, // so `ui` nests directly. this.frame = new RenderPipeline({ label: 'frame' }) @@ -65,19 +82,31 @@ class RenderPipelineScene extends Scene { }; app.onResize.add(handleResize); this.detachResize = () => app.onResize.remove(handleResize); + this.controls = mountControls({ + title: 'Composable Render Pipeline', + status: 'Blur: on', + hint: 'Named world, blur, composite, custom callback, and HUD passes run in order. Disable blur to bypass to the current unblurred frame.', + }); + mountControlPanel({ title: 'Passes' }).addToggle({ + label: 'Blur', + value: true, + onChange: enabled => { + this.blurPass.enabled = enabled; + this.composite.setTexture(enabled ? this.blurredRt : this.sceneRt); + this.controls.setStatus(`Blur: ${enabled ? 'on' : 'off'}`); + }, + }); } update(delta) { const app = this.app; const { width, height } = app; this.time += delta; - // `enabled` lives on the pass - flip it and the composer skips the step next frame. - this.blurPass.enabled = Math.floor(this.time / 2.5) % 2 === 0; this.orb.clear(); this.orb.fillColor = new Color(90, 150, 255); this.orb.drawCircle(width / 2 + Math.cos(this.time) * (width * 0.32), height / 2 + Math.sin(this.time * 1.3) * (height * 0.32), 90); this.hudBar.clear(); this.hudBar.fillColor = this.blurPass.enabled ? new Color(120, 230, 150) : new Color(230, 120, 120); - this.hudBar.drawRectangle(20, 20, 200, 16); + this.hudBar.drawRectangle(width - 220, 20, 200, 16); } draw(context) { this.frame.execute(context); @@ -89,6 +118,7 @@ class RenderPipelineScene extends Scene { this.sceneRt.destroy(); this.blurredRt.destroy(); this.blur.destroy(); + this.controls.dispose(); super.destroy(); } } diff --git a/examples/render-targets/render-pipeline.ts b/examples/render-targets/render-pipeline.ts index 586a6315e..874873710 100644 --- a/examples/render-targets/render-pipeline.ts +++ b/examples/render-targets/render-pipeline.ts @@ -14,6 +14,7 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // A composable frame, configured once: the world renders off-screen, a blur step turns it into its // blurred version, a composite step draws that to the screen, and a nested UI pipeline overlays a HUD. @@ -28,7 +29,9 @@ class RenderPipelineScene extends Scene { private blurPass!: CallbackRenderPass; private hud!: Container; private hudBar!: Graphics; + private overlay!: Graphics; private frame!: RenderPipeline; + private controls!: ReturnType; private detachResize: (() => void) | null = null; private time = 0; @@ -48,6 +51,7 @@ class RenderPipelineScene extends Scene { this.hud = new Container(); this.hudBar = new Graphics(); + this.overlay = new Graphics(); this.hud.addChild(this.hudBar); // A filter step: read the off-screen scene, write its blurred version. @@ -56,7 +60,20 @@ class RenderPipelineScene extends Scene { }); // The HUD is its own nested pipeline, rendered in screen space. - const ui = new RenderPipeline({ label: 'ui' }).addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); + const ui = new RenderPipeline({ label: 'ui' }) + .addPass( + new CallbackRenderPass( + context => { + this.overlay.clear(); + this.overlay.lineWidth = 7; + this.overlay.lineColor = new Color(130, 235, 180); + this.overlay.drawArc(app.width / 2, app.height / 2, 130, this.time, this.time + Math.PI * 1.3); + context.render(this.overlay, { view: screenView }); + }, + { label: 'custom-arc' }, + ), + ) + .addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); // world → off-screen → blur → composite → UI overlay. A RenderPipeline is itself a RenderPass, // so `ui` nests directly. @@ -75,6 +92,21 @@ class RenderPipelineScene extends Scene { }; app.onResize.add(handleResize); this.detachResize = () => app.onResize.remove(handleResize); + + this.controls = mountControls({ + title: 'Composable Render Pipeline', + status: 'Blur: on', + hint: 'Named world, blur, composite, custom callback, and HUD passes run in order. Disable blur to bypass to the current unblurred frame.', + }); + mountControlPanel({ title: 'Passes' }).addToggle({ + label: 'Blur', + value: true, + onChange: enabled => { + this.blurPass.enabled = enabled; + this.composite.setTexture(enabled ? this.blurredRt : this.sceneRt); + this.controls.setStatus(`Blur: ${enabled ? 'on' : 'off'}`); + }, + }); } override update(delta: Seconds): void { @@ -82,16 +114,13 @@ class RenderPipelineScene extends Scene { const { width, height } = app; this.time += delta; - // `enabled` lives on the pass - flip it and the composer skips the step next frame. - this.blurPass.enabled = Math.floor(this.time / 2.5) % 2 === 0; - this.orb.clear(); this.orb.fillColor = new Color(90, 150, 255); this.orb.drawCircle(width / 2 + Math.cos(this.time) * (width * 0.32), height / 2 + Math.sin(this.time * 1.3) * (height * 0.32), 90); this.hudBar.clear(); this.hudBar.fillColor = this.blurPass.enabled ? new Color(120, 230, 150) : new Color(230, 120, 120); - this.hudBar.drawRectangle(20, 20, 200, 16); + this.hudBar.drawRectangle(width - 220, 20, 200, 16); } override draw(context: RenderingContext): void { @@ -105,6 +134,7 @@ class RenderPipelineScene extends Scene { this.sceneRt.destroy(); this.blurredRt.destroy(); this.blur.destroy(); + this.controls.dispose(); super.destroy(); } } diff --git a/examples/render-targets/render-to-texture.js b/examples/render-targets/render-to-texture.js index ebd712fd7..b829a1abb 100644 --- a/examples/render-targets/render-to-texture.js +++ b/examples/render-targets/render-to-texture.js @@ -1,53 +1,77 @@ // Auto-generated from render-to-texture.ts - edit the .ts source, not this file. -import { Application, Color, Container, FixedResolutionCanvasSizing, RenderTexture, Scene, Sprite } from '@codexo/exojs'; +import { Application, Color, Container, FixedResolutionCanvasSizing, RenderTexture, Scene, Sprite, Text } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; class RenderToTextureScene extends Scene { - container; - renderTexture; - renderSprite; + source; + texture; + liveTarget; + live; + ships = []; + target; + snapshot; + labels; + captureRequested = true; + captures = 0; + hud; + time = 0; init() { - const app = this.app; - const { width, height } = app; - this.container = this.createBunnyContainer(this.loader.get('image/ship-a.png')); - this.renderTexture = this.createRenderTexture(app.backend, this.container); - this.renderSprite = new Sprite(this.renderTexture); - this.renderSprite.setPosition(width, height); - this.renderSprite.setAnchor(1, 1); - } - createBunnyContainer(texture) { - const container = new Container(); + this.texture = this.loader.get(assets.demo.textures.shipA); + this.source = new Container(); for (let i = 0; i < 25; i++) { - const bunny = new Sprite(texture); - bunny.setAnchor(0.5, 0.5); - bunny.setPosition(25 + (i % 5) * 30, 25 + Math.floor(i / 5) * 30); - bunny.setRotation(Math.random() * 360); - container.addChild(bunny); + const ship = new Sprite(this.texture) + .setAnchor(0.5) + .setScale(2) + .setPosition(50 + (i % 5) * 55, 30 + Math.floor(i / 5) * 45); + this.source.addChild(ship); + this.ships.push(ship); } - return container; + this.liveTarget = new RenderTexture(320, 240); + this.live = new Sprite(this.liveTarget).setPosition(170, 220); + this.target = new RenderTexture(320, 240); + this.snapshot = new Sprite(this.target).setPosition(790, 220); + this.labels = [ + new Text('Live source', { fillColor: Color.white, fontSize: 32 }).setPosition(170, 160), + new Text('Frozen snapshot', { fillColor: Color.white, fontSize: 32 }).setPosition(790, 160), + ]; + this.hud = mountControls({ + title: 'Capture a Texture', + controls: [{ keys: 'Click', action: 'capture the current source frame' }], + status: 'Waiting for the source texture...', + hint: 'The left group keeps moving while the right texture stays frozen until the next click.', + }); + this.app.input.onPointerTap.add(() => { + this.captureRequested = true; + }); } - createRenderTexture(backend, container) { - const renderTexture = new RenderTexture(Math.ceil(container.width), Math.ceil(container.height)); - backend.setRenderTarget(renderTexture); - backend.clear(); - container.render(backend); - backend.setRenderTarget(null); - return renderTexture; + update(delta) { + this.time += delta; + for (let i = 0; i < this.ships.length; i++) { + this.ships[i].setRotation(this.time * (20 + (i % 5) * 12)); + } } draw(context) { - context.render(this.container); - context.render(this.renderSprite); + context.renderTo(this.source, { target: this.liveTarget, clear: Color.black }); + if (this.captureRequested && this.texture.ready) { + context.renderTo(this.source, { target: this.target, clear: Color.black }); + this.captureRequested = false; + this.captures++; + this.hud.setStatus(`Captures: ${this.captures}`); + } else if (this.texture.state === 'failed') { + this.hud.setStatus('The source texture failed to load.'); + } + context.render(this.live); + context.render(this.snapshot); + for (const label of this.labels) context.render(label); + } + destroy() { + this.liveTarget.destroy(); + this.target.destroy(); + super.destroy(); } } const app = new Application({ scenes: { RenderToTextureScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 18, 28), }); await app.start(RenderToTextureScene); diff --git a/examples/render-targets/render-to-texture.ts b/examples/render-targets/render-to-texture.ts index 0768bac27..62994974e 100644 --- a/examples/render-targets/render-to-texture.ts +++ b/examples/render-targets/render-to-texture.ts @@ -1,70 +1,95 @@ -import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs'; +import { + Application, + Color, + Container, + FixedResolutionCanvasSizing, + type RenderingContext, + RenderTexture, + Scene, + type Seconds, + Sprite, + Text, + Texture, +} from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; class RenderToTextureScene extends Scene { - private container!: Container; - private renderTexture!: RenderTexture; - private renderSprite!: Sprite; + private source!: Container; + private texture!: Texture; + private liveTarget!: RenderTexture; + private live!: Sprite; + private ships: Sprite[] = []; + private target!: RenderTexture; + private snapshot!: Sprite; + private labels!: Text[]; + private captureRequested = true; + private captures = 0; + private hud!: ReturnType; + private time = 0; override init(): void { - const app = this.app; - const { width, height } = app; - - this.container = this.createBunnyContainer(this.loader.get('image/ship-a.png')); - - this.renderTexture = this.createRenderTexture(app.backend, this.container); - - this.renderSprite = new Sprite(this.renderTexture); - this.renderSprite.setPosition(width, height); - this.renderSprite.setAnchor(1, 1); - } - - private createBunnyContainer(texture: Texture): Container { - const container = new Container(); - + this.texture = this.loader.get(assets.demo.textures.shipA); + this.source = new Container(); for (let i = 0; i < 25; i++) { - const bunny = new Sprite(texture); - - bunny.setAnchor(0.5, 0.5); - bunny.setPosition(25 + (i % 5) * 30, 25 + Math.floor(i / 5) * 30); - bunny.setRotation(Math.random() * 360); - - container.addChild(bunny); + const ship = new Sprite(this.texture) + .setAnchor(0.5) + .setScale(2) + .setPosition(50 + (i % 5) * 55, 30 + Math.floor(i / 5) * 45); + this.source.addChild(ship); + this.ships.push(ship); } - - return container; + this.liveTarget = new RenderTexture(320, 240); + this.live = new Sprite(this.liveTarget).setPosition(170, 220); + this.target = new RenderTexture(320, 240); + this.snapshot = new Sprite(this.target).setPosition(790, 220); + this.labels = [ + new Text('Live source', { fillColor: Color.white, fontSize: 32 }).setPosition(170, 160), + new Text('Frozen snapshot', { fillColor: Color.white, fontSize: 32 }).setPosition(790, 160), + ]; + this.hud = mountControls({ + title: 'Capture a Texture', + controls: [{ keys: 'Click', action: 'capture the current source frame' }], + status: 'Waiting for the source texture...', + hint: 'The left group keeps moving while the right texture stays frozen until the next click.', + }); + this.app.input.onPointerTap.add(() => { + this.captureRequested = true; + }); } - private createRenderTexture(backend: Application['backend'], container: Container): RenderTexture { - const renderTexture = new RenderTexture(Math.ceil(container.width), Math.ceil(container.height)); - - backend.setRenderTarget(renderTexture); - - backend.clear(); - container.render(backend); - - backend.setRenderTarget(null); - - return renderTexture; + override update(delta: Seconds): void { + this.time += delta; + for (let i = 0; i < this.ships.length; i++) { + this.ships[i].setRotation(this.time * (20 + (i % 5) * 12)); + } } override draw(context: RenderingContext): void { - context.render(this.container); - context.render(this.renderSprite); + context.renderTo(this.source, { target: this.liveTarget, clear: Color.black }); + if (this.captureRequested && this.texture.ready) { + context.renderTo(this.source, { target: this.target, clear: Color.black }); + this.captureRequested = false; + this.captures++; + this.hud.setStatus(`Captures: ${this.captures}`); + } else if (this.texture.state === 'failed') { + this.hud.setStatus('The source texture failed to load.'); + } + context.render(this.live); + context.render(this.snapshot); + for (const label of this.labels) context.render(label); + } + + override destroy(): void { + this.liveTarget.destroy(); + this.target.destroy(); + super.destroy(); } } const app = new Application({ scenes: { RenderToTextureScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, - clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(12, 18, 28), }); await app.start(RenderToTextureScene); diff --git a/examples/render-targets/trail-feedback.js b/examples/render-targets/trail-feedback.js index 414f99128..262a40910 100644 --- a/examples/render-targets/trail-feedback.js +++ b/examples/render-targets/trail-feedback.js @@ -20,6 +20,9 @@ class TrailFeedbackScene extends Scene { bunny; pipeAtoB; pipeBtoA; + decayA; + decayB; + decayTint = new Color(255, 255, 255); forward = true; time = 0; init() { @@ -28,9 +31,8 @@ class TrailFeedbackScene extends Scene { this.rtA = new RenderTexture(width, height); this.rtB = new RenderTexture(width, height); 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)); + this.decayA = new Sprite(this.rtA); + this.decayB = new Sprite(this.rtB); const showA = new Sprite(this.rtA); const showB = new Sprite(this.rtB); // Read A → write B → show B. @@ -39,7 +41,7 @@ class TrailFeedbackScene extends Scene { new CallbackRenderPass( context => { context.backend.clear(); - context.render(decayA); + context.render(this.decayA); context.render(this.bunny); }, { target: this.rtB }, @@ -52,7 +54,7 @@ class TrailFeedbackScene extends Scene { new CallbackRenderPass( context => { context.backend.clear(); - context.render(decayB); + context.render(this.decayB); context.render(this.bunny); }, { target: this.rtA }, @@ -64,6 +66,9 @@ class TrailFeedbackScene extends Scene { const app = this.app; const { width, height } = app; this.time += delta; + this.decayTint.set(255, 255, 255, Math.pow(0.93, delta * 60)); + this.decayA.setTint(this.decayTint); + this.decayB.setTint(this.decayTint); this.bunny.setPosition(width / 2 + Math.cos(this.time * 2.0) * (width * 0.36), height / 2 + Math.sin(this.time * 2.7) * (height * 0.34)); } draw(context) { diff --git a/examples/render-targets/trail-feedback.ts b/examples/render-targets/trail-feedback.ts index 5c6e17a2c..f4f108e3a 100644 --- a/examples/render-targets/trail-feedback.ts +++ b/examples/render-targets/trail-feedback.ts @@ -22,6 +22,9 @@ class TrailFeedbackScene extends Scene { private bunny!: Sprite; private pipeAtoB!: RenderPipeline; private pipeBtoA!: RenderPipeline; + private decayA!: Sprite; + private decayB!: Sprite; + private readonly decayTint = new Color(255, 255, 255); private forward = true; private time = 0; @@ -33,9 +36,8 @@ class TrailFeedbackScene extends Scene { this.rtB = new RenderTexture(width, height); 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)); + this.decayA = new Sprite(this.rtA); + this.decayB = new Sprite(this.rtB); const showA = new Sprite(this.rtA); const showB = new Sprite(this.rtB); @@ -45,7 +47,7 @@ class TrailFeedbackScene extends Scene { new CallbackRenderPass( context => { context.backend.clear(); - context.render(decayA); + context.render(this.decayA); context.render(this.bunny); }, { target: this.rtB }, @@ -59,7 +61,7 @@ class TrailFeedbackScene extends Scene { new CallbackRenderPass( context => { context.backend.clear(); - context.render(decayB); + context.render(this.decayB); context.render(this.bunny); }, { target: this.rtA }, @@ -72,6 +74,9 @@ class TrailFeedbackScene extends Scene { const app = this.app; const { width, height } = app; this.time += delta; + this.decayTint.set(255, 255, 255, Math.pow(0.93, delta * 60)); + this.decayA.setTint(this.decayTint); + this.decayB.setTint(this.decayTint); this.bunny.setPosition(width / 2 + Math.cos(this.time * 2.0) * (width * 0.36), height / 2 + Math.sin(this.time * 2.7) * (height * 0.34)); } diff --git a/examples/render-targets/water-mirror.js b/examples/render-targets/water-mirror.js index 3834826f8..fae1d23c8 100644 --- a/examples/render-targets/water-mirror.js +++ b/examples/render-targets/water-mirror.js @@ -3,48 +3,80 @@ import { Application, CallbackRenderPass, Color, + ColorMatrixFilter, + DisplacementFilter, FixedResolutionCanvasSizing, + Graphics, RenderNodePass, RenderPipeline, RenderTexture, + ScaleModes, Scene, - ShaderFilter, Sprite, + Texture, + WrapModes, } from '@codexo/exojs'; -const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; uniform float uTime; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv; uv.y += sin(uv.x*18.0+uTime*2.8)*0.025; vec4 c=texture(uTexture,uv); fragColor=vec4(c.rgb*vec3(0.72,0.85,1.0),c.a*0.85); }`; -const wgsl = ` -@group(0) @binding(1) var uTexture:texture_2d; -@group(0) @binding(2) var uSampler:sampler; -struct Uniforms { uTime:f32 }; -@group(1) @binding(0) var uniforms:Uniforms; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ - var uv=vUv; uv.y = uv.y + sin(uv.x*18.0+uniforms.uTime*2.8)*0.025; - let c=textureSample(uTexture,uSampler,uv); return vec4(c.rgb*vec3(0.72,0.85,1.0),c.a*0.85); -}`; +import { mountControls } from '@examples/runtime'; +const MAP_SIZE = 256; +const createRippleMap = () => { + const canvas = document.createElement('canvas'); + canvas.width = MAP_SIZE; + canvas.height = MAP_SIZE; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + const image = context.createImageData(MAP_SIZE, MAP_SIZE); + for (let y = 0; y < MAP_SIZE; y++) { + for (let x = 0; x < MAP_SIZE; x++) { + const offset = (y * MAP_SIZE + x) * 4; + const u = (x / MAP_SIZE) * Math.PI * 2; + const v = (y / MAP_SIZE) * Math.PI * 2; + image.data[offset] = Math.round((Math.sin(v * 3) * 0.5 + 0.5) * 255); + image.data[offset + 1] = Math.round((Math.sin(u * 2) * 0.5 + 0.5) * 255); + image.data[offset + 3] = 255; + } + } + context.putImageData(image, 0, 0); + return new Texture(canvas, { scaleMode: ScaleModes.Linear, wrapMode: WrapModes.Repeat, generateMipMap: false }); +}; class WaterMirrorScene extends Scene { - rt; + target; + map; source; mirror; - filter; + ripple; + tint; + waterline; pipeline; - time = 0; + hud; + dragging = false; + onPointerDown = (_pointer, x, y) => { + if (y < this.app.height / 2) return; + this.dragging = true; + this.setRippleStrength(x); + }; + onPointerMove = (_pointer, x) => { + if (this.dragging) this.setRippleStrength(x); + }; + onPointerEnd = () => { + this.dragging = false; + }; init() { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; const half = height / 2; - this.rt = new RenderTexture(width, half); + this.target = new RenderTexture(width, half); + this.map = createRippleMap(); 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 = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0 } }); - this.mirror.filters = [this.filter]; - // Capture the source into a target (camera view → a callback), then composite the source and - // its filtered, flipped mirror to the screen. + .setScale(5); + this.mirror = new Sprite(this.target).setPosition(0, height).setScale(1, -1); + this.ripple = new DisplacementFilter({ map: this.map, scale: 24 }); + this.tint = new ColorMatrixFilter().tint(new Color(130, 195, 235)); + this.mirror.filters = [this.ripple, this.tint]; + this.waterline = new Graphics(); + this.waterline.lineColor = new Color(100, 205, 245); + this.waterline.lineWidth = 3; + this.waterline.drawLine(0, half, width, half); this.pipeline = new RenderPipeline() .addPass( new CallbackRenderPass( @@ -52,28 +84,50 @@ class WaterMirrorScene extends Scene { context.backend.clear(); context.render(this.source); }, - { target: this.rt }, + { target: this.target }, ), ) .addPass(new RenderNodePass(this.source, { clear: new Color(18, 24, 36) })) .addPass(new RenderNodePass(this.mirror)); + this.hud = mountControls({ + title: 'Water Reflection and Distortion', + controls: [{ keys: 'Drag on water', action: 'change ripple strength' }], + status: 'Ripple strength: 24', + hint: 'The upper scene is captured into a RenderTexture, flipped, then displaced by a scrolling direction map. Drag across the reflection to change its strength.', + }); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); + } + setRippleStrength(x) { + const strength = Math.round(Math.max(0, Math.min(1, x / this.app.width)) * 60); + this.ripple.setScale(strength); + this.hud.setStatus(`Ripple strength: ${strength}`); } update(delta) { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; + const time = this.app.activeSeconds; const quarter = height / 4; - this.time += delta; - this.source.setPosition(width / 2 + Math.cos(this.time * 1.7) * (width * 0.3), quarter + Math.sin(this.time * 1.3) * (quarter * 0.55)); - this.filter.setUniform('uTime', this.time); + this.source.setPosition(width / 2 + Math.cos(time * 1.7) * (width * 0.3), quarter + Math.sin(time * 1.3) * (quarter * 0.55)); + this.ripple.offsetU += delta * 0.08; + this.ripple.offsetV += delta * 0.13; } draw(context) { this.pipeline.execute(context); + context.render(this.waterline); } destroy() { - // Pipeline cascades destroy() to its passes; the caller-owned target and shader filter are freed here. + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); this.pipeline.destroy(); - this.rt.destroy(); - this.filter.destroy(); + this.target.destroy(); + this.ripple.destroy(); + this.tint.destroy(); + this.map.destroy(); super.destroy(); } } @@ -86,8 +140,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(WaterMirrorScene); diff --git a/examples/render-targets/water-mirror.ts b/examples/render-targets/water-mirror.ts index fc7ecf994..d0e2abf52 100644 --- a/examples/render-targets/water-mirror.ts +++ b/examples/render-targets/water-mirror.ts @@ -2,55 +2,89 @@ import { Application, CallbackRenderPass, Color, + ColorMatrixFilter, + DisplacementFilter, FixedResolutionCanvasSizing, + Graphics, type RenderingContext, RenderNodePass, RenderPipeline, RenderTexture, + ScaleModes, Scene, type Seconds, - ShaderFilter, Sprite, + Texture, + WrapModes, } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; -const glsl = `#version 300 es -precision mediump float; uniform sampler2D uTexture; uniform float uTime; in vec2 vUv; out vec4 fragColor; -void main(){ vec2 uv=vUv; uv.y += sin(uv.x*18.0+uTime*2.8)*0.025; vec4 c=texture(uTexture,uv); fragColor=vec4(c.rgb*vec3(0.72,0.85,1.0),c.a*0.85); }`; -const wgsl = ` -@group(0) @binding(1) var uTexture:texture_2d; -@group(0) @binding(2) var uSampler:sampler; -struct Uniforms { uTime:f32 }; -@group(1) @binding(0) var uniforms:Uniforms; -@fragment fn fragmentMain(@location(0) vUv:vec2)->@location(0) vec4{ - var uv=vUv; uv.y = uv.y + sin(uv.x*18.0+uniforms.uTime*2.8)*0.025; - let c=textureSample(uTexture,uSampler,uv); return vec4(c.rgb*vec3(0.72,0.85,1.0),c.a*0.85); -}`; +const MAP_SIZE = 256; + +const createRippleMap = (): Texture => { + const canvas = document.createElement('canvas'); + canvas.width = MAP_SIZE; + canvas.height = MAP_SIZE; + const context = canvas.getContext('2d'); + if (!context) throw new Error('2D canvas context unavailable.'); + + const image = context.createImageData(MAP_SIZE, MAP_SIZE); + for (let y = 0; y < MAP_SIZE; y++) { + for (let x = 0; x < MAP_SIZE; x++) { + const offset = (y * MAP_SIZE + x) * 4; + const u = (x / MAP_SIZE) * Math.PI * 2; + const v = (y / MAP_SIZE) * Math.PI * 2; + image.data[offset] = Math.round((Math.sin(v * 3) * 0.5 + 0.5) * 255); + image.data[offset + 1] = Math.round((Math.sin(u * 2) * 0.5 + 0.5) * 255); + image.data[offset + 3] = 255; + } + } + context.putImageData(image, 0, 0); + return new Texture(canvas, { scaleMode: ScaleModes.Linear, wrapMode: WrapModes.Repeat, generateMipMap: false }); +}; class WaterMirrorScene extends Scene { - private rt!: RenderTexture; + private target!: RenderTexture; + private map!: Texture; private source!: Sprite; private mirror!: Sprite; - private filter!: ShaderFilter; + private ripple!: DisplacementFilter; + private tint!: ColorMatrixFilter; + private waterline!: Graphics; private pipeline!: RenderPipeline; - private time = 0; + private hud!: ReturnType; + private dragging = false; + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + if (y < this.app.height / 2) return; + this.dragging = true; + this.setRippleStrength(x); + }; + private readonly onPointerMove = (_pointer: unknown, x: number): void => { + if (this.dragging) this.setRippleStrength(x); + }; + private readonly onPointerEnd = (): void => { + this.dragging = false; + }; override init(): void { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; const half = height / 2; - - this.rt = new RenderTexture(width, half); + this.target = new RenderTexture(width, half); + this.map = createRippleMap(); 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 = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0 } }); - this.mirror.filters = [this.filter]; + .setScale(5); + this.mirror = new Sprite(this.target).setPosition(0, height).setScale(1, -1); + this.ripple = new DisplacementFilter({ map: this.map, scale: 24 }); + this.tint = new ColorMatrixFilter().tint(new Color(130, 195, 235)); + this.mirror.filters = [this.ripple, this.tint]; + + this.waterline = new Graphics(); + this.waterline.lineColor = new Color(100, 205, 245); + this.waterline.lineWidth = 3; + this.waterline.drawLine(0, half, width, half); - // Capture the source into a target (camera view → a callback), then composite the source and - // its filtered, flipped mirror to the screen. this.pipeline = new RenderPipeline() .addPass( new CallbackRenderPass( @@ -58,31 +92,55 @@ class WaterMirrorScene extends Scene { context.backend.clear(); context.render(this.source); }, - { target: this.rt }, + { target: this.target }, ), ) .addPass(new RenderNodePass(this.source, { clear: new Color(18, 24, 36) })) .addPass(new RenderNodePass(this.mirror)); + + this.hud = mountControls({ + title: 'Water Reflection and Distortion', + controls: [{ keys: 'Drag on water', action: 'change ripple strength' }], + status: 'Ripple strength: 24', + hint: 'The upper scene is captured into a RenderTexture, flipped, then displaced by a scrolling direction map. Drag across the reflection to change its strength.', + }); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerEnd); + this.app.input.onPointerCancel.add(this.onPointerEnd); + } + + private setRippleStrength(x: number): void { + const strength = Math.round(Math.max(0, Math.min(1, x / this.app.width)) * 60); + this.ripple.setScale(strength); + this.hud.setStatus(`Ripple strength: ${strength}`); } override update(delta: Seconds): void { - const app = this.app; - const { width, height } = app; + const { width, height } = this.app; + const time = this.app.activeSeconds; const quarter = height / 4; - this.time += delta; - this.source.setPosition(width / 2 + Math.cos(this.time * 1.7) * (width * 0.3), quarter + Math.sin(this.time * 1.3) * (quarter * 0.55)); - this.filter.setUniform('uTime', this.time); + this.source.setPosition(width / 2 + Math.cos(time * 1.7) * (width * 0.3), quarter + Math.sin(time * 1.3) * (quarter * 0.55)); + this.ripple.offsetU += delta * 0.08; + this.ripple.offsetV += delta * 0.13; } override draw(context: RenderingContext): void { this.pipeline.execute(context); + context.render(this.waterline); } override destroy(): void { - // Pipeline cascades destroy() to its passes; the caller-owned target and shader filter are freed here. + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerEnd); + this.app.input.onPointerCancel.remove(this.onPointerEnd); + this.hud.dispose(); this.pipeline.destroy(); - this.rt.destroy(); - this.filter.destroy(); + this.target.destroy(); + this.ripple.destroy(); + this.tint.destroy(); + this.map.destroy(); super.destroy(); } } @@ -96,9 +154,7 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(WaterMirrorScene); diff --git a/examples/scene-graph/nested-transforms.js b/examples/scene-graph/nested-transforms.js index 3007456e6..3cf2deeaa 100644 --- a/examples/scene-graph/nested-transforms.js +++ b/examples/scene-graph/nested-transforms.js @@ -1,11 +1,12 @@ // Auto-generated from nested-transforms.ts - edit the .ts source, not this file. -import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; +import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; class NestedTransformsScene extends Scene { sun; planetOrbit; planet; moonOrbit; moon; + readout; init() { const app = this.app; const { width, height } = app; @@ -13,27 +14,42 @@ class NestedTransformsScene extends Scene { this.sun.fillColor = new Color(255, 220, 90); this.sun.drawCircle(0, 0, 30); this.planetOrbit = new Container().setPosition(width / 2, height / 2); + const planetPath = new Graphics(); + planetPath.lineWidth = 2; + planetPath.lineColor = new Color(60, 88, 122); + planetPath.drawCircle(0, 0, 220); this.planet = new Graphics(); this.planet.fillColor = new Color(120, 190, 255); this.planet.drawCircle(0, 0, 16); this.planet.setPosition(220, 0); this.moonOrbit = new Container().setPosition(220, 0); + const moonPath = new Graphics(); + moonPath.lineWidth = 2; + moonPath.lineColor = new Color(88, 98, 118); + moonPath.drawCircle(0, 0, 44); this.moon = new Graphics(); this.moon.fillColor = new Color(220, 220, 220); this.moon.drawCircle(0, 0, 8); this.moon.setPosition(44, 0); + this.planetOrbit.addChild(planetPath); this.planetOrbit.addChild(this.sun); this.planetOrbit.addChild(this.planet); this.planetOrbit.addChild(this.moonOrbit); + this.moonOrbit.addChild(moonPath); this.moonOrbit.addChild(this.moon); + this.readout = new Text('', { fillColor: Color.white, fontSize: 17 }); + this.readout.setPosition(18, 18); } update(delta) { this.planetOrbit.rotate(delta * 30); - this.planet.rotate(delta * 120); this.moonOrbit.rotate(delta * 180); + const planetWorld = this.planet.getWorldTransform(); + const moonWorld = this.moon.getWorldTransform(); + this.readout.text = `Planet local ${this.planet.x.toFixed(0)}, ${this.planet.y.toFixed(0)} | world ${planetWorld.x.toFixed(0)}, ${planetWorld.y.toFixed(0)}\nMoon local ${this.moon.x.toFixed(0)}, ${this.moon.y.toFixed(0)} | world ${moonWorld.x.toFixed(0)}, ${moonWorld.y.toFixed(0)}`; } draw(context) { context.render(this.planetOrbit); + context.render(this.readout, { view: context.screenView }); } } const app = new Application({ diff --git a/examples/scene-graph/nested-transforms.ts b/examples/scene-graph/nested-transforms.ts index 573a1469c..a5322f497 100644 --- a/examples/scene-graph/nested-transforms.ts +++ b/examples/scene-graph/nested-transforms.ts @@ -1,4 +1,4 @@ -import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; +import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs'; class NestedTransformsScene extends Scene { private sun!: Graphics; @@ -6,6 +6,7 @@ class NestedTransformsScene extends Scene { private planet!: Graphics; private moonOrbit!: Container; private moon!: Graphics; + private readout!: Text; override init(): void { const app = this.app; @@ -16,31 +17,46 @@ class NestedTransformsScene extends Scene { this.sun.drawCircle(0, 0, 30); this.planetOrbit = new Container().setPosition(width / 2, height / 2); + const planetPath = new Graphics(); + planetPath.lineWidth = 2; + planetPath.lineColor = new Color(60, 88, 122); + planetPath.drawCircle(0, 0, 220); this.planet = new Graphics(); this.planet.fillColor = new Color(120, 190, 255); this.planet.drawCircle(0, 0, 16); this.planet.setPosition(220, 0); this.moonOrbit = new Container().setPosition(220, 0); + const moonPath = new Graphics(); + moonPath.lineWidth = 2; + moonPath.lineColor = new Color(88, 98, 118); + moonPath.drawCircle(0, 0, 44); this.moon = new Graphics(); this.moon.fillColor = new Color(220, 220, 220); this.moon.drawCircle(0, 0, 8); this.moon.setPosition(44, 0); + this.planetOrbit.addChild(planetPath); this.planetOrbit.addChild(this.sun); this.planetOrbit.addChild(this.planet); this.planetOrbit.addChild(this.moonOrbit); + this.moonOrbit.addChild(moonPath); this.moonOrbit.addChild(this.moon); + this.readout = new Text('', { fillColor: Color.white, fontSize: 17 }); + this.readout.setPosition(18, 18); } override update(delta: Seconds): void { this.planetOrbit.rotate(delta * 30); - this.planet.rotate(delta * 120); this.moonOrbit.rotate(delta * 180); + const planetWorld = this.planet.getWorldTransform(); + const moonWorld = this.moon.getWorldTransform(); + this.readout.text = `Planet local ${this.planet.x.toFixed(0)}, ${this.planet.y.toFixed(0)} | world ${planetWorld.x.toFixed(0)}, ${planetWorld.y.toFixed(0)}\nMoon local ${this.moon.x.toFixed(0)}, ${this.moon.y.toFixed(0)} | world ${moonWorld.x.toFixed(0)}, ${moonWorld.y.toFixed(0)}`; } override draw(context: RenderingContext): void { context.render(this.planetOrbit); + context.render(this.readout, { view: context.screenView }); } } diff --git a/examples/scene-graph/parallax-starfield.js b/examples/scene-graph/parallax-starfield.js index 7e6c40997..6196a7669 100644 --- a/examples/scene-graph/parallax-starfield.js +++ b/examples/scene-graph/parallax-starfield.js @@ -1,11 +1,17 @@ // Auto-generated from parallax-starfield.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; const speeds = [0.15, 0.35, 0.6]; const counts = [120, 80, 48]; const colors = [new Color(120, 140, 200), new Color(170, 190, 255), new Color(255, 255, 255)]; class ParallaxStarfieldScene extends Scene { layers; pointer = { x: 0, y: 0 }; + hud; + onPointerMove = pointer => { + this.pointer.x = pointer.x; + this.pointer.y = pointer.y; + }; init() { const app = this.app; const { width, height } = app; @@ -22,9 +28,12 @@ class ParallaxStarfieldScene extends Scene { } return g; }); - app.input.onPointerMove.add(pointer => { - this.pointer = { x: pointer.x, y: pointer.y }; + this.hud = mountControls({ + title: 'Parallax Layers', + controls: [{ keys: 'Move pointer', action: 'shift the three depth layers' }], + hint: 'Near stars travel farther than distant stars for the same pointer movement.', }); + app.input.onPointerMove.add(this.onPointerMove); } draw(context) { const app = this.app; @@ -36,6 +45,11 @@ class ParallaxStarfieldScene extends Scene { context.render(layer); } } + destroy() { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { ParallaxStarfieldScene }, diff --git a/examples/scene-graph/parallax-starfield.ts b/examples/scene-graph/parallax-starfield.ts index be4cd2143..475b64095 100644 --- a/examples/scene-graph/parallax-starfield.ts +++ b/examples/scene-graph/parallax-starfield.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; const speeds = [0.15, 0.35, 0.6]; const counts = [120, 80, 48]; @@ -7,6 +8,11 @@ const colors = [new Color(120, 140, 200), new Color(170, 190, 255), new Color(25 class ParallaxStarfieldScene extends Scene { private layers!: Graphics[]; private pointer = { x: 0, y: 0 }; + private hud!: ReturnType; + private readonly onPointerMove = (pointer: { x: number; y: number }): void => { + this.pointer.x = pointer.x; + this.pointer.y = pointer.y; + }; override init(): void { const app = this.app; @@ -27,9 +33,12 @@ class ParallaxStarfieldScene extends Scene { return g; }); - app.input.onPointerMove.add(pointer => { - this.pointer = { x: pointer.x, y: pointer.y }; + this.hud = mountControls({ + title: 'Parallax Layers', + controls: [{ keys: 'Move pointer', action: 'shift the three depth layers' }], + hint: 'Near stars travel farther than distant stars for the same pointer movement.', }); + app.input.onPointerMove.add(this.onPointerMove); } override draw(context: RenderingContext): void { @@ -42,6 +51,12 @@ class ParallaxStarfieldScene extends Scene { context.render(layer); } } + + override destroy(): void { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/scene-graph/pivot-and-anchor.js b/examples/scene-graph/pivot-and-anchor.js index 8be5595d3..9851386d1 100644 --- a/examples/scene-graph/pivot-and-anchor.js +++ b/examples/scene-graph/pivot-and-anchor.js @@ -1,5 +1,6 @@ // Auto-generated from pivot-and-anchor.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite, Text } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; const modes = [ { name: 'corner', anchor: [0, 0], origin: [0, 0] }, { name: 'center', anchor: [0.5, 0.5], origin: null }, @@ -9,8 +10,8 @@ class PivotAndAnchorScene extends Scene { sprite; pivotMarker; label; - mode = 0; - timer = 0; + hud; + panel; init() { const app = this.app; const { width, height } = app; @@ -18,32 +19,46 @@ class PivotAndAnchorScene extends Scene { this.pivotMarker = new Graphics(); this.label = new Text('', { fillColor: Color.white, fontSize: 18 }); this.label.setPosition(20, 20); - this.applyMode(); + this.hud = mountControls({ title: 'Pivot and Anchor', controls: [{ keys: 'Preset', action: 'compare rotation around each origin' }] }); + this.panel = mountControlPanel({ title: 'Origin presets' }); + modes.forEach((mode, index) => this.panel.addButton({ label: mode.name, onClick: () => this.applyMode(index) })); + this.applyMode(0); } - applyMode() { - const mode = modes[this.mode]; + applyMode(index) { + const mode = modes[index]; this.sprite.setAnchor(mode.anchor[0], mode.anchor[1]); - if (mode.origin) this.sprite.setOrigin(mode.origin[0], mode.origin[1]); - this.label.text = `mode: ${mode.name}`; + const bounds = this.sprite.getLocalBounds(); + this.sprite.setOrigin(mode.origin?.[0] ?? bounds.width * mode.anchor[0], mode.origin?.[1] ?? bounds.height * mode.anchor[1]); + this.hud.setStatus(`${mode.name}: anchor (${mode.anchor.join(', ')}), origin (${this.sprite.origin.x.toFixed(0)}, ${this.sprite.origin.y.toFixed(0)})`); } update(delta) { - this.timer += delta; - this.sprite.rotate(delta * 90); - if (this.timer > 1.8) { - this.timer = 0; - this.mode = (this.mode + 1) % modes.length; - this.applyMode(); - } + this.sprite.rotate(delta * 45); } draw(context) { const m = this.sprite.getGlobalTransform(); + const bounds = this.sprite.getBounds(); context.render(this.sprite); this.pivotMarker.clear(); + this.pivotMarker.lineColor = new Color(110, 215, 245); + this.pivotMarker.lineWidth = 2; + this.pivotMarker.drawLine(bounds.x, bounds.y, bounds.x + bounds.width, bounds.y); + this.pivotMarker.drawLine(bounds.x + bounds.width, bounds.y, bounds.x + bounds.width, bounds.y + bounds.height); + this.pivotMarker.drawLine(bounds.x + bounds.width, bounds.y + bounds.height, bounds.x, bounds.y + bounds.height); + this.pivotMarker.drawLine(bounds.x, bounds.y + bounds.height, bounds.x, bounds.y); this.pivotMarker.fillColor = new Color(255, 80, 80); - this.pivotMarker.drawCircle(m.x, m.y, 5); + this.pivotMarker.drawCircle(m.x, m.y, 6); + this.label.text = `position (${this.sprite.x.toFixed(0)}, ${this.sprite.y.toFixed(0)}) bounds (${bounds.x.toFixed(0)}, ${bounds.y.toFixed(0)}, ${bounds.width.toFixed(0)}, ${bounds.height.toFixed(0)})`; context.render(this.pivotMarker); context.render(this.label); } + destroy() { + this.panel?.dispose(); + this.hud?.dispose(); + this.sprite?.destroy(); + this.pivotMarker?.destroy(); + this.label?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { PivotAndAnchorScene }, diff --git a/examples/scene-graph/pivot-and-anchor.ts b/examples/scene-graph/pivot-and-anchor.ts index 0a1fd72a6..234b759c9 100644 --- a/examples/scene-graph/pivot-and-anchor.ts +++ b/examples/scene-graph/pivot-and-anchor.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, Sprite, Text } from '@codexo/exojs'; +import { mountControlPanel, mountControls } from '@examples/runtime'; const modes = [ { name: 'corner', anchor: [0, 0] as [number, number], origin: [0, 0] as [number, number] | null }, @@ -10,8 +11,8 @@ class PivotAndAnchorScene extends Scene { private sprite!: Sprite; private pivotMarker!: Graphics; private label!: Text; - private mode = 0; - private timer = 0; + private hud!: ReturnType; + private panel!: ReturnType; override init(): void { const app = this.app; @@ -21,35 +22,50 @@ class PivotAndAnchorScene extends Scene { this.pivotMarker = new Graphics(); this.label = new Text('', { fillColor: Color.white, fontSize: 18 }); this.label.setPosition(20, 20); - this.applyMode(); + this.hud = mountControls({ title: 'Pivot and Anchor', controls: [{ keys: 'Preset', action: 'compare rotation around each origin' }] }); + this.panel = mountControlPanel({ title: 'Origin presets' }); + modes.forEach((mode, index) => this.panel.addButton({ label: mode.name, onClick: () => this.applyMode(index) })); + this.applyMode(0); } - private applyMode(): void { - const mode = modes[this.mode]; + private applyMode(index: number): void { + const mode = modes[index]; this.sprite.setAnchor(mode.anchor[0], mode.anchor[1]); - if (mode.origin) this.sprite.setOrigin(mode.origin[0], mode.origin[1]); - this.label.text = `mode: ${mode.name}`; + const bounds = this.sprite.getLocalBounds(); + this.sprite.setOrigin(mode.origin?.[0] ?? bounds.width * mode.anchor[0], mode.origin?.[1] ?? bounds.height * mode.anchor[1]); + this.hud.setStatus(`${mode.name}: anchor (${mode.anchor.join(', ')}), origin (${this.sprite.origin.x.toFixed(0)}, ${this.sprite.origin.y.toFixed(0)})`); } override update(delta: Seconds): void { - this.timer += delta; - this.sprite.rotate(delta * 90); - if (this.timer > 1.8) { - this.timer = 0; - this.mode = (this.mode + 1) % modes.length; - this.applyMode(); - } + this.sprite.rotate(delta * 45); } override draw(context: RenderingContext): void { const m = this.sprite.getGlobalTransform(); + const bounds = this.sprite.getBounds(); context.render(this.sprite); this.pivotMarker.clear(); + this.pivotMarker.lineColor = new Color(110, 215, 245); + this.pivotMarker.lineWidth = 2; + this.pivotMarker.drawLine(bounds.x, bounds.y, bounds.x + bounds.width, bounds.y); + this.pivotMarker.drawLine(bounds.x + bounds.width, bounds.y, bounds.x + bounds.width, bounds.y + bounds.height); + this.pivotMarker.drawLine(bounds.x + bounds.width, bounds.y + bounds.height, bounds.x, bounds.y + bounds.height); + this.pivotMarker.drawLine(bounds.x, bounds.y + bounds.height, bounds.x, bounds.y); this.pivotMarker.fillColor = new Color(255, 80, 80); - this.pivotMarker.drawCircle(m.x, m.y, 5); + this.pivotMarker.drawCircle(m.x, m.y, 6); + this.label.text = `position (${this.sprite.x.toFixed(0)}, ${this.sprite.y.toFixed(0)}) bounds (${bounds.x.toFixed(0)}, ${bounds.y.toFixed(0)}, ${bounds.width.toFixed(0)}, ${bounds.height.toFixed(0)})`; context.render(this.pivotMarker); context.render(this.label); } + + override destroy(): void { + this.panel?.dispose(); + this.hud?.dispose(); + this.sprite?.destroy(); + this.pivotMarker?.destroy(); + this.label?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/showcase/audio-visualisation.js b/examples/showcase/audio-visualisation.js index 7b1d63505..32c594019 100644 --- a/examples/showcase/audio-visualisation.js +++ b/examples/showcase/audio-visualisation.js @@ -2,6 +2,7 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Texture } from '@codexo/exojs'; import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; +const BAND_LABELS = ['Sub', 'Bass', 'Low mid', 'Mid', 'High mid', 'Presence', 'Treble', 'Air']; class AudioVisualisationScene extends Scene { music; musicVoice; @@ -15,6 +16,7 @@ class AudioVisualisationScene extends Scene { screen; hud; tapPrompt; + bandEdges = []; async load() { const app = this.app; const { width, height } = app; @@ -26,6 +28,9 @@ class AudioVisualisationScene extends Scene { // beat-pulse ring. Both read the music bus the stream plays through, // without altering playback. this.analyser = new AudioAnalyser({ source: app.audio.music }); + for (let i = 0; i <= BAND_LABELS.length; i++) { + this.bandEdges.push(Math.round(Math.pow(this.analyser.frequencyBinCount, i / BAND_LABELS.length))); + } this.detector = new BeatDetector(); this.detector.source = app.audio.music; this.canvas = document.createElement('canvas'); @@ -46,10 +51,10 @@ class AudioVisualisationScene extends Scene { this.texture = new Texture(this.canvas); this.screen = new Sprite(this.texture); this.hud = mountControls({ - title: 'Audio Visualisation', + title: 'Spectrum and Waveform', controls: [{ keys: 'Click', action: 'play / pause' }], status: 'Playing…', - hint: 'Frequency bars, waveform, and a beat-pulse ring — all driven by live AudioAnalyser + BeatDetector data.', + hint: 'Live spectrum, waveform, eight frequency bands, and a beat pulse from the music bus.', }); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the queued music starts. @@ -114,6 +119,27 @@ class AudioVisualisationScene extends Scene { // restore the default stroke style for the next frame's waveform. this.context.strokeStyle = '#fff'; this.context.lineWidth = 4; + const bandWidth = width / BAND_LABELS.length; + const bandBase = height - 28; + const bandHeight = 92; + this.context.fillStyle = 'rgba(8, 13, 24, 0.82)'; + this.context.fillRect(0, height - 142, width, 142); + this.context.textAlign = 'center'; + this.context.font = '17px sans-serif'; + for (let band = 0; band < BAND_LABELS.length; band++) { + const start = this.bandEdges[band]; + const end = Math.max(start + 1, this.bandEdges[band + 1]); + let sum = 0; + for (let bin = start; bin < end; bin++) sum += freqData[bin]; + const level = sum / ((end - start) * 255); + const x = band * bandWidth + bandWidth / 2; + this.context.fillStyle = '#283342'; + this.context.fillRect(x - 24, bandBase - bandHeight, 48, bandHeight); + this.context.fillStyle = '#70d6ff'; + this.context.fillRect(x - 24, bandBase - bandHeight * level, 48, bandHeight * level); + this.context.fillStyle = '#dbe9f5'; + this.context.fillText(BAND_LABELS[band], x, height - 7); + } this.screen.updateTexture(); context.render(this.screen); if (app.audio.locked) { diff --git a/examples/showcase/audio-visualisation.ts b/examples/showcase/audio-visualisation.ts index a6bb8c43f..435e70ab6 100644 --- a/examples/showcase/audio-visualisation.ts +++ b/examples/showcase/audio-visualisation.ts @@ -16,6 +16,8 @@ import { import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; import { mountControls } from '@examples/runtime'; +const BAND_LABELS = ['Sub', 'Bass', 'Low mid', 'Mid', 'High mid', 'Presence', 'Treble', 'Air']; + class AudioVisualisationScene extends Scene { private music!: AudioStream; private musicVoice!: Voice & Pausable & Seekable; @@ -29,6 +31,7 @@ class AudioVisualisationScene extends Scene { private screen!: Sprite; private hud!: ReturnType; private tapPrompt!: Text; + private bandEdges: number[] = []; override async load(): Promise { const app = this.app; @@ -43,6 +46,9 @@ class AudioVisualisationScene extends Scene { // beat-pulse ring. Both read the music bus the stream plays through, // without altering playback. this.analyser = new AudioAnalyser({ source: app.audio.music }); + for (let i = 0; i <= BAND_LABELS.length; i++) { + this.bandEdges.push(Math.round(Math.pow(this.analyser.frequencyBinCount, i / BAND_LABELS.length))); + } this.detector = new BeatDetector(); this.detector.source = app.audio.music; @@ -68,10 +74,10 @@ class AudioVisualisationScene extends Scene { this.screen = new Sprite(this.texture); this.hud = mountControls({ - title: 'Audio Visualisation', + title: 'Spectrum and Waveform', controls: [{ keys: 'Click', action: 'play / pause' }], status: 'Playing…', - hint: 'Frequency bars, waveform, and a beat-pulse ring — all driven by live AudioAnalyser + BeatDetector data.', + hint: 'Live spectrum, waveform, eight frequency bands, and a beat pulse from the music bus.', }); // Shown while the browser still blocks audio (`app.audio.locked`); the @@ -151,6 +157,28 @@ class AudioVisualisationScene extends Scene { this.context.strokeStyle = '#fff'; this.context.lineWidth = 4; + const bandWidth = width / BAND_LABELS.length; + const bandBase = height - 28; + const bandHeight = 92; + this.context.fillStyle = 'rgba(8, 13, 24, 0.82)'; + this.context.fillRect(0, height - 142, width, 142); + this.context.textAlign = 'center'; + this.context.font = '17px sans-serif'; + for (let band = 0; band < BAND_LABELS.length; band++) { + const start = this.bandEdges[band]; + const end = Math.max(start + 1, this.bandEdges[band + 1]); + let sum = 0; + for (let bin = start; bin < end; bin++) sum += freqData[bin]; + const level = sum / ((end - start) * 255); + const x = band * bandWidth + bandWidth / 2; + this.context.fillStyle = '#283342'; + this.context.fillRect(x - 24, bandBase - bandHeight, 48, bandHeight); + this.context.fillStyle = '#70d6ff'; + this.context.fillRect(x - 24, bandBase - bandHeight * level, 48, bandHeight * level); + this.context.fillStyle = '#dbe9f5'; + this.context.fillText(BAND_LABELS[band], x, height - 7); + } + this.screen.updateTexture(); context.render(this.screen); diff --git a/examples/showcase/boss-intro-cinematic.js b/examples/showcase/boss-intro-cinematic.js index 4acfc2478..96b04e005 100644 --- a/examples/showcase/boss-intro-cinematic.js +++ b/examples/showcase/boss-intro-cinematic.js @@ -16,6 +16,7 @@ class BossIntroCinematicScene extends Scene { tapPrompt; width = 0; height = 0; + sequenceTweens = []; async load() { const app = this.app; const { width, height } = app; @@ -52,11 +53,11 @@ class BossIntroCinematicScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture; start the cinematic in lockstep with the sting on unlock. this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.2 }); - app.audio.onUnlock.add(() => this.playSequence()); + app.audio.onUnlock.add(this.playSequence); this.inputs.onTrigger(Keyboard.R, () => this.replay()); - app.input.onPointerDown.add(() => this.replay()); + app.input.onPointerDown.add(this.replay); } - replay() { + replay = () => { const app = this.app; if (app.audio.locked) { return; @@ -69,12 +70,11 @@ class BossIntroCinematicScene extends Scene { } this.playSequence(); this.hud.setStatus('Replaying…'); - } - playSequence() { - const app = this.app; + }; + playSequence = () => { const { width, height } = this; - // Wipe any in-flight tweens and reset the visible state to frame zero. - app.tweens.clear(); + for (const tween of this.sequenceTweens) tween.stop(); + this.sequenceTweens.length = 0; this.view.reset(width * 0.42, height / 2, width, height); this.view.clearShake(); this.barSize.v = 0; @@ -82,33 +82,33 @@ class BossIntroCinematicScene extends Scene { this.title.text = ''; this.boss.setScale(0.4); // Letterbox bars slam in. - app.tweens.create(this.barSize).to({ v: 84 }, 0.6).start(); + this.sequenceTweens.push(this.tweens.create(this.barSize).to({ v: 84 }, 0.6).start()); // Slow camera push-in toward the boss. - app.tweens - .create(this.view.center) - .to({ x: width * 0.55, y: height / 2 }, 2.0) - .start(); + this.sequenceTweens.push( + this.tweens + .create(this.view.center) + .to({ x: width * 0.55, y: height / 2 }, 2.0) + .start(), + ); // The boss looms larger as the camera arrives. - app.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start(); + this.sequenceTweens.push(this.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start()); // Typewriter title reveal - its onStart IS the reveal beat: punch a shake. - app.tweens - .create(this.titleState) - .to({ count: titleText.length }, 1.0) - .delay(1.6) - .onStart(() => { - this.view.shake(18, Time.seconds(0.52), { frequency: 24, decay: true }); - }) - .onUpdate(() => { - this.title.text = titleText.slice(0, this.titleState.count | 0); - }) - .start(); + this.sequenceTweens.push( + this.tweens + .create(this.titleState) + .to({ count: titleText.length }, 1.0) + .delay(1.6) + .onStart(() => { + this.view.shake(18, Time.seconds(0.52), { frequency: 24, decay: true }); + }) + .onUpdate(() => { + this.title.text = titleText.slice(0, this.titleState.count | 0); + }) + .start(), + ); // Music swells up under the reveal. - app.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start(); - } - update(delta) { - // Advance the camera shake (and follow/bounds) animation each frame. - this.view.update(delta * 1000); - } + this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start()); + }; draw(context) { const app = this.app; const { width, height } = this; @@ -116,20 +116,26 @@ class BossIntroCinematicScene extends Scene { this.bg.fillColor = new Color(36, 42, 70); // Span well past the view edges so the push-in never reveals a seam. this.bg.drawRectangle(-width * 0.25, 0, width * 1.5, height); - context.backend.setView(this.view); - context.render(this.bg); - context.render(this.boss); - context.backend.setView(null); - context.render(this.title); + context.render(this.bg, { view: this.view }); + context.render(this.boss, { view: this.view }); + context.render(this.title, { view: context.screenView }); this.bars.clear(); this.bars.fillColor = Color.black; this.bars.drawRectangle(0, 0, width, this.barSize.v); this.bars.drawRectangle(0, height - this.barSize.v, width, this.barSize.v); - context.render(this.bars); + context.render(this.bars, { view: context.screenView }); if (app.audio.locked) { - context.render(this.tapPrompt); + context.render(this.tapPrompt, { view: context.screenView }); } } + destroy() { + for (const tween of this.sequenceTweens) tween.stop(); + this.app.audio.onUnlock.remove(this.playSequence); + this.app.input.onPointerDown.remove(this.replay); + this.musicVoice?.stop(); + this.hud?.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { BossIntroCinematicScene }, diff --git a/examples/showcase/boss-intro-cinematic.ts b/examples/showcase/boss-intro-cinematic.ts index 09fd00dce..7c6adcf6e 100644 --- a/examples/showcase/boss-intro-cinematic.ts +++ b/examples/showcase/boss-intro-cinematic.ts @@ -9,7 +9,6 @@ import { type Pausable, type RenderingContext, Scene, - type Seconds, type Seekable, Sprite, Text, @@ -35,6 +34,7 @@ class BossIntroCinematicScene extends Scene { private tapPrompt!: Text; private width = 0; private height = 0; + private readonly sequenceTweens: { stop(): void }[] = []; override async load(): Promise { const app = this.app; @@ -76,13 +76,13 @@ class BossIntroCinematicScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture; start the cinematic in lockstep with the sting on unlock. this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.2 }) as Voice & Seekable & Pausable; - app.audio.onUnlock.add(() => this.playSequence()); + app.audio.onUnlock.add(this.playSequence); this.inputs.onTrigger(Keyboard.R, () => this.replay()); - app.input.onPointerDown.add(() => this.replay()); + app.input.onPointerDown.add(this.replay); } - private replay(): void { + private readonly replay = (): void => { const app = this.app; if (app.audio.locked) { return; @@ -96,14 +96,13 @@ class BossIntroCinematicScene extends Scene { } this.playSequence(); this.hud.setStatus('Replaying…'); - } + }; - private playSequence(): void { - const app = this.app; + private readonly playSequence = (): void => { const { width, height } = this; - // Wipe any in-flight tweens and reset the visible state to frame zero. - app.tweens.clear(); + for (const tween of this.sequenceTweens) tween.stop(); + this.sequenceTweens.length = 0; this.view.reset(width * 0.42, height / 2, width, height); this.view.clearShake(); this.barSize.v = 0; @@ -112,34 +111,33 @@ class BossIntroCinematicScene extends Scene { this.boss.setScale(0.4); // Letterbox bars slam in. - app.tweens.create(this.barSize).to({ v: 84 }, 0.6).start(); + this.sequenceTweens.push(this.tweens.create(this.barSize).to({ v: 84 }, 0.6).start()); // Slow camera push-in toward the boss. - app.tweens - .create(this.view.center) - .to({ x: width * 0.55, y: height / 2 }, 2.0) - .start(); + this.sequenceTweens.push( + this.tweens + .create(this.view.center) + .to({ x: width * 0.55, y: height / 2 }, 2.0) + .start(), + ); // The boss looms larger as the camera arrives. - app.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start(); + this.sequenceTweens.push(this.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start()); // Typewriter title reveal - its onStart IS the reveal beat: punch a shake. - app.tweens - .create(this.titleState) - .to({ count: titleText.length }, 1.0) - .delay(1.6) - .onStart(() => { - this.view.shake(18, Time.seconds(0.52), { frequency: 24, decay: true }); - }) - .onUpdate(() => { - this.title.text = titleText.slice(0, this.titleState.count | 0); - }) - .start(); + this.sequenceTweens.push( + this.tweens + .create(this.titleState) + .to({ count: titleText.length }, 1.0) + .delay(1.6) + .onStart(() => { + this.view.shake(18, Time.seconds(0.52), { frequency: 24, decay: true }); + }) + .onUpdate(() => { + this.title.text = titleText.slice(0, this.titleState.count | 0); + }) + .start(), + ); // Music swells up under the reveal. - app.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start(); - } - - override update(delta: Seconds): void { - // Advance the camera shake (and follow/bounds) animation each frame. - this.view.update(delta * 1000); - } + this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start()); + }; override draw(context: RenderingContext): void { const app = this.app; @@ -149,21 +147,28 @@ class BossIntroCinematicScene extends Scene { this.bg.fillColor = new Color(36, 42, 70); // Span well past the view edges so the push-in never reveals a seam. this.bg.drawRectangle(-width * 0.25, 0, width * 1.5, height); - context.backend.setView(this.view); - context.render(this.bg); - context.render(this.boss); - context.backend.setView(null); - context.render(this.title); + context.render(this.bg, { view: this.view }); + context.render(this.boss, { view: this.view }); + context.render(this.title, { view: context.screenView }); this.bars.clear(); this.bars.fillColor = Color.black; this.bars.drawRectangle(0, 0, width, this.barSize.v); this.bars.drawRectangle(0, height - this.barSize.v, width, this.barSize.v); - context.render(this.bars); + context.render(this.bars, { view: context.screenView }); if (app.audio.locked) { - context.render(this.tapPrompt); + context.render(this.tapPrompt, { view: context.screenView }); } } + + override destroy(): void { + for (const tween of this.sequenceTweens) tween.stop(); + this.app.audio.onUnlock.remove(this.playSequence); + this.app.input.onPointerDown.remove(this.replay); + this.musicVoice?.stop(); + this.hud?.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/showcase/dialog-system.js b/examples/showcase/dialog-system.js index 00b12635a..8ae1953be 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, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; const lines = [ { speaker: 'Commander Vale', text: 'Commander, the anomaly has entered low orbit.' }, @@ -8,7 +8,6 @@ const lines = [ ]; const choices = ['Hold formation', 'Burn the gates']; class DialogSystemScene extends Scene { - portrait; namePlate; box; choicePrompt; @@ -23,23 +22,16 @@ class DialogSystemScene extends Scene { init() { const app = this.app; const { width, height } = app; - // 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(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. + const textX = width * 0.16; this.namePlate = new Text(lines[0].speaker, { fillColor: new Color(255, 214, 120), fontSize: 26, fontWeight: 'bold' }); this.namePlate.setPosition(textX, height * 0.5); - this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.55 }); + this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.68 }); 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 = this.loader.get(assets.demo.sound.uiConfirm); this.hud = mountControls({ - title: 'Dialog System', + title: 'Dialogue and Choices', controls: [{ keys: 'Click', action: 'advance / reveal' }], hint: 'Click anywhere to skip the typewriter, then click again to continue.', }); @@ -107,7 +99,6 @@ class DialogSystemScene extends Scene { this.box.text = lines[this.lineIndex].text.slice(0, this.chars); } draw(context) { - context.render(this.portrait); context.render(this.namePlate); context.render(this.box); if (this.choicePrompt.visible) { diff --git a/examples/showcase/dialog-system.ts b/examples/showcase/dialog-system.ts index a5ba9cafb..d535c9942 100644 --- a/examples/showcase/dialog-system.ts +++ b/examples/showcase/dialog-system.ts @@ -1,4 +1,4 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sound, Sprite, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sound, Text } from '@codexo/exojs'; import { mountControlPanel, mountControls } from '@examples/runtime'; interface DialogLine { @@ -15,7 +15,6 @@ const lines: DialogLine[] = [ const choices = ['Hold formation', 'Burn the gates']; class DialogSystemScene extends Scene { - private portrait!: Sprite; private namePlate!: Text; private box!: Text; private choicePrompt!: Text; @@ -32,20 +31,12 @@ class DialogSystemScene extends Scene { const app = this.app; const { width, height } = app; - // 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(this.loader.get(assets.demo.textures.shipA)) - .setAnchor(0.5) - .setScale(2.4) - .setPosition(width * 0.16, height * 0.62); + const textX = width * 0.16; - 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' }); this.namePlate.setPosition(textX, height * 0.5); - this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.55 }); + this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.68 }); this.box.setPosition(textX, height * 0.56); this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); @@ -54,7 +45,7 @@ class DialogSystemScene extends Scene { this.beep = this.loader.get(assets.demo.sound.uiConfirm); this.hud = mountControls({ - title: 'Dialog System', + title: 'Dialogue and Choices', controls: [{ keys: 'Click', action: 'advance / reveal' }], hint: 'Click anywhere to skip the typewriter, then click again to continue.', }); @@ -133,7 +124,6 @@ class DialogSystemScene extends Scene { } override draw(context: RenderingContext): void { - context.render(this.portrait); context.render(this.namePlate); context.render(this.box); if (this.choicePrompt.visible) { diff --git a/examples/showcase/gamepad-spaceship.js b/examples/showcase/gamepad-spaceship.js index bec0b64e6..216951250 100644 --- a/examples/showcase/gamepad-spaceship.js +++ b/examples/showcase/gamepad-spaceship.js @@ -1,23 +1,33 @@ // Auto-generated from gamepad-spaceship.ts - edit the .ts source, not this file. import { + ActionMap, Application, AudioGenerator, + ButtonAction, Color, FixedResolutionCanvasSizing, GamepadAxis, GamepadButton, Graphics, + Keyboard, Scene, Sprite, Text, Vector, + VectorAction, } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; import { mountControls } from '@examples/runtime'; class GamepadSpaceshipScene extends Scene { ship; velocity = new Vector(0, 0); - thrust = new Vector(0, 0); + actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + fire: new ButtonAction([Keyboard.Space, GamepadButton.RightTrigger]), + }); facing = -Math.PI / 2; engine; bullets = []; @@ -27,7 +37,6 @@ class GamepadSpaceshipScene extends Scene { burst; pad = null; score = 0; - hasPad = false; connectPrompt; hud; init() { @@ -38,6 +47,7 @@ class GamepadSpaceshipScene extends Scene { .setScale(0.5) .setPosition(width / 2, height / 2); this.engine = app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }); + this.inputs.attach(this.actions); this.fx = new Graphics(); this.particles = new ParticleSystem(this.loader.get(assets.demo.textures.particleSpark), { capacity: 4000 }); this.systems.add(this.particles); @@ -54,24 +64,14 @@ class GamepadSpaceshipScene extends Scene { this.asteroids.push(this.spawnAsteroid(width, height)); } this.pad = app.input.getGamepad(0); - this.pad.onActive(GamepadAxis.LeftStickX, v => (this.thrust.x = v)); - this.pad.onStop(GamepadAxis.LeftStickX, () => (this.thrust.x = 0)); - this.pad.onActive(GamepadAxis.LeftStickY, v => (this.thrust.y = v)); - this.pad.onStop(GamepadAxis.LeftStickY, () => (this.thrust.y = 0)); - this.pad.onStart(GamepadButton.RightTrigger, () => this.fire()); - // Track controller presence with the engine's connect/disconnect signals - // and prompt with an on-screen Text while none is attached. - this.hasPad = app.input.gamepads.some(pad => pad.connected); - app.input.onGamepadConnected.add(() => (this.hasPad = true)); - app.input.onGamepadDisconnected.add(() => (this.hasPad = app.input.gamepads.some(pad => pad.connected))); - this.connectPrompt = new Text('Connect a controller to fly', { fillColor: Color.white, fontSize: 24, align: 'center' }) + this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { fillColor: Color.white, fontSize: 24, align: 'center' }) .setAnchor(0.5, 0.5) - .setPosition(width / 2, height / 2); + .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Gamepad Spaceship', + title: 'Fly a Spaceship', controls: [ - { keys: 'L-Stick', action: 'steer & thrust' }, - { keys: 'R-Trigger', action: 'fire' }, + { keys: 'WASD / Arrows / Left stick', action: 'steer and thrust' }, + { keys: 'Space / Right trigger', action: 'fire' }, ], status: 'Score: 0', }); @@ -110,9 +110,11 @@ class GamepadSpaceshipScene extends Scene { update(delta) { const app = this.app; const { width, height } = app; - const mag = Math.min(1, Math.hypot(this.thrust.x, this.thrust.y)); + const thrust = this.actions.move.value; + const mag = Math.min(1, Math.hypot(thrust.x, thrust.y)); + if (this.actions.fire.pressed) this.fire(); if (mag > 0.05) { - this.facing = Math.atan2(this.thrust.y, this.thrust.x); + this.facing = Math.atan2(thrust.y, thrust.x); this.ship.setRotation((this.facing * 180) / Math.PI + 90); this.velocity.x += Math.cos(this.facing) * mag * 420 * delta; this.velocity.y += Math.sin(this.facing) * mag * 420 * delta; @@ -121,8 +123,9 @@ class GamepadSpaceshipScene extends Scene { this.engine.volume = 0; } this.ship.move(this.velocity.x * delta, this.velocity.y * delta); - this.velocity.x *= 0.985; - this.velocity.y *= 0.985; + const damping = Math.pow(0.985, delta * 60); + this.velocity.x *= damping; + this.velocity.y *= damping; this.wrap(this.ship.position, width, height); for (const asteroid of this.asteroids) { asteroid.x += asteroid.vx * delta; @@ -168,10 +171,18 @@ class GamepadSpaceshipScene extends Scene { context.render(this.fx); context.render(this.particles); context.render(this.ship); - if (!this.hasPad) { + if (!this.app.input.gamepads.some(pad => pad.connected)) { context.render(this.connectPrompt); } } + destroy() { + this.engine?.stop(); + this.hud?.dispose(); + this.fx?.destroy(); + this.ship?.destroy(); + this.connectPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ scenes: { GamepadSpaceshipScene }, diff --git a/examples/showcase/gamepad-spaceship.ts b/examples/showcase/gamepad-spaceship.ts index 17e92672d..0f8cd2bca 100644 --- a/examples/showcase/gamepad-spaceship.ts +++ b/examples/showcase/gamepad-spaceship.ts @@ -1,18 +1,22 @@ import { + ActionMap, Application, AudioGenerator, + ButtonAction, Color, FixedResolutionCanvasSizing, type Gamepad, GamepadAxis, GamepadButton, Graphics, + Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Text, Vector, + VectorAction, type Voice, } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; @@ -37,7 +41,13 @@ interface Asteroid { class GamepadSpaceshipScene extends Scene { private ship!: Sprite; private velocity = new Vector(0, 0); - private thrust = new Vector(0, 0); + private readonly actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + fire: new ButtonAction([Keyboard.Space, GamepadButton.RightTrigger]), + }); private facing = -Math.PI / 2; private engine!: Voice; private bullets: Bullet[] = []; @@ -47,7 +57,6 @@ class GamepadSpaceshipScene extends Scene { private burst!: BurstSpawn; private pad: Gamepad | null = null; private score = 0; - private hasPad = false; private connectPrompt!: Text; private hud!: ReturnType; @@ -60,6 +69,7 @@ class GamepadSpaceshipScene extends Scene { .setScale(0.5) .setPosition(width / 2, height / 2); this.engine = app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }); + this.inputs.attach(this.actions); this.fx = new Graphics(); @@ -80,26 +90,15 @@ class GamepadSpaceshipScene extends Scene { } this.pad = app.input.getGamepad(0); - this.pad.onActive(GamepadAxis.LeftStickX, (v: number) => (this.thrust.x = v)); - this.pad.onStop(GamepadAxis.LeftStickX, () => (this.thrust.x = 0)); - this.pad.onActive(GamepadAxis.LeftStickY, (v: number) => (this.thrust.y = v)); - this.pad.onStop(GamepadAxis.LeftStickY, () => (this.thrust.y = 0)); - this.pad.onStart(GamepadButton.RightTrigger, () => this.fire()); - - // Track controller presence with the engine's connect/disconnect signals - // and prompt with an on-screen Text while none is attached. - this.hasPad = app.input.gamepads.some(pad => pad.connected); - app.input.onGamepadConnected.add(() => (this.hasPad = true)); - app.input.onGamepadDisconnected.add(() => (this.hasPad = app.input.gamepads.some(pad => pad.connected))); - this.connectPrompt = new Text('Connect a controller to fly', { fillColor: Color.white, fontSize: 24, align: 'center' }) + this.connectPrompt = new Text('WASD / Arrows to fly · Space to fire', { fillColor: Color.white, fontSize: 24, align: 'center' }) .setAnchor(0.5, 0.5) - .setPosition(width / 2, height / 2); + .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Gamepad Spaceship', + title: 'Fly a Spaceship', controls: [ - { keys: 'L-Stick', action: 'steer & thrust' }, - { keys: 'R-Trigger', action: 'fire' }, + { keys: 'WASD / Arrows / Left stick', action: 'steer and thrust' }, + { keys: 'Space / Right trigger', action: 'fire' }, ], status: 'Score: 0', }); @@ -146,10 +145,13 @@ class GamepadSpaceshipScene extends Scene { const app = this.app; const { width, height } = app; - const mag = Math.min(1, Math.hypot(this.thrust.x, this.thrust.y)); + const thrust = this.actions.move.value; + const mag = Math.min(1, Math.hypot(thrust.x, thrust.y)); + + if (this.actions.fire.pressed) this.fire(); if (mag > 0.05) { - this.facing = Math.atan2(this.thrust.y, this.thrust.x); + this.facing = Math.atan2(thrust.y, thrust.x); this.ship.setRotation((this.facing * 180) / Math.PI + 90); this.velocity.x += Math.cos(this.facing) * mag * 420 * delta; this.velocity.y += Math.sin(this.facing) * mag * 420 * delta; @@ -159,8 +161,9 @@ class GamepadSpaceshipScene extends Scene { } this.ship.move(this.velocity.x * delta, this.velocity.y * delta); - this.velocity.x *= 0.985; - this.velocity.y *= 0.985; + const damping = Math.pow(0.985, delta * 60); + this.velocity.x *= damping; + this.velocity.y *= damping; this.wrap(this.ship.position, width, height); for (const asteroid of this.asteroids) { @@ -219,10 +222,19 @@ class GamepadSpaceshipScene extends Scene { context.render(this.particles); context.render(this.ship); - if (!this.hasPad) { + if (!this.app.input.gamepads.some(pad => pad.connected)) { context.render(this.connectPrompt); } } + + override destroy(): void { + this.engine?.stop(); + this.hud?.dispose(); + this.fx?.destroy(); + this.ship?.destroy(); + this.connectPrompt?.destroy(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/showcase/low-band-camera-shake.js b/examples/showcase/low-band-camera-shake.js index 3608370e4..d86f8cb11 100644 --- a/examples/showcase/low-band-camera-shake.js +++ b/examples/showcase/low-band-camera-shake.js @@ -38,25 +38,21 @@ class LowBandCameraShakeScene extends Scene { // gesture, then starts automatically - play() returns the Voice now. this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); } - update(delta) { + update() { const low = this.analyser.getBandEnergy(20, 180); // No constant floor: amplitude is purely low-band energy, so a quiet // passage produces zero shake. A small deadzone keeps faint noise still. const amplitude = low > 0.04 ? low * 28 : 0; this.view.shake(amplitude, Time.seconds(0.09), { decay: true, frequency: 22 }); - // Advance the shake oscillation (the View only animates when updated). - this.view.update(delta * 1000); if (this.musicVoice) { this.hud.setStatus(`bass ${(low * 100) | 0}%`); } } draw(context) { const app = this.app; - context.backend.setView(this.view); - context.render(this.sprite); - context.backend.setView(null); + context.render(this.sprite, { view: this.view }); if (app.audio.locked) { - context.render(this.tapPrompt); + context.render(this.tapPrompt, { view: context.screenView }); } } } diff --git a/examples/showcase/low-band-camera-shake.ts b/examples/showcase/low-band-camera-shake.ts index 9d86226d5..cac9d53ff 100644 --- a/examples/showcase/low-band-camera-shake.ts +++ b/examples/showcase/low-band-camera-shake.ts @@ -6,7 +6,6 @@ import { FixedResolutionCanvasSizing, type RenderingContext, Scene, - type Seconds, Sprite, Text, Time, @@ -58,7 +57,7 @@ class LowBandCameraShakeScene extends Scene { this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); } - override update(delta: Seconds): void { + override update(): void { const low = this.analyser.getBandEnergy(20, 180); // No constant floor: amplitude is purely low-band energy, so a quiet @@ -66,9 +65,6 @@ class LowBandCameraShakeScene extends Scene { const amplitude = low > 0.04 ? low * 28 : 0; this.view.shake(amplitude, Time.seconds(0.09), { decay: true, frequency: 22 }); - // Advance the shake oscillation (the View only animates when updated). - this.view.update(delta * 1000); - if (this.musicVoice) { this.hud.setStatus(`bass ${(low * 100) | 0}%`); } @@ -76,12 +72,10 @@ class LowBandCameraShakeScene extends Scene { override draw(context: RenderingContext): void { const app = this.app; - context.backend.setView(this.view); - context.render(this.sprite); - context.backend.setView(null); + context.render(this.sprite, { view: this.view }); if (app.audio.locked) { - context.render(this.tapPrompt); + context.render(this.tapPrompt, { view: context.screenView }); } } } diff --git a/examples/showcase/orb-dodge.js b/examples/showcase/orb-dodge.js index 0bb5490a4..18b3b929f 100644 --- a/examples/showcase/orb-dodge.js +++ b/examples/showcase/orb-dodge.js @@ -1,5 +1,17 @@ // Auto-generated from orb-dodge.ts - edit the .ts source, not this file. -import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; +import { + ActionMap, + Application, + Color, + Container, + FixedResolutionCanvasSizing, + GamepadAxis, + Graphics, + Keyboard, + Scene, + Text, + VectorAction, +} from '@codexo/exojs'; // #region guide:constants const CANVAS_WIDTH = 1280; const CANVAS_HEIGHT = 720; @@ -15,8 +27,12 @@ class PlayScene extends Scene { orbs = []; px = CANVAS_WIDTH / 2; py = CANVAS_HEIGHT / 2; - dx = 0; - dy = 0; + actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }); score = 0; elapsed = 0; spawnTimer = 0; @@ -29,9 +45,8 @@ class PlayScene extends Scene { this.score = 0; this.elapsed = 0; this.spawnTimer = 0; - this.dx = 0; - this.dy = 0; this.orbs = []; + this.inputs.attach(this.actions); this.world = new Container(); this.player = new Graphics(); this.player.fillColor = new Color(80, 160, 255); @@ -42,54 +57,6 @@ class PlayScene extends Scene { this.scoreText.setPosition(16, 14); this.timeText = new Text('0.0 s', { fillColor: Color.white, fontSize: 20 }); this.timeText.setPosition(CANVAS_WIDTH - 90, 14); - this.inputs.onActive(Keyboard.W, () => { - this.dy = -1; - }); - this.inputs.onStop(Keyboard.W, () => { - if (this.dy < 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.Up, () => { - this.dy = -1; - }); - this.inputs.onStop(Keyboard.Up, () => { - if (this.dy < 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.S, () => { - this.dy = 1; - }); - this.inputs.onStop(Keyboard.S, () => { - if (this.dy > 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.Down, () => { - this.dy = 1; - }); - this.inputs.onStop(Keyboard.Down, () => { - if (this.dy > 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.A, () => { - this.dx = -1; - }); - this.inputs.onStop(Keyboard.A, () => { - if (this.dx < 0) this.dx = 0; - }); - this.inputs.onActive(Keyboard.Left, () => { - this.dx = -1; - }); - this.inputs.onStop(Keyboard.Left, () => { - if (this.dx < 0) this.dx = 0; - }); - this.inputs.onActive(Keyboard.D, () => { - this.dx = 1; - }); - this.inputs.onStop(Keyboard.D, () => { - if (this.dx > 0) this.dx = 0; - }); - this.inputs.onActive(Keyboard.Right, () => { - this.dx = 1; - }); - this.inputs.onStop(Keyboard.Right, () => { - if (this.dx > 0) this.dx = 0; - }); } // #endregion guide:play-init // #region guide:spawn-orb @@ -122,7 +89,8 @@ class PlayScene extends Scene { const speed = ORB_SPEED_MIN + Math.random() * (ORB_SPEED_MAX - ORB_SPEED_MIN); const gfx = new Graphics(); gfx.fillColor = danger ? new Color(255, 80, 80) : new Color(80, 220, 120); - gfx.drawCircle(0, 0, ORB_RADIUS); + if (danger) gfx.drawStar(0, 0, 4, ORB_RADIUS * 1.25, ORB_RADIUS * 0.68, Math.PI / 4); + else gfx.drawCircle(0, 0, ORB_RADIUS); gfx.setPosition(ox, oy); this.world.addChild(gfx); this.orbs.push({ gfx, vx: ((tx - ox) / dist) * speed, vy: ((ty - oy) / dist) * speed, danger }); @@ -137,10 +105,11 @@ class PlayScene extends Scene { this.spawnTimer -= SPAWN_INTERVAL; this.spawnOrb(); } - const mag = Math.hypot(this.dx, this.dy) || 1; - if (this.dx !== 0 || this.dy !== 0) { - this.px += (this.dx / mag) * PLAYER_SPEED * delta; - this.py += (this.dy / mag) * PLAYER_SPEED * delta; + const { x, y } = this.actions.move.value; + const mag = Math.max(1, Math.hypot(x, y)); + if (x !== 0 || y !== 0) { + this.px += (x / mag) * PLAYER_SPEED * delta; + this.py += (y / mag) * PLAYER_SPEED * delta; } this.px = Math.max(PLAYER_RADIUS, Math.min(CANVAS_WIDTH - PLAYER_RADIUS, this.px)); this.py = Math.max(PLAYER_RADIUS, Math.min(CANVAS_HEIGHT - PLAYER_RADIUS, this.py)); diff --git a/examples/showcase/orb-dodge.ts b/examples/showcase/orb-dodge.ts index 516a69853..f0343beb6 100644 --- a/examples/showcase/orb-dodge.ts +++ b/examples/showcase/orb-dodge.ts @@ -1,14 +1,17 @@ import { + ActionMap, Application, Color, Container, FixedResolutionCanvasSizing, + GamepadAxis, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, Text, + VectorAction, } from '@codexo/exojs'; // #region guide:constants @@ -35,8 +38,12 @@ class PlayScene extends Scene { private orbs: OrbData[] = []; private px = CANVAS_WIDTH / 2; private py = CANVAS_HEIGHT / 2; - private dx = 0; - private dy = 0; + private readonly actions = new ActionMap({ + move: new VectorAction([ + { up: [Keyboard.W, Keyboard.Up], down: [Keyboard.S, Keyboard.Down], left: [Keyboard.A, Keyboard.Left], right: [Keyboard.D, Keyboard.Right] }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }); private score = 0; private elapsed = 0; private spawnTimer = 0; @@ -50,9 +57,8 @@ class PlayScene extends Scene { this.score = 0; this.elapsed = 0; this.spawnTimer = 0; - this.dx = 0; - this.dy = 0; this.orbs = []; + this.inputs.attach(this.actions); this.world = new Container(); @@ -67,58 +73,6 @@ class PlayScene extends Scene { this.timeText = new Text('0.0 s', { fillColor: Color.white, fontSize: 20 }); this.timeText.setPosition(CANVAS_WIDTH - 90, 14); - - this.inputs.onActive(Keyboard.W, () => { - this.dy = -1; - }); - this.inputs.onStop(Keyboard.W, () => { - if (this.dy < 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.Up, () => { - this.dy = -1; - }); - this.inputs.onStop(Keyboard.Up, () => { - if (this.dy < 0) this.dy = 0; - }); - - this.inputs.onActive(Keyboard.S, () => { - this.dy = 1; - }); - this.inputs.onStop(Keyboard.S, () => { - if (this.dy > 0) this.dy = 0; - }); - this.inputs.onActive(Keyboard.Down, () => { - this.dy = 1; - }); - this.inputs.onStop(Keyboard.Down, () => { - if (this.dy > 0) this.dy = 0; - }); - - this.inputs.onActive(Keyboard.A, () => { - this.dx = -1; - }); - this.inputs.onStop(Keyboard.A, () => { - if (this.dx < 0) this.dx = 0; - }); - this.inputs.onActive(Keyboard.Left, () => { - this.dx = -1; - }); - this.inputs.onStop(Keyboard.Left, () => { - if (this.dx < 0) this.dx = 0; - }); - - this.inputs.onActive(Keyboard.D, () => { - this.dx = 1; - }); - this.inputs.onStop(Keyboard.D, () => { - if (this.dx > 0) this.dx = 0; - }); - this.inputs.onActive(Keyboard.Right, () => { - this.dx = 1; - }); - this.inputs.onStop(Keyboard.Right, () => { - if (this.dx > 0) this.dx = 0; - }); } // #endregion guide:play-init @@ -153,7 +107,8 @@ class PlayScene extends Scene { const gfx = new Graphics(); gfx.fillColor = danger ? new Color(255, 80, 80) : new Color(80, 220, 120); - gfx.drawCircle(0, 0, ORB_RADIUS); + if (danger) gfx.drawStar(0, 0, 4, ORB_RADIUS * 1.25, ORB_RADIUS * 0.68, Math.PI / 4); + else gfx.drawCircle(0, 0, ORB_RADIUS); gfx.setPosition(ox, oy); this.world.addChild(gfx); this.orbs.push({ gfx, vx: ((tx - ox) / dist) * speed, vy: ((ty - oy) / dist) * speed, danger }); @@ -171,10 +126,11 @@ class PlayScene extends Scene { this.spawnOrb(); } - const mag = Math.hypot(this.dx, this.dy) || 1; - if (this.dx !== 0 || this.dy !== 0) { - this.px += (this.dx / mag) * PLAYER_SPEED * delta; - this.py += (this.dy / mag) * PLAYER_SPEED * delta; + const { x, y } = this.actions.move.value; + const mag = Math.max(1, Math.hypot(x, y)); + if (x !== 0 || y !== 0) { + this.px += (x / mag) * PLAYER_SPEED * delta; + this.py += (y / mag) * PLAYER_SPEED * delta; } this.px = Math.max(PLAYER_RADIUS, Math.min(CANVAS_WIDTH - PLAYER_RADIUS, this.px)); this.py = Math.max(PLAYER_RADIUS, Math.min(CANVAS_HEIGHT - PLAYER_RADIUS, this.py)); diff --git a/examples/showcase/pause-blur.js b/examples/showcase/pause-blur.js index 77bd9c0fa..d27eca24c 100644 --- a/examples/showcase/pause-blur.js +++ b/examples/showcase/pause-blur.js @@ -1,30 +1,24 @@ // Auto-generated from pause-blur.ts - edit the .ts source, not this file. -import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, Keyboard, Label, Panel, Scene, Sprite } from '@codexo/exojs'; +import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, Keyboard, Label, Panel, Scene, SceneAvailability, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const PAUSE_BLUR_STRENGTH = 3; const PAUSE_FADE_SECONDS = 0.35; -/** - * Pause without a scene stack: a pause overlay lives on `scene.ui` (always - * above the world) and is toggled together with a scene-local `frozen` flag, - * which the scene's own `update()` checks to skip gameplay while it keeps - * drawing. The blur tween runs on the app-level TweenSystem, so it still - * animates while the scene is frozen. - */ class GameScene extends Scene { sprite; time = 0; - frozen = false; blur = new BlurFilter({ strength: 0 }); + blurTween = null; pausePanel; pauseLabel; hud; + onPointerDown = () => this.togglePause(); init() { const app = this.app; const { width, height } = app; this.sprite = new Sprite(this.loader.get('image/ship-a.png')) .setAnchor(0.5) .setScale(2) - .setPosition(width / 2, height / 2); + .setPosition(width * 0.72, 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) }); @@ -36,16 +30,15 @@ class GameScene extends Scene { this.pauseLabel.visible = false; this.ui.addChild(this.pauseLabel); this.hud = mountControls({ - title: 'Pause Blur', + title: 'Pause Menu', controls: [{ keys: 'Esc / Click', action: 'pause / resume' }], - hint: 'Press Esc or click to pause — the scene blurs up behind the menu.', + status: 'Running', + hint: 'Scene updates stop while paused; the application tween still fades in the blur.', }); - this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); - // Same toggle on click/tap so the pause works without a keyboard. - app.input.onPointerTap.add(() => this.togglePause()); + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause(), { when: SceneAvailability.Always }); + app.input.onPointerDown.add(this.onPointerDown); } update(delta) { - if (this.frozen) return; this.time += delta; this.sprite.setRotation(this.time * 80); } @@ -53,19 +46,30 @@ class GameScene extends Scene { context.render(this.root); } destroy() { + this.blurTween?.stop(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); this.root.clearFilters(); super.destroy(); } togglePause() { - this.frozen = !this.frozen; - this.pausePanel.visible = this.frozen; - this.pauseLabel.visible = this.frozen; - if (this.frozen) { + const pausing = !this.paused; + this.blurTween?.stop(); + if (pausing) { + this.app.scenes.pause(); + } else { + this.app.scenes.resume(); + } + this.pausePanel.visible = pausing; + this.pauseLabel.visible = pausing; + this.hud.setStatus(pausing ? 'Paused' : 'Running'); + if (pausing) { this.blur.strength = 0; this.root.filters = [this.blur]; - this.tweens.create(this.blur).to({ strength: PAUSE_BLUR_STRENGTH }, PAUSE_FADE_SECONDS).start(); + this.blurTween = this.app.tweens.create(this.blur).to({ strength: PAUSE_BLUR_STRENGTH }, PAUSE_FADE_SECONDS).start(); } else { this.root.clearFilters(); + this.blurTween = null; } } } diff --git a/examples/showcase/pause-blur.ts b/examples/showcase/pause-blur.ts index b90521888..fe044c2a1 100644 --- a/examples/showcase/pause-blur.ts +++ b/examples/showcase/pause-blur.ts @@ -8,29 +8,25 @@ import { Panel, type RenderingContext, Scene, + SceneAvailability, type Seconds, Sprite, + Tween, } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const PAUSE_BLUR_STRENGTH = 3; const PAUSE_FADE_SECONDS = 0.35; -/** - * Pause without a scene stack: a pause overlay lives on `scene.ui` (always - * above the world) and is toggled together with a scene-local `frozen` flag, - * which the scene's own `update()` checks to skip gameplay while it keeps - * drawing. The blur tween runs on the app-level TweenSystem, so it still - * animates while the scene is frozen. - */ class GameScene extends Scene { private sprite!: Sprite; private time = 0; - private frozen = false; private readonly blur = new BlurFilter({ strength: 0 }); + private blurTween: Tween | null = null; private pausePanel!: Panel; private pauseLabel!: Label; private hud!: ReturnType; + private readonly onPointerDown = (): void => this.togglePause(); override init(): void { const app = this.app; @@ -39,7 +35,7 @@ class GameScene extends Scene { this.sprite = new Sprite(this.loader.get('image/ship-a.png')) .setAnchor(0.5) .setScale(2) - .setPosition(width / 2, height / 2); + .setPosition(width * 0.72, height / 2); this.addChild(this.sprite); // Pause overlay on the UI layer, hidden until paused. @@ -54,19 +50,17 @@ class GameScene extends Scene { this.ui.addChild(this.pauseLabel); this.hud = mountControls({ - title: 'Pause Blur', + title: 'Pause Menu', controls: [{ keys: 'Esc / Click', action: 'pause / resume' }], - hint: 'Press Esc or click to pause — the scene blurs up behind the menu.', + status: 'Running', + hint: 'Scene updates stop while paused; the application tween still fades in the blur.', }); - this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); - // Same toggle on click/tap so the pause works without a keyboard. - app.input.onPointerTap.add(() => this.togglePause()); + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause(), { when: SceneAvailability.Always }); + app.input.onPointerDown.add(this.onPointerDown); } override update(delta: Seconds): void { - if (this.frozen) return; - this.time += delta; this.sprite.setRotation(this.time * 80); } @@ -76,21 +70,32 @@ class GameScene extends Scene { } override destroy(): void { + this.blurTween?.stop(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); this.root.clearFilters(); super.destroy(); } private togglePause(): void { - this.frozen = !this.frozen; - this.pausePanel.visible = this.frozen; - this.pauseLabel.visible = this.frozen; + const pausing = !this.paused; + this.blurTween?.stop(); + if (pausing) { + this.app.scenes.pause(); + } else { + this.app.scenes.resume(); + } + this.pausePanel.visible = pausing; + this.pauseLabel.visible = pausing; + this.hud.setStatus(pausing ? 'Paused' : 'Running'); - if (this.frozen) { + if (pausing) { this.blur.strength = 0; this.root.filters = [this.blur]; - this.tweens.create(this.blur).to({ strength: PAUSE_BLUR_STRENGTH }, PAUSE_FADE_SECONDS).start(); + this.blurTween = this.app.tweens.create(this.blur).to({ strength: PAUSE_BLUR_STRENGTH }, PAUSE_FADE_SECONDS).start(); } else { this.root.clearFilters(); + this.blurTween = null; } } } diff --git a/examples/showcase/screen-shake-on-explosion.js b/examples/showcase/screen-shake-on-explosion.js index dc003a81e..51170029a 100644 --- a/examples/showcase/screen-shake-on-explosion.js +++ b/examples/showcase/screen-shake-on-explosion.js @@ -1,15 +1,29 @@ // Auto-generated from screen-shake-on-explosion.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Time, Vector, View } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Time, Vector, View } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { mountControls } from '@examples/runtime'; class ScreenShakeOnExplosionScene extends Scene { view; ps; burstPos; burst; + ship; + flash = new Color(255, 255, 255); + hits = 0; + hud; init() { const app = this.app; const { width, height } = app; this.view = new View(width / 2, height / 2, width, height); + this.ship = new Sprite(this.loader.get('image/ship-a.png')) + .setAnchor(0.5) + .setScale(2.2) + .setPosition(width / 2, height / 2); + this.hud = mountControls({ + title: 'Impact Feedback', + controls: [{ keys: 'Click', action: 'flash the ship, burst particles, and shake the camera' }], + status: 'Hits: 0', + }); this.ps = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: 5000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); @@ -27,12 +41,18 @@ class ScreenShakeOnExplosionScene extends Scene { this.burstPos.set(p.x - this.ps.position.x, p.y - this.ps.position.y); this.burst.reset(); this.view.shake(22, Time.seconds(0.28), { frequency: 26, decay: true }); + this.hits++; + this.hud.setStatus(`Hits: ${this.hits}`); + this.flash.set(255, 120, 120, 1); + app.tweens.create(this.flash).to({ r: 255, g: 255, b: 255 }, 0.2).start(); }); } + update() { + this.ship.setTint(this.flash); + } draw(context) { - context.backend.setView(this.view); - context.render(this.ps); - context.backend.setView(null); + context.render(this.ps, { view: this.view }); + context.render(this.ship, { view: this.view }); } } const app = new Application({ diff --git a/examples/showcase/screen-shake-on-explosion.ts b/examples/showcase/screen-shake-on-explosion.ts index 36410501c..67d7d7e0e 100644 --- a/examples/showcase/screen-shake-on-explosion.ts +++ b/examples/showcase/screen-shake-on-explosion.ts @@ -1,17 +1,31 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Time, Vector, View } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Time, Vector, View } from '@codexo/exojs'; import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { mountControls } from '@examples/runtime'; class ScreenShakeOnExplosionScene extends Scene { private view!: View; private ps!: ParticleSystem; private burstPos!: Vector; private burst!: BurstSpawn; + private ship!: Sprite; + private flash = new Color(255, 255, 255); + private hits = 0; + private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; this.view = new View(width / 2, height / 2, width, height); + this.ship = new Sprite(this.loader.get('image/ship-a.png')) + .setAnchor(0.5) + .setScale(2.2) + .setPosition(width / 2, height / 2); + this.hud = mountControls({ + title: 'Impact Feedback', + controls: [{ keys: 'Click', action: 'flash the ship, burst particles, and shake the camera' }], + status: 'Hits: 0', + }); this.ps = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: 5000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); @@ -29,13 +43,20 @@ class ScreenShakeOnExplosionScene extends Scene { this.burstPos.set(p.x - this.ps.position.x, p.y - this.ps.position.y); this.burst.reset(); this.view.shake(22, Time.seconds(0.28), { frequency: 26, decay: true }); + this.hits++; + this.hud.setStatus(`Hits: ${this.hits}`); + this.flash.set(255, 120, 120, 1); + app.tweens.create(this.flash).to({ r: 255, g: 255, b: 255 }, 0.2).start(); }); } + override update(): void { + this.ship.setTint(this.flash); + } + override draw(context: RenderingContext): void { - context.backend.setView(this.view); - context.render(this.ps); - context.backend.setView(null); + context.render(this.ps, { view: this.view }); + context.render(this.ship, { view: this.view }); } } diff --git a/examples/spatial-audio/listener-and-source.js b/examples/spatial-audio/listener-and-source.js index dafeed5fb..5e4797b3f 100644 --- a/examples/spatial-audio/listener-and-source.js +++ b/examples/spatial-audio/listener-and-source.js @@ -1,6 +1,6 @@ // Auto-generated from listener-and-source.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the // wide 1280px canvas. These mirror the Web Audio `linear` model (see // DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what @@ -9,10 +9,11 @@ const REF_DISTANCE = 50; const MAX_DISTANCE = 560; const ROLLOFF = 1; const SOURCE_RADIUS = 24; -function linearAttenuation(distance) { +function attenuation(model, distance) { if (distance <= REF_DISTANCE) return 1; - const t = (distance - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE); - return Math.max(0, 1 - ROLLOFF * t); + if (model === 'linear') return Math.max(0, 1 - (ROLLOFF * (distance - REF_DISTANCE)) / (MAX_DISTANCE - REF_DISTANCE)); + if (model === 'inverse') return REF_DISTANCE / (REF_DISTANCE + ROLLOFF * (distance - REF_DISTANCE)); + return Math.pow(distance / REF_DISTANCE, -ROLLOFF); } class ListenerAndSourceScene extends Scene { sound; @@ -24,6 +25,9 @@ class ListenerAndSourceScene extends Scene { label; tapPrompt; hud; + mode = 'drag'; + model = 'linear'; + angle = 0; async load() { const app = this.app; const { width, height } = app; @@ -44,18 +48,43 @@ class ListenerAndSourceScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Listener and Source', - controls: [{ keys: 'Drag', action: 'move the red source around the listener' }], + title: 'Spatial Audio', + controls: [{ keys: 'Drag', action: 'move the red source in drag mode' }], status: 'Click or press any key to start…', - hint: 'The green dot is the listener. Drag the red source — volume falls off with distance.', + hint: 'Move the source or let it orbit, then compare distance models with the live volume readout.', }); + const panel = mountControlPanel({ title: 'Source and falloff' }); + panel.addButton({ + label: 'Drag source', + onClick: () => { + this.mode = 'drag'; + this.dragging = false; + }, + }); + panel.addButton({ + label: 'Orbit source', + onClick: () => { + this.mode = 'orbit'; + this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + }, + }); + for (const model of ['linear', 'inverse', 'exponential']) { + panel.addButton({ + label: model, + onClick: () => { + this.model = model; + if (this.voice) this.voice.distanceModel = model; + }, + }); + } this.source.x = width / 2 + 220; this.source.y = height / 2; app.input.onPointerDown.add(pointer => { const dx = pointer.x - this.source.x; const dy = pointer.y - this.source.y; // Generous grab radius so the source is easy to pick up. - if (dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true; + if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true; }); app.input.onPointerMove.add(pointer => { if (!this.dragging) return; @@ -76,7 +105,7 @@ class ListenerAndSourceScene extends Scene { loop: true, volume: 1, position: this.source, - distanceModel: 'linear', + distanceModel: this.model, refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, rolloffFactor: ROLLOFF, @@ -84,17 +113,24 @@ class ListenerAndSourceScene extends Scene { this.hud.setStatus('Drag the red source to move it'); }); } + update(delta) { + if (this.mode !== 'orbit') return; + this.angle += delta * 1.1; + this.source.x = this.listener.x + Math.cos(this.angle) * 220; + this.source.y = this.listener.y + Math.sin(this.angle) * 160; + if (this.voice) this.voice.position = this.source; + } draw(context) { const app = this.app; const source = this.source; const dx = source.x - this.listener.x; const dy = source.y - this.listener.y; const dist = Math.sqrt(dx * dx + dy * dy); - const volume = linearAttenuation(dist); + const volume = attenuation(this.model, dist); // Horizontal offset maps to stereo pan (left of listener = left ear). const pan = Math.max(-1, Math.min(1, dx / MAX_DISTANCE)); const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center'; - this.label.text = `distance: ${dist.toFixed(0)} px volume: ${(volume * 100).toFixed(0)}% pan: ${panText}`; + this.label.text = `${this.mode} · ${this.model} · distance ${dist.toFixed(0)} px · volume ${(volume * 100).toFixed(0)}% · pan ${panText}`; this.graphics.clear(); // Reference + max distance rings around the listener. this.graphics.fillColor = new Color(50, 60, 60); @@ -114,6 +150,10 @@ class ListenerAndSourceScene extends Scene { context.render(this.tapPrompt); } } + destroy() { + this.voice?.stop(); + super.destroy(); + } } const app = new Application({ scenes: { ListenerAndSourceScene }, diff --git a/examples/spatial-audio/listener-and-source.ts b/examples/spatial-audio/listener-and-source.ts index e2e4cc25e..f01c0d31e 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -1,6 +1,6 @@ -import type { RenderingContext, Spatializable, Voice } from '@codexo/exojs'; +import type { RenderingContext, Seconds, Spatializable, Voice } from '@codexo/exojs'; import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { mountControlPanel, mountControls } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the // wide 1280px canvas. These mirror the Web Audio `linear` model (see @@ -10,11 +10,13 @@ const REF_DISTANCE = 50; const MAX_DISTANCE = 560; const ROLLOFF = 1; const SOURCE_RADIUS = 24; +type FalloffModel = 'linear' | 'inverse' | 'exponential'; -function linearAttenuation(distance: number): number { +function attenuation(model: FalloffModel, distance: number): number { if (distance <= REF_DISTANCE) return 1; - const t = (distance - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE); - return Math.max(0, 1 - ROLLOFF * t); + if (model === 'linear') return Math.max(0, 1 - (ROLLOFF * (distance - REF_DISTANCE)) / (MAX_DISTANCE - REF_DISTANCE)); + if (model === 'inverse') return REF_DISTANCE / (REF_DISTANCE + ROLLOFF * (distance - REF_DISTANCE)); + return Math.pow(distance / REF_DISTANCE, -ROLLOFF); } class ListenerAndSourceScene extends Scene { @@ -27,6 +29,9 @@ class ListenerAndSourceScene extends Scene { private label!: Text; private tapPrompt!: Text; private hud!: ReturnType; + private mode: 'drag' | 'orbit' = 'drag'; + private model: FalloffModel = 'linear'; + private angle = 0; override async load(): Promise { const app = this.app; @@ -52,11 +57,36 @@ class ListenerAndSourceScene extends Scene { .setPosition(width / 2, height - 48); this.hud = mountControls({ - title: 'Listener and Source', - controls: [{ keys: 'Drag', action: 'move the red source around the listener' }], + title: 'Spatial Audio', + controls: [{ keys: 'Drag', action: 'move the red source in drag mode' }], status: 'Click or press any key to start…', - hint: 'The green dot is the listener. Drag the red source — volume falls off with distance.', + hint: 'Move the source or let it orbit, then compare distance models with the live volume readout.', }); + const panel = mountControlPanel({ title: 'Source and falloff' }); + panel.addButton({ + label: 'Drag source', + onClick: () => { + this.mode = 'drag'; + this.dragging = false; + }, + }); + panel.addButton({ + label: 'Orbit source', + onClick: () => { + this.mode = 'orbit'; + this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + }, + }); + for (const model of ['linear', 'inverse', 'exponential'] as const) { + panel.addButton({ + label: model, + onClick: () => { + this.model = model; + if (this.voice) this.voice.distanceModel = model; + }, + }); + } this.source.x = width / 2 + 220; this.source.y = height / 2; @@ -65,7 +95,7 @@ class ListenerAndSourceScene extends Scene { const dx = pointer.x - this.source.x; const dy = pointer.y - this.source.y; // Generous grab radius so the source is easy to pick up. - if (dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true; + if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true; }); app.input.onPointerMove.add(pointer => { if (!this.dragging) return; @@ -87,7 +117,7 @@ class ListenerAndSourceScene extends Scene { loop: true, volume: 1, position: this.source, - distanceModel: 'linear', + distanceModel: this.model, refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, rolloffFactor: ROLLOFF, @@ -96,17 +126,25 @@ class ListenerAndSourceScene extends Scene { }); } + override update(delta: Seconds): void { + if (this.mode !== 'orbit') return; + this.angle += delta * 1.1; + this.source.x = this.listener.x + Math.cos(this.angle) * 220; + this.source.y = this.listener.y + Math.sin(this.angle) * 160; + if (this.voice) this.voice.position = this.source; + } + override draw(context: RenderingContext): void { const app = this.app; const source = this.source; const dx = source.x - this.listener.x; const dy = source.y - this.listener.y; const dist = Math.sqrt(dx * dx + dy * dy); - const volume = linearAttenuation(dist); + const volume = attenuation(this.model, dist); // Horizontal offset maps to stereo pan (left of listener = left ear). const pan = Math.max(-1, Math.min(1, dx / MAX_DISTANCE)); const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center'; - this.label.text = `distance: ${dist.toFixed(0)} px volume: ${(volume * 100).toFixed(0)}% pan: ${panText}`; + this.label.text = `${this.mode} · ${this.model} · distance ${dist.toFixed(0)} px · volume ${(volume * 100).toFixed(0)}% · pan ${panText}`; this.graphics.clear(); @@ -132,6 +170,11 @@ class ListenerAndSourceScene extends Scene { context.render(this.tapPrompt); } } + + override destroy(): void { + this.voice?.stop(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/sprites-textures/asset-catalogs.js b/examples/sprites-textures/asset-catalogs.js index dddf0823c..8811d508a 100644 --- a/examples/sprites-textures/asset-catalogs.js +++ b/examples/sprites-textures/asset-catalogs.js @@ -1,5 +1,5 @@ // Auto-generated from asset-catalogs.ts - edit the .ts source, not this file. -import { Application, Asset, Assets, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; +import { Application, Asset, Assets, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; // #region guide:catalog-declare // A catalog is a named, typed group of assets. A bare path infers its type from @@ -34,32 +34,38 @@ class AssetCatalogsScene extends Scene { ship; ground; summary; - bar; hud; dayGround; nightGround; theme; - progress = 0; frameCount = 0; loadError = ''; night = false; - barX = 0; - barY = 0; - barWidth = 0; async load() { + this.hud = mountControls({ + title: 'Asset Catalogs', + controls: [ + { keys: 'N', action: 'swap the ground texture for the derived night catalog' }, + { keys: 'G', action: 'load a ground texture by a computed path' }, + { keys: 'M', action: 'play the streamed theme (a non-leaf asset)' }, + ], + status: 'Loading the shared and day catalogs...', + hint: 'Catalog handles expose their queue and cache state while loading.', + }); // #region guide:queue-progress // Every `load(...)` call returns a LoadingQueue. It is `PromiseLike`, so // it can be awaited directly, and it reports the progress of this one // queue through `onProgress`. const loading = this.loader.load(LevelAssets); loading.onProgress.add(progress => { - this.progress = progress.loaded / progress.total; + this.hud.setStatus(`Day catalog: ${progress.loaded}/${progress.total} loaded; logo ${SharedAssets.logo.state}.`); }); // #endregion guide:queue-progress // #region guide:catalog-parallel // Independent catalogs get independent queues - start both, await both. // The result tuple keeps each catalog's shape. const [day, night] = await Promise.all([loading, this.loader.load(NightAssets)]); + this.hud.setStatus(`Both catalogs ready; logo ${SharedAssets.logo.state}, night ground ${NightAssets.ground.state}.`); this.dayGround = day.ground; this.nightGround = night.ground; // #endregion guide:catalog-parallel @@ -101,12 +107,8 @@ class AssetCatalogsScene extends Scene { .setAnchor(0.5) .setPosition(width * 0.75, height * 0.55) .setScale(1.4); - this.barWidth = width * 0.5; - this.barX = (width - this.barWidth) / 2; - this.barY = height * 0.16; - this.bar = new Graphics(); this.summary = new Text('', { fillColor: Color.white, fontSize: 18, align: 'center' }); - this.summary.setAnchor(0.5, 0).setPosition(width / 2, this.barY + 44); + this.summary.setAnchor(0.5, 0).setPosition(width / 2, height * 0.16); this.inputs.onTrigger(Keyboard.N, () => { this.night = !this.night; this.ground.setTexture(this.night ? this.nightGround : this.dayGround); @@ -117,15 +119,6 @@ class AssetCatalogsScene extends Scene { this.inputs.onTrigger(Keyboard.G, () => { void this.useVariant('hue-ramp'); }); - this.hud = mountControls({ - title: 'Asset Catalogs', - controls: [ - { keys: 'N', action: 'swap the ground texture for the derived night catalog' }, - { keys: 'G', action: 'load a ground texture by a computed path' }, - { keys: 'M', action: 'play the streamed theme (a non-leaf asset)' }, - ], - hint: 'Three catalogs — a shared one, a composed one, and one derived with extend — loaded through two parallel queues.', - }); } // #region guide:dynamic-path /** Replaces the ground texture with a variant chosen at runtime. */ @@ -138,16 +131,10 @@ class AssetCatalogsScene extends Scene { } // #endregion guide:dynamic-path draw(context) { - this.bar.clear(); - this.bar.fillColor = new Color(48, 52, 62); - this.bar.drawRectangle(this.barX, this.barY, this.barWidth, 22); - this.bar.fillColor = new Color(110, 200, 255); - this.bar.drawRectangle(this.barX, this.barY, this.barWidth * this.progress, 22); - context.render(this.bar); const failure = this.loadError === '' ? '' : `\n${this.loadError}`; this.summary.text = `LevelAssets = compose(SharedAssets, LevelLocalAssets) — ${Object.keys(LevelAssets.entries).length} keys\n` + - `atlas frames: ${this.frameCount} ground: ${this.night ? 'night' : 'day'} queue: ${Math.round(this.progress * 100)}%${failure}`; + `atlas frames: ${this.frameCount} ground: ${this.night ? 'night' : 'day'} logo: ${SharedAssets.logo.state}${failure}`; context.render(this.summary); context.render(this.logo); context.render(this.ship); diff --git a/examples/sprites-textures/asset-catalogs.ts b/examples/sprites-textures/asset-catalogs.ts index 75149f0d8..a80aef18e 100644 --- a/examples/sprites-textures/asset-catalogs.ts +++ b/examples/sprites-textures/asset-catalogs.ts @@ -5,7 +5,6 @@ import { type AudioStream, Color, FixedResolutionCanvasSizing, - Graphics, Keyboard, type RenderingContext, Scene, @@ -52,22 +51,26 @@ class AssetCatalogsScene extends Scene { private ship!: Sprite; private ground!: Sprite; private summary!: Text; - private bar!: Graphics; private hud!: ReturnType; private dayGround!: Texture; private nightGround!: Texture; private theme!: AudioStream; - private progress = 0; private frameCount = 0; private loadError = ''; private night = false; - private barX = 0; - private barY = 0; - private barWidth = 0; - override async load(): Promise { + this.hud = mountControls({ + title: 'Asset Catalogs', + controls: [ + { keys: 'N', action: 'swap the ground texture for the derived night catalog' }, + { keys: 'G', action: 'load a ground texture by a computed path' }, + { keys: 'M', action: 'play the streamed theme (a non-leaf asset)' }, + ], + status: 'Loading the shared and day catalogs...', + hint: 'Catalog handles expose their queue and cache state while loading.', + }); // #region guide:queue-progress // Every `load(...)` call returns a LoadingQueue. It is `PromiseLike`, so // it can be awaited directly, and it reports the progress of this one @@ -75,7 +78,7 @@ class AssetCatalogsScene extends Scene { const loading = this.loader.load(LevelAssets); loading.onProgress.add(progress => { - this.progress = progress.loaded / progress.total; + this.hud.setStatus(`Day catalog: ${progress.loaded}/${progress.total} loaded; logo ${SharedAssets.logo.state}.`); }); // #endregion guide:queue-progress @@ -83,6 +86,7 @@ class AssetCatalogsScene extends Scene { // Independent catalogs get independent queues - start both, await both. // The result tuple keeps each catalog's shape. const [day, night] = await Promise.all([loading, this.loader.load(NightAssets)]); + this.hud.setStatus(`Both catalogs ready; logo ${SharedAssets.logo.state}, night ground ${NightAssets.ground.state}.`); this.dayGround = day.ground; this.nightGround = night.ground; @@ -132,13 +136,8 @@ class AssetCatalogsScene extends Scene { .setPosition(width * 0.75, height * 0.55) .setScale(1.4); - this.barWidth = width * 0.5; - this.barX = (width - this.barWidth) / 2; - this.barY = height * 0.16; - this.bar = new Graphics(); - this.summary = new Text('', { fillColor: Color.white, fontSize: 18, align: 'center' }); - this.summary.setAnchor(0.5, 0).setPosition(width / 2, this.barY + 44); + this.summary.setAnchor(0.5, 0).setPosition(width / 2, height * 0.16); this.inputs.onTrigger(Keyboard.N, () => { this.night = !this.night; @@ -152,16 +151,6 @@ class AssetCatalogsScene extends Scene { this.inputs.onTrigger(Keyboard.G, () => { void this.useVariant('hue-ramp'); }); - - this.hud = mountControls({ - title: 'Asset Catalogs', - controls: [ - { keys: 'N', action: 'swap the ground texture for the derived night catalog' }, - { keys: 'G', action: 'load a ground texture by a computed path' }, - { keys: 'M', action: 'play the streamed theme (a non-leaf asset)' }, - ], - hint: 'Three catalogs — a shared one, a composed one, and one derived with extend — loaded through two parallel queues.', - }); } // #region guide:dynamic-path @@ -178,17 +167,10 @@ class AssetCatalogsScene extends Scene { // #endregion guide:dynamic-path override draw(context: RenderingContext): void { - this.bar.clear(); - this.bar.fillColor = new Color(48, 52, 62); - this.bar.drawRectangle(this.barX, this.barY, this.barWidth, 22); - this.bar.fillColor = new Color(110, 200, 255); - this.bar.drawRectangle(this.barX, this.barY, this.barWidth * this.progress, 22); - context.render(this.bar); - const failure = this.loadError === '' ? '' : `\n${this.loadError}`; this.summary.text = `LevelAssets = compose(SharedAssets, LevelLocalAssets) — ${Object.keys(LevelAssets.entries).length} keys\n` + - `atlas frames: ${this.frameCount} ground: ${this.night ? 'night' : 'day'} queue: ${Math.round(this.progress * 100)}%${failure}`; + `atlas frames: ${this.frameCount} ground: ${this.night ? 'night' : 'day'} logo: ${SharedAssets.logo.state}${failure}`; context.render(this.summary); context.render(this.logo); diff --git a/examples/sprites-textures/texture-sampling.js b/examples/sprites-textures/texture-sampling.js new file mode 100644 index 000000000..1abf1f9f2 --- /dev/null +++ b/examples/sprites-textures/texture-sampling.js @@ -0,0 +1,106 @@ +// Auto-generated from texture-sampling.ts - edit the .ts source, not this file. +import { Application, Color, FixedResolutionCanvasSizing, Keyboard, PixelSnapMode, ScaleModes, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; +const MODES = [ + { label: 'Nearest', scaleMode: ScaleModes.Nearest, mipmaps: false }, + { label: 'Linear', scaleMode: ScaleModes.Linear, mipmaps: false }, + { label: 'Linear + mipmaps', scaleMode: ScaleModes.LinearMipmapLinear, mipmaps: true }, +]; +const makePattern = () => { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#f9d66e' : '#284b7a'; + ctx.fillRect(x, y, 1, 1); + } + } + ctx.fillStyle = '#ff627d'; + ctx.fillRect(9, 9, 14, 14); + return canvas; +}; +class TextureSamplingScene extends Scene { + reference; + sample; + referenceTexture; + sampleTexture; + referenceLabel; + sampleLabel; + hud; + mode = 0; + zoom = 8; + snap = false; + elapsed = 0; + onWheel = (_deltaX, deltaY) => { + this.zoom = Math.max(0.3, Math.min(12, this.zoom * Math.exp(-deltaY * 0.002))); + this.updateHud(); + }; + init() { + const source = makePattern(); + this.referenceTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); + this.sampleTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); + this.reference = new Sprite(this.referenceTexture).setAnchor(0.5); + this.sample = new Sprite(this.sampleTexture).setAnchor(0.5); + this.reference.pixelSnapMode = PixelSnapMode.Geometry; + this.referenceLabel = new Text('Reference: nearest + snap', { fillColor: Color.white, fontSize: 20 }); + this.sampleLabel = new Text('', { fillColor: Color.white, fontSize: 20 }); + this.referenceLabel.setPosition(this.app.width * 0.18, this.app.height * 0.78); + this.sampleLabel.setPosition(this.app.width * 0.6, this.app.height * 0.78); + this.hud = mountControls({ + title: 'Texture Sampling and Pixel Snapping', + controls: [ + { keys: 'Space', action: 'cycle sample filtering' }, + { keys: 'P', action: 'toggle sample snapping' }, + { keys: 'Wheel', action: 'zoom both images' }, + ], + hint: 'Compare the right image with the fixed nearest-filtered reference while zooming in and out.', + }); + this.inputs.onTrigger(Keyboard.Space, () => this.selectMode((this.mode + 1) % MODES.length)); + this.inputs.onTrigger(Keyboard.P, () => { + this.snap = !this.snap; + this.sample.pixelSnapMode = this.snap ? PixelSnapMode.Geometry : PixelSnapMode.None; + this.updateHud(); + }); + this.app.input.onMouseWheel.add(this.onWheel); + this.updateHud(); + } + selectMode(index) { + this.mode = index; + this.sampleTexture.setScaleMode(MODES[index].scaleMode); + this.sampleTexture.setGenerateMipMap(MODES[index].mipmaps); + this.updateHud(); + } + updateHud() { + this.sampleLabel.text = `Sample: ${MODES[this.mode].label}${this.snap ? ' + snap' : ''}`; + this.hud.setStatus(`Zoom ${this.zoom.toFixed(2)}x · ${MODES[this.mode].label} · snap ${this.snap ? 'on' : 'off'}`); + } + update(delta) { + this.elapsed += delta; + const drift = Math.sin(this.elapsed * 1.8) * 0.45; + this.reference.setPosition(this.app.width * 0.3 + drift, this.app.height * 0.54 + drift); + this.sample.setPosition(this.app.width * 0.7 + drift, this.app.height * 0.54 + drift); + this.reference.setScale(this.zoom); + this.sample.setScale(this.zoom); + } + draw(context) { + context.render(this.reference); + context.render(this.sample); + context.render(this.referenceLabel); + context.render(this.sampleLabel); + } + destroy() { + this.app.input.onMouseWheel.remove(this.onWheel); + this.hud.dispose(); + super.destroy(); + this.referenceTexture.destroy(); + this.sampleTexture.destroy(); + } +} +const app = new Application({ + scenes: { TextureSamplingScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 18, 27), +}); +await app.start(TextureSamplingScene); diff --git a/examples/sprites-textures/texture-sampling.ts b/examples/sprites-textures/texture-sampling.ts new file mode 100644 index 000000000..56d7c9315 --- /dev/null +++ b/examples/sprites-textures/texture-sampling.ts @@ -0,0 +1,130 @@ +import { + Application, + Color, + FixedResolutionCanvasSizing, + Keyboard, + PixelSnapMode, + type RenderingContext, + ScaleModes, + Scene, + type Seconds, + Sprite, + Text, + Texture, +} from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; + +const MODES = [ + { label: 'Nearest', scaleMode: ScaleModes.Nearest, mipmaps: false }, + { label: 'Linear', scaleMode: ScaleModes.Linear, mipmaps: false }, + { label: 'Linear + mipmaps', scaleMode: ScaleModes.LinearMipmapLinear, mipmaps: true }, +] as const; + +const makePattern = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const ctx = canvas.getContext('2d')!; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#f9d66e' : '#284b7a'; + ctx.fillRect(x, y, 1, 1); + } + } + ctx.fillStyle = '#ff627d'; + ctx.fillRect(9, 9, 14, 14); + return canvas; +}; + +class TextureSamplingScene extends Scene { + private reference!: Sprite; + private sample!: Sprite; + private referenceTexture!: Texture; + private sampleTexture!: Texture; + private referenceLabel!: Text; + private sampleLabel!: Text; + private hud!: ReturnType; + private mode = 0; + private zoom = 8; + private snap = false; + private elapsed = 0; + private readonly onWheel = (_deltaX: number, deltaY: number): void => { + this.zoom = Math.max(0.3, Math.min(12, this.zoom * Math.exp(-deltaY * 0.002))); + this.updateHud(); + }; + + override init(): void { + const source = makePattern(); + this.referenceTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); + this.sampleTexture = new Texture(source, { scaleMode: ScaleModes.Nearest, generateMipMap: false }); + this.reference = new Sprite(this.referenceTexture).setAnchor(0.5); + this.sample = new Sprite(this.sampleTexture).setAnchor(0.5); + this.reference.pixelSnapMode = PixelSnapMode.Geometry; + this.referenceLabel = new Text('Reference: nearest + snap', { fillColor: Color.white, fontSize: 20 }); + this.sampleLabel = new Text('', { fillColor: Color.white, fontSize: 20 }); + this.referenceLabel.setPosition(this.app.width * 0.18, this.app.height * 0.78); + this.sampleLabel.setPosition(this.app.width * 0.6, this.app.height * 0.78); + + this.hud = mountControls({ + title: 'Texture Sampling and Pixel Snapping', + controls: [ + { keys: 'Space', action: 'cycle sample filtering' }, + { keys: 'P', action: 'toggle sample snapping' }, + { keys: 'Wheel', action: 'zoom both images' }, + ], + hint: 'Compare the right image with the fixed nearest-filtered reference while zooming in and out.', + }); + this.inputs.onTrigger(Keyboard.Space, () => this.selectMode((this.mode + 1) % MODES.length)); + this.inputs.onTrigger(Keyboard.P, () => { + this.snap = !this.snap; + this.sample.pixelSnapMode = this.snap ? PixelSnapMode.Geometry : PixelSnapMode.None; + this.updateHud(); + }); + this.app.input.onMouseWheel.add(this.onWheel); + this.updateHud(); + } + + private selectMode(index: number): void { + this.mode = index; + this.sampleTexture.setScaleMode(MODES[index].scaleMode); + this.sampleTexture.setGenerateMipMap(MODES[index].mipmaps); + this.updateHud(); + } + + private updateHud(): void { + this.sampleLabel.text = `Sample: ${MODES[this.mode].label}${this.snap ? ' + snap' : ''}`; + this.hud.setStatus(`Zoom ${this.zoom.toFixed(2)}x · ${MODES[this.mode].label} · snap ${this.snap ? 'on' : 'off'}`); + } + + override update(delta: Seconds): void { + this.elapsed += delta; + const drift = Math.sin(this.elapsed * 1.8) * 0.45; + this.reference.setPosition(this.app.width * 0.3 + drift, this.app.height * 0.54 + drift); + this.sample.setPosition(this.app.width * 0.7 + drift, this.app.height * 0.54 + drift); + this.reference.setScale(this.zoom); + this.sample.setScale(this.zoom); + } + + override draw(context: RenderingContext): void { + context.render(this.reference); + context.render(this.sample); + context.render(this.referenceLabel); + context.render(this.sampleLabel); + } + + override destroy(): void { + this.app.input.onMouseWheel.remove(this.onWheel); + this.hud.dispose(); + super.destroy(); + this.referenceTexture.destroy(); + this.sampleTexture.destroy(); + } +} + +const app = new Application({ + scenes: { TextureSamplingScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 18, 27), +}); + +await app.start(TextureSamplingScene); diff --git a/examples/text-fonts/typographic-styling.js b/examples/text-fonts/typographic-styling.js index f4e5d0985..022dd80b9 100644 --- a/examples/text-fonts/typographic-styling.js +++ b/examples/text-fonts/typographic-styling.js @@ -1,86 +1,65 @@ // Auto-generated from typographic-styling.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; -const ink = new Color(232, 238, 250); -const margin = 64; -class TypographicStylingScene extends Scene { +import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +class TextStylesScene extends Scene { labels = []; + async load() { + await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + } init() { const { width } = this.app; - const column = (width - margin * 2) / 2; - // A multi-stop gradient with a CSS-style angle: 0 runs towards the top, - // 90 towards the right, and the ramp spans the ink box corner to corner. - this.place( - new Text('LEVEL UP', { - fontSize: 76, - gradient: { - stops: [ - { offset: 0, color: new Color(255, 255, 255) }, - { offset: 0.55, color: new Color(255, 214, 92) }, - { offset: 1, color: new Color(255, 96, 84) }, - ], - angle: 200, - }, - }), - margin, - margin, - ); - // Small caps and oblique come from the CSS font shorthand, so a family's - // own glyphs are used where it ships them. - this.place(new Text('Chapter One', { fillColor: ink, fontSize: 34, fontVariant: 'small-caps' }), margin, 170); - this.place(new Text('a passing thought', { fillColor: ink, fontSize: 26, fontStyle: 'oblique' }), margin, 220); - // Rules are quads the layout emits per line, so they follow alignment and - // wrapping. Without decorationColor they take the fill. - this.place(new Text('Read the manual', { fillColor: ink, fontSize: 26, underline: true }), margin, 272); - this.place(new Text('999 gold', { fillColor: ink, fontSize: 26, strikethrough: true, decorationColor: new Color(255, 96, 84) }), margin + column, 272); - // A case mapping applied at layout time - the node's own text is untouched. - this.place(new Text('quest complete', { fillColor: ink, fontSize: 26, textTransform: 'uppercase' }), margin, 326); - this.place(new Text('the sunken keep', { fillColor: ink, fontSize: 26, textTransform: 'capitalize' }), margin + column, 326); - // maxLines clips the line count; overflow marks the cut. - this.place( - new Text('The keep has stood since long before the first maps were drawn, and nobody living remembers who raised it.', { - fillColor: ink, - fontSize: 20, - maxWidth: column - 24, - maxLines: 2, - overflow: 'ellipsis', - }), - margin, - 388, - ); - // Preserved tabs advance to the next stop, so the values line up as a column. - this.place( - new Text('Sword\t120\nShield\t80\nPotion\t15', { - fillColor: ink, - fontSize: 20, - lineHeight: 1.4, - whiteSpace: 'pre', - tabSize: 4, - }), - margin + column, - 388, - ); + const title = new Text('EXOJS', { + fontFamily: 'Kenney Future', + fontSize: 102, + gradient: { + stops: [ + { offset: 0, color: new Color(255, 246, 172) }, + { offset: 0.55, color: new Color(255, 165, 94) }, + { offset: 1, color: new Color(255, 88, 122) }, + ], + angle: 90, + }, + outlineColor: new Color(45, 62, 105), + outlineWidth: 0.25, + shadowColor: Color.black, + shadowAlpha: 0.65, + shadowOffsetX: 7, + shadowOffsetY: 8, + shadowBlur: 0.5, + }); + title.setAnchor(0.5).setPosition(width / 2, 175); + this.labels.push(title); + const defaultFont = new Text('Default font', { fillColor: Color.white, fontSize: 43 }); + defaultFont.setAnchor(0.5).setPosition(width / 2, 350); + this.labels.push(defaultFont); + const loadedFont = new Text('Kenney Future font', { fillColor: new Color(124, 218, 255), fontFamily: 'Kenney Future', fontSize: 43 }); + loadedFont.setAnchor(0.5).setPosition(width / 2, 435); + this.labels.push(loadedFont); + const decoration = new Text('Underline and shadow', { + fillColor: new Color(229, 236, 250), + fontSize: 32, + underline: true, + shadowColor: Color.black, + shadowAlpha: 0.8, + shadowOffsetX: 4, + shadowOffsetY: 5, + }); + decoration.setAnchor(0.5).setPosition(width / 2, 555); + this.labels.push(decoration); } draw(context) { for (const label of this.labels) { context.render(label); } } - place(label, x, y) { - label.setPosition(x, y); - this.labels.push(label); + destroy() { + this.labels.forEach(label => label.destroy()); + super.destroy(); } } const app = new Application({ - scenes: { TypographicStylingScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { TextStylesScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(18, 22, 34), - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TypographicStylingScene); +await app.start(TextStylesScene); diff --git a/examples/text-fonts/typographic-styling.ts b/examples/text-fonts/typographic-styling.ts index 114922e3e..e0f07a4ef 100644 --- a/examples/text-fonts/typographic-styling.ts +++ b/examples/text-fonts/typographic-styling.ts @@ -1,72 +1,55 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; -const ink = new Color(232, 238, 250); -const margin = 64; - -class TypographicStylingScene extends Scene { +class TextStylesScene extends Scene { private readonly labels: Text[] = []; + override async load(): Promise { + await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + } + override init(): void { const { width } = this.app; - const column = (width - margin * 2) / 2; - - // A multi-stop gradient with a CSS-style angle: 0 runs towards the top, - // 90 towards the right, and the ramp spans the ink box corner to corner. - this.place( - new Text('LEVEL UP', { - fontSize: 76, - gradient: { - stops: [ - { offset: 0, color: new Color(255, 255, 255) }, - { offset: 0.55, color: new Color(255, 214, 92) }, - { offset: 1, color: new Color(255, 96, 84) }, - ], - angle: 200, - }, - }), - margin, - margin, - ); - - // Small caps and oblique come from the CSS font shorthand, so a family's - // own glyphs are used where it ships them. - this.place(new Text('Chapter One', { fillColor: ink, fontSize: 34, fontVariant: 'small-caps' }), margin, 170); - this.place(new Text('a passing thought', { fillColor: ink, fontSize: 26, fontStyle: 'oblique' }), margin, 220); - - // Rules are quads the layout emits per line, so they follow alignment and - // wrapping. Without decorationColor they take the fill. - this.place(new Text('Read the manual', { fillColor: ink, fontSize: 26, underline: true }), margin, 272); - this.place(new Text('999 gold', { fillColor: ink, fontSize: 26, strikethrough: true, decorationColor: new Color(255, 96, 84) }), margin + column, 272); + const title = new Text('EXOJS', { + fontFamily: 'Kenney Future', + fontSize: 102, + gradient: { + stops: [ + { offset: 0, color: new Color(255, 246, 172) }, + { offset: 0.55, color: new Color(255, 165, 94) }, + { offset: 1, color: new Color(255, 88, 122) }, + ], + angle: 90, + }, + outlineColor: new Color(45, 62, 105), + outlineWidth: 0.25, + shadowColor: Color.black, + shadowAlpha: 0.65, + shadowOffsetX: 7, + shadowOffsetY: 8, + shadowBlur: 0.5, + }); + title.setAnchor(0.5).setPosition(width / 2, 175); + this.labels.push(title); - // A case mapping applied at layout time - the node's own text is untouched. - this.place(new Text('quest complete', { fillColor: ink, fontSize: 26, textTransform: 'uppercase' }), margin, 326); - this.place(new Text('the sunken keep', { fillColor: ink, fontSize: 26, textTransform: 'capitalize' }), margin + column, 326); + const defaultFont = new Text('Default font', { fillColor: Color.white, fontSize: 43 }); + defaultFont.setAnchor(0.5).setPosition(width / 2, 350); + this.labels.push(defaultFont); - // maxLines clips the line count; overflow marks the cut. - this.place( - new Text('The keep has stood since long before the first maps were drawn, and nobody living remembers who raised it.', { - fillColor: ink, - fontSize: 20, - maxWidth: column - 24, - maxLines: 2, - overflow: 'ellipsis', - }), - margin, - 388, - ); + const loadedFont = new Text('Kenney Future font', { fillColor: new Color(124, 218, 255), fontFamily: 'Kenney Future', fontSize: 43 }); + loadedFont.setAnchor(0.5).setPosition(width / 2, 435); + this.labels.push(loadedFont); - // Preserved tabs advance to the next stop, so the values line up as a column. - this.place( - new Text('Sword\t120\nShield\t80\nPotion\t15', { - fillColor: ink, - fontSize: 20, - lineHeight: 1.4, - whiteSpace: 'pre', - tabSize: 4, - }), - margin + column, - 388, - ); + const decoration = new Text('Underline and shadow', { + fillColor: new Color(229, 236, 250), + fontSize: 32, + underline: true, + shadowColor: Color.black, + shadowAlpha: 0.8, + shadowOffsetX: 4, + shadowOffsetY: 5, + }); + decoration.setAnchor(0.5).setPosition(width / 2, 555); + this.labels.push(decoration); } override draw(context: RenderingContext): void { @@ -75,24 +58,17 @@ class TypographicStylingScene extends Scene { } } - private place(label: Text, x: number, y: number): void { - label.setPosition(x, y); - this.labels.push(label); + override destroy(): void { + this.labels.forEach(label => label.destroy()); + super.destroy(); } } const app = new Application({ - scenes: { TypographicStylingScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { TextStylesScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(18, 22, 34), - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TypographicStylingScene); +await app.start(TextStylesScene); diff --git a/examples/tilemap/editor-objects-gameplay.js b/examples/tilemap/editor-objects-gameplay.js new file mode 100644 index 000000000..2dcb1160a --- /dev/null +++ b/examples/tilemap/editor-objects-gameplay.js @@ -0,0 +1,92 @@ +// Auto-generated from editor-objects-gameplay.ts - edit the .ts source, not this file. +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; +import { MapObjectSpawner } from '@codexo/exojs-tilemap'; +import { mountControls } from '@examples/runtime'; +const makeMarker = (object, color) => { + const marker = new Graphics(); + marker.position.set(object.x, object.y); + marker.fillColor = color; + marker.drawRoundedRectangle(0, 0, object.width, object.height, 10); + return marker; +}; +const contains = (object, x, y) => x >= object.x && x <= object.x + object.width && y >= object.y && y <= object.y + object.height; +class EditorObjectsScene extends Scene { + mapNode; + spawns; + objects = new Map(); + labels = []; + hud; + doorOpen = false; + collected = false; + async load() { + const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); + const map = source.toTileMap(); + this.mapNode = new TileMapNode(map); + const spawn = (object, color) => { + this.objects.set(object.kind, object); + const label = new Text(object.name.replace('-', ' '), { fillColor: Color.white, fontSize: 20 }); + label.setAnchor(0.5).setPosition(object.x + object.width / 2, object.y - 18); + this.labels.push(label); + return makeMarker(object, color); + }; + const spawner = new MapObjectSpawner({ + Switch: object => spawn(object, new Color(255, 190, 65)), + Door: object => spawn(object, new Color(225, 78, 83)), + Collectible: object => spawn(object, new Color(75, 225, 241)), + }); + this.spawns = await spawner.spawn(map, undefined); + this.hud = mountControls({ + title: 'Editor Objects Become Gameplay', + controls: [{ keys: 'Click', action: 'toggle the switch, then collect the gem' }], + status: 'The authored switch controls the door. Open it to collect the gem.', + hint: 'The three colored objects come from the Tiled object layer. MapObjectSpawner creates their runtime counterparts.', + }); + this.app.input.onPointerTap.add(this.onTap); + } + onTap = pointer => { + const switchObject = this.objects.get('Switch'); + const gemObject = this.objects.get('Collectible'); + if (switchObject && contains(switchObject, pointer.x, pointer.y)) { + this.doorOpen = !this.doorOpen; + const doorObject = this.objects.get('Door'); + const door = doorObject && this.spawns.get(doorObject.id); + if (door && doorObject) { + door.clear(); + door.fillColor = this.doorOpen ? new Color(54, 167, 119, 0.35) : new Color(225, 78, 83); + door.drawRoundedRectangle(0, 0, doorObject.width, doorObject.height, 10); + } + this.hud.setStatus(this.doorOpen ? 'Door open. Click the gem.' : 'Door closed. Click the switch to reopen it.'); + } else if (gemObject && contains(gemObject, pointer.x, pointer.y) && !this.collected) { + if (!this.doorOpen) { + this.hud.setStatus('The door blocks the gem. Click the switch first.'); + return; + } + this.collected = true; + const gem = this.spawns.get(gemObject.id); + if (gem) gem.visible = false; + this.hud.setStatus(`Gem collected. Its runtime object came from authored map id ${gemObject.id}.`); + } + }; + draw(context) { + context.render(this.mapNode); + for (const object of this.spawns.objects) context.render(object); + for (const label of this.labels) context.render(label); + } + destroy() { + this.app.input.onPointerTap.remove(this.onTap); + this.hud?.dispose(); + this.labels.forEach(label => label.destroy()); + this.spawns?.destroy(); + this.mapNode?.destroy(); + super.destroy(); + } +} +const app = new Application({ + scenes: { EditorObjectsScene }, + canvas: { width: 1280, height: 768, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(28, 36, 46), + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, +}); +await app.start(EditorObjectsScene); diff --git a/examples/tilemap/editor-objects-gameplay.ts b/examples/tilemap/editor-objects-gameplay.ts new file mode 100644 index 000000000..a23f5046d --- /dev/null +++ b/examples/tilemap/editor-objects-gameplay.ts @@ -0,0 +1,103 @@ +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; +import { type MapObjectDescriptor, MapObjectSpawner, type MapSpawnSession } from '@codexo/exojs-tilemap'; +import { mountControls } from '@examples/runtime'; + +const makeMarker = (object: MapObjectDescriptor, color: Color): Graphics => { + const marker = new Graphics(); + marker.position.set(object.x, object.y); + marker.fillColor = color; + marker.drawRoundedRectangle(0, 0, object.width, object.height, 10); + return marker; +}; + +const contains = (object: MapObjectDescriptor, x: number, y: number): boolean => + x >= object.x && x <= object.x + object.width && y >= object.y && y <= object.y + object.height; + +class EditorObjectsScene extends Scene { + private mapNode!: TileMapNode; + private spawns!: MapSpawnSession; + private readonly objects = new Map(); + private readonly labels: Text[] = []; + private hud!: ReturnType; + private doorOpen = false; + private collected = false; + + override async load(): Promise { + const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); + const map = source.toTileMap(); + this.mapNode = new TileMapNode(map); + + const spawn = (object: MapObjectDescriptor, color: Color): Graphics => { + this.objects.set(object.kind!, object); + const label = new Text(object.name.replace('-', ' '), { fillColor: Color.white, fontSize: 20 }); + label.setAnchor(0.5).setPosition(object.x + object.width / 2, object.y - 18); + this.labels.push(label); + return makeMarker(object, color); + }; + const spawner = new MapObjectSpawner({ + Switch: object => spawn(object, new Color(255, 190, 65)), + Door: object => spawn(object, new Color(225, 78, 83)), + Collectible: object => spawn(object, new Color(75, 225, 241)), + }); + this.spawns = await spawner.spawn(map, undefined); + + this.hud = mountControls({ + title: 'Editor Objects Become Gameplay', + controls: [{ keys: 'Click', action: 'toggle the switch, then collect the gem' }], + status: 'The authored switch controls the door. Open it to collect the gem.', + hint: 'The three colored objects come from the Tiled object layer. MapObjectSpawner creates their runtime counterparts.', + }); + this.app.input.onPointerTap.add(this.onTap); + } + + private readonly onTap = (pointer: { x: number; y: number }): void => { + const switchObject = this.objects.get('Switch'); + const gemObject = this.objects.get('Collectible'); + if (switchObject && contains(switchObject, pointer.x, pointer.y)) { + this.doorOpen = !this.doorOpen; + const doorObject = this.objects.get('Door'); + const door = doorObject && this.spawns.get(doorObject.id); + if (door && doorObject) { + door.clear(); + door.fillColor = this.doorOpen ? new Color(54, 167, 119, 0.35) : new Color(225, 78, 83); + door.drawRoundedRectangle(0, 0, doorObject.width, doorObject.height, 10); + } + this.hud.setStatus(this.doorOpen ? 'Door open. Click the gem.' : 'Door closed. Click the switch to reopen it.'); + } else if (gemObject && contains(gemObject, pointer.x, pointer.y) && !this.collected) { + if (!this.doorOpen) { + this.hud.setStatus('The door blocks the gem. Click the switch first.'); + return; + } + this.collected = true; + const gem = this.spawns.get(gemObject.id); + if (gem) gem.visible = false; + this.hud.setStatus(`Gem collected. Its runtime object came from authored map id ${gemObject.id}.`); + } + }; + + override draw(context: RenderingContext): void { + context.render(this.mapNode); + for (const object of this.spawns.objects) context.render(object); + for (const label of this.labels) context.render(label); + } + + override destroy(): void { + this.app.input.onPointerTap.remove(this.onTap); + this.hud?.dispose(); + this.labels.forEach(label => label.destroy()); + this.spawns?.destroy(); + this.mapNode?.destroy(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { EditorObjectsScene }, + canvas: { width: 1280, height: 768, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(28, 36, 46), + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, +}); + +await app.start(EditorObjectsScene); diff --git a/examples/tilemap/level-loading-and-ownership.js b/examples/tilemap/level-loading-and-ownership.js new file mode 100644 index 000000000..04953a4e8 --- /dev/null +++ b/examples/tilemap/level-loading-and-ownership.js @@ -0,0 +1,140 @@ +// Auto-generated from level-loading-and-ownership.ts - edit the .ts source, not this file. +import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; +import { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +const ROOM_SIZE = { x: 0, y: 0, width: 1280, height: 768 }; +const world = new MapWorld({ + name: 'Two Rooms', + levels: [ + { id: 'harbor', name: 'Harbor', index: 0, bounds: ROOM_SIZE, external: true, neighbours: [{ id: 'workshop', side: MapLevelSide.East }], properties: {} }, + { id: 'workshop', name: 'Workshop', index: 1, bounds: ROOM_SIZE, external: true, neighbours: [{ id: 'harbor', side: MapLevelSide.West }], properties: {} }, + ], +}); +const roomPath = { + harbor: 'json/maps/harbor-plaza.tmj', + workshop: 'json/maps/ownership-workshop.tmj', +}; +const waitForTransition = signal => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Transition cancelled.', 'AbortError')); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('Transition cancelled.', 'AbortError')); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, 750); + signal.addEventListener('abort', onAbort, { once: true }); + }); +class LevelOwnershipScene extends Scene { + runtime; + node = null; + activeId = null; + pendingId = null; + roomLabel = new Text('No room loaded', { fillColor: new Color(31, 49, 54), fontSize: 34 }); + hud; + panel; + init() { + this.roomLabel.position.set(470, 80); + this.runtime = new MapWorldRuntime({ + world, + scope: this.loader, + load: async ({ level, scope, signal }) => { + await waitForTransition(signal); + return scope.load(Asset.type('tileMap', roomPath[level.id])); + }, + }); + this.hud = mountControls({ + title: 'Level Loading and Asset Ownership', + controls: [{ keys: 'Panel', action: 'load a room, cancel a pending load, or unload it' }], + status: 'Each room gets a child loader scope. Both maps reference the same atlas.', + hint: 'A short delay makes cancellation observable; the map and its asset scope remain owned by MapWorldRuntime.', + }); + this.panel = mountControlPanel({ title: 'Rooms' }); + this.panel.addButton({ + label: 'Enter Harbor', + onClick: () => { + void this.enter('harbor'); + }, + }); + this.panel.addButton({ + label: 'Enter Workshop', + onClick: () => { + void this.enter('workshop'); + }, + }); + this.panel.addButton({ label: 'Cancel pending', onClick: () => this.cancelPending() }); + this.panel.addButton({ label: 'Unload room', onClick: () => this.unloadActive() }); + void this.enter('harbor'); + } + async enter(id) { + if (this.pendingId) this.cancelPending(); + if (id === this.activeId) return; + this.pendingId = id; + this.hud.setStatus(`Loading ${world.getLevel(id)?.name}... ${this.describeResidency()}`); + try { + const level = await this.runtime.loadLevel(id); + if (this.pendingId !== id) return; + const previousId = this.activeId; + this.node?.destroy(); + this.node = new TileMapNode(level.map); + this.node.position.set(96, 34); + this.node.scale.set(0.85); + this.activeId = id; + this.roomLabel.text = `${level.level.name} loaded`; + if (previousId) this.runtime.unloadLevel(previousId); + this.hud.setStatus(`${level.level.name} ready. ${this.describeResidency()}`); + } catch (error) { + if (this.pendingId === id) this.hud.setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (this.pendingId === id) this.pendingId = null; + } + } + cancelPending() { + if (!this.pendingId) return; + const id = this.pendingId; + this.pendingId = null; + this.runtime.unloadLevel(id); + this.hud.setStatus(`${world.getLevel(id)?.name} cancelled. ${this.describeResidency()}`); + } + unloadActive() { + if (!this.activeId) return; + const id = this.activeId; + this.node?.destroy(); + this.node = null; + this.activeId = null; + this.runtime.unloadLevel(id); + this.roomLabel.text = 'No room loaded'; + this.hud.setStatus(`${world.getLevel(id)?.name} unloaded. ${this.describeResidency()}`); + } + describeResidency() { + const atlas = this.app.loader.inspect().find(row => row.aliases.some(alias => alias.includes('mapPack_tilesheet'))); + return `${this.runtime.levels.length} live room(s), shared atlas ${atlas?.claims ?? 0} claim(s)`; + } + draw(context) { + if (this.node) context.render(this.node); + context.render(this.roomLabel); + } + destroy() { + this.cancelPending(); + this.node?.destroy(); + this.runtime?.destroy(); + this.roomLabel.destroy(); + this.hud?.dispose(); + this.panel?.dispose(); + super.destroy(); + } +} +const app = new Application({ + scenes: { LevelOwnershipScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(28, 36, 46), + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, +}); +await app.start(LevelOwnershipScene); diff --git a/examples/tilemap/level-loading-and-ownership.ts b/examples/tilemap/level-loading-and-ownership.ts new file mode 100644 index 000000000..eb58eec8a --- /dev/null +++ b/examples/tilemap/level-loading-and-ownership.ts @@ -0,0 +1,152 @@ +import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; +import { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +const ROOM_SIZE = { x: 0, y: 0, width: 1280, height: 768 }; +const world = new MapWorld({ + name: 'Two Rooms', + levels: [ + { id: 'harbor', name: 'Harbor', index: 0, bounds: ROOM_SIZE, external: true, neighbours: [{ id: 'workshop', side: MapLevelSide.East }], properties: {} }, + { id: 'workshop', name: 'Workshop', index: 1, bounds: ROOM_SIZE, external: true, neighbours: [{ id: 'harbor', side: MapLevelSide.West }], properties: {} }, + ], +}); + +const roomPath: Record = { + harbor: 'json/maps/harbor-plaza.tmj', + workshop: 'json/maps/ownership-workshop.tmj', +}; + +const waitForTransition = (signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Transition cancelled.', 'AbortError')); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + reject(new DOMException('Transition cancelled.', 'AbortError')); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, 750); + signal.addEventListener('abort', onAbort, { once: true }); + }); + +class LevelOwnershipScene extends Scene { + private runtime!: MapWorldRuntime; + private node: TileMapNode | null = null; + private activeId: string | null = null; + private pendingId: string | null = null; + private readonly roomLabel = new Text('No room loaded', { fillColor: new Color(31, 49, 54), fontSize: 34 }); + private hud!: ReturnType; + private panel!: ReturnType; + + override init(): void { + this.roomLabel.position.set(470, 80); + this.runtime = new MapWorldRuntime({ + world, + scope: this.loader, + load: async ({ level, scope, signal }) => { + await waitForTransition(signal); + return scope.load(Asset.type('tileMap', roomPath[level.id]!)); + }, + }); + this.hud = mountControls({ + title: 'Level Loading and Asset Ownership', + controls: [{ keys: 'Panel', action: 'load a room, cancel a pending load, or unload it' }], + status: 'Each room gets a child loader scope. Both maps reference the same atlas.', + hint: 'A short delay makes cancellation observable; the map and its asset scope remain owned by MapWorldRuntime.', + }); + this.panel = mountControlPanel({ title: 'Rooms' }); + this.panel.addButton({ + label: 'Enter Harbor', + onClick: () => { + void this.enter('harbor'); + }, + }); + this.panel.addButton({ + label: 'Enter Workshop', + onClick: () => { + void this.enter('workshop'); + }, + }); + this.panel.addButton({ label: 'Cancel pending', onClick: () => this.cancelPending() }); + this.panel.addButton({ label: 'Unload room', onClick: () => this.unloadActive() }); + void this.enter('harbor'); + } + + private async enter(id: string): Promise { + if (this.pendingId) this.cancelPending(); + if (id === this.activeId) return; + this.pendingId = id; + this.hud.setStatus(`Loading ${world.getLevel(id)?.name}... ${this.describeResidency()}`); + try { + const level = await this.runtime.loadLevel(id); + if (this.pendingId !== id) return; + const previousId = this.activeId; + this.node?.destroy(); + this.node = new TileMapNode(level.map); + this.node.position.set(96, 34); + this.node.scale.set(0.85); + this.activeId = id; + this.roomLabel.text = `${level.level.name} loaded`; + if (previousId) this.runtime.unloadLevel(previousId); + this.hud.setStatus(`${level.level.name} ready. ${this.describeResidency()}`); + } catch (error) { + if (this.pendingId === id) this.hud.setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (this.pendingId === id) this.pendingId = null; + } + } + + private cancelPending(): void { + if (!this.pendingId) return; + const id = this.pendingId; + this.pendingId = null; + this.runtime.unloadLevel(id); + this.hud.setStatus(`${world.getLevel(id)?.name} cancelled. ${this.describeResidency()}`); + } + + private unloadActive(): void { + if (!this.activeId) return; + const id = this.activeId; + this.node?.destroy(); + this.node = null; + this.activeId = null; + this.runtime.unloadLevel(id); + this.roomLabel.text = 'No room loaded'; + this.hud.setStatus(`${world.getLevel(id)?.name} unloaded. ${this.describeResidency()}`); + } + + private describeResidency(): string { + const atlas = this.app.loader.inspect().find(row => row.aliases.some(alias => alias.includes('mapPack_tilesheet'))); + return `${this.runtime.levels.length} live room(s), shared atlas ${atlas?.claims ?? 0} claim(s)`; + } + + override draw(context: RenderingContext): void { + if (this.node) context.render(this.node); + context.render(this.roomLabel); + } + + override destroy(): void { + this.cancelPending(); + this.node?.destroy(); + this.runtime?.destroy(); + this.roomLabel.destroy(); + this.hud?.dispose(); + this.panel?.dispose(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { LevelOwnershipScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(28, 36, 46), + extensions: [tiledExtension], + loader: { basePath: 'assets/' }, +}); + +await app.start(LevelOwnershipScene); diff --git a/examples/tilemap/paint-autotiled-room.js b/examples/tilemap/paint-autotiled-room.js new file mode 100644 index 000000000..4f8364c68 --- /dev/null +++ b/examples/tilemap/paint-autotiled-room.js @@ -0,0 +1,125 @@ +// Auto-generated from paint-autotiled-room.ts - edit the .ts source, not this file. +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, TextureRegion } from '@codexo/exojs'; +import { refreshCell, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet, WangSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +const TILE = 64; +const WIDTH = 16; +const HEIGHT = 11; +const OFFSET_X = 128; +const GRASS = 23; +const grassVariant = mask => { + const top = (mask & 1) !== 0; + const right = (mask & 2) !== 0; + const bottom = (mask & 4) !== 0; + const left = (mask & 8) !== 0; + if (!top && !left) return 5; + if (!top && !right) return 7; + if (!bottom && !left) return 39; + if (!bottom && !right) return 41; + if (!top) return 6; + if (!right) return 24; + if (!bottom) return 40; + if (!left) return 22; + return GRASS; +}; +class AutoTiledRoomScene extends Scene { + layer; + tileset; + node; + grid = new Graphics(); + hud; + panel; + wang = new WangSet({ + tilesetIndex: 0, + type: 'edge', + blobMap: Object.fromEntries(Array.from({ length: 16 }, (_, mask) => [mask, grassVariant(mask)])), + }); + erase = false; + painting = false; + async load() { + const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'map-pack', + texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + this.layer = new TileLayer({ id: 1, name: 'grass', width: WIDTH, height: HEIGHT, tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + for (let y = 3; y < 8; y++) { + for (let x = 4; x < 12; x++) this.layer.setTileAt(x, y, { tileset: this.tileset, localTileId: GRASS, transform: TILE_TRANSFORM_IDENTITY }); + } + for (let y = 3; y < 8; y++) { + for (let x = 4; x < 12; x++) refreshCell(this.layer, x, y, this.wang, { wrapBorder: false }); + } + this.node = new TileMapNode( + new TileMap({ name: 'painted-room', width: WIDTH, height: HEIGHT, tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.layer] }), + ); + this.node.position.set(OFFSET_X, 0); + this.grid.lineWidth = 1; + this.grid.lineColor = new Color(72, 89, 99, 0.35); + for (let x = 0; x <= WIDTH; x++) this.grid.drawLine(OFFSET_X + x * TILE, 0, OFFSET_X + x * TILE, HEIGHT * TILE); + for (let y = 0; y <= HEIGHT; y++) this.grid.drawLine(OFFSET_X, y * TILE, OFFSET_X + WIDTH * TILE, y * TILE); + this.hud = mountControls({ + title: 'Paint an Autotiled Room', + controls: [{ keys: 'Drag', action: 'paint or erase grass' }], + status: 'Paint grass. Each edit refreshes only the cell and its neighbors.', + hint: 'WangSet maps four-neighbor masks to tile variants in the existing map atlas.', + }); + this.panel = mountControlPanel({ title: 'Brush' }); + this.panel.addToggle({ + label: 'Erase', + value: false, + onChange: value => { + this.erase = value; + }, + }); + this.app.input.onPointerDown.add(this.onDown); + this.app.input.onPointerMove.add(this.onMove); + this.app.input.onPointerUp.add(this.onEnd); + this.app.input.onPointerCancel.add(this.onEnd); + } + paint = pointer => { + const x = Math.floor((pointer.x - OFFSET_X) / TILE); + const y = Math.floor(pointer.y / TILE); + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + if (this.erase) this.layer.clearTileAt(x, y); + else this.layer.setTileAt(x, y, { tileset: this.tileset, localTileId: GRASS, transform: TILE_TRANSFORM_IDENTITY }); + refreshCell(this.layer, x, y, this.wang, { wrapBorder: false }); + this.hud.setStatus(`${this.erase ? 'Erased' : 'Painted'} (${x}, ${y}); local Wang neighbors updated.`); + }; + onDown = pointer => { + this.painting = true; + this.paint(pointer); + }; + onMove = pointer => { + if (this.painting) this.paint(pointer); + }; + onEnd = () => { + this.painting = false; + }; + draw(context) { + context.render(this.node); + context.render(this.grid); + } + destroy() { + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.panel?.dispose(); + this.grid.destroy(); + this.node?.destroy(); + super.destroy(); + } +} +const app = new Application({ + scenes: { AutoTiledRoomScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(32, 44, 50), + extensions: [tilemapExtension], + loader: { basePath: 'assets/' }, +}); +await app.start(AutoTiledRoomScene); diff --git a/examples/tilemap/paint-autotiled-room.ts b/examples/tilemap/paint-autotiled-room.ts new file mode 100644 index 000000000..3c45d6de2 --- /dev/null +++ b/examples/tilemap/paint-autotiled-room.ts @@ -0,0 +1,135 @@ +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, TextureRegion } from '@codexo/exojs'; +import { refreshCell, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet, WangSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +const TILE = 64; +const WIDTH = 16; +const HEIGHT = 11; +const OFFSET_X = 128; +const GRASS = 23; + +const grassVariant = (mask: number): number => { + const top = (mask & 1) !== 0; + const right = (mask & 2) !== 0; + const bottom = (mask & 4) !== 0; + const left = (mask & 8) !== 0; + if (!top && !left) return 5; + if (!top && !right) return 7; + if (!bottom && !left) return 39; + if (!bottom && !right) return 41; + if (!top) return 6; + if (!right) return 24; + if (!bottom) return 40; + if (!left) return 22; + return GRASS; +}; + +class AutoTiledRoomScene extends Scene { + private layer!: TileLayer; + private tileset!: TileSet; + private node!: TileMapNode; + private readonly grid = new Graphics(); + private hud!: ReturnType; + private panel!: ReturnType; + private readonly wang = new WangSet({ + tilesetIndex: 0, + type: 'edge', + blobMap: Object.fromEntries(Array.from({ length: 16 }, (_, mask) => [mask, grassVariant(mask)])), + }); + private erase = false; + private painting = false; + + override async load(): Promise { + const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'map-pack', + texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + this.layer = new TileLayer({ id: 1, name: 'grass', width: WIDTH, height: HEIGHT, tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + for (let y = 3; y < 8; y++) { + for (let x = 4; x < 12; x++) this.layer.setTileAt(x, y, { tileset: this.tileset, localTileId: GRASS, transform: TILE_TRANSFORM_IDENTITY }); + } + for (let y = 3; y < 8; y++) { + for (let x = 4; x < 12; x++) refreshCell(this.layer, x, y, this.wang, { wrapBorder: false }); + } + this.node = new TileMapNode( + new TileMap({ name: 'painted-room', width: WIDTH, height: HEIGHT, tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.layer] }), + ); + this.node.position.set(OFFSET_X, 0); + + this.grid.lineWidth = 1; + this.grid.lineColor = new Color(72, 89, 99, 0.35); + for (let x = 0; x <= WIDTH; x++) this.grid.drawLine(OFFSET_X + x * TILE, 0, OFFSET_X + x * TILE, HEIGHT * TILE); + for (let y = 0; y <= HEIGHT; y++) this.grid.drawLine(OFFSET_X, y * TILE, OFFSET_X + WIDTH * TILE, y * TILE); + + this.hud = mountControls({ + title: 'Paint an Autotiled Room', + controls: [{ keys: 'Drag', action: 'paint or erase grass' }], + status: 'Paint grass. Each edit refreshes only the cell and its neighbors.', + hint: 'WangSet maps four-neighbor masks to tile variants in the existing map atlas.', + }); + this.panel = mountControlPanel({ title: 'Brush' }); + this.panel.addToggle({ + label: 'Erase', + value: false, + onChange: value => { + this.erase = value; + }, + }); + this.app.input.onPointerDown.add(this.onDown); + this.app.input.onPointerMove.add(this.onMove); + this.app.input.onPointerUp.add(this.onEnd); + this.app.input.onPointerCancel.add(this.onEnd); + } + + private readonly paint = (pointer: { x: number; y: number }): void => { + const x = Math.floor((pointer.x - OFFSET_X) / TILE); + const y = Math.floor(pointer.y / TILE); + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + if (this.erase) this.layer.clearTileAt(x, y); + else this.layer.setTileAt(x, y, { tileset: this.tileset, localTileId: GRASS, transform: TILE_TRANSFORM_IDENTITY }); + refreshCell(this.layer, x, y, this.wang, { wrapBorder: false }); + this.hud.setStatus(`${this.erase ? 'Erased' : 'Painted'} (${x}, ${y}); local Wang neighbors updated.`); + }; + private readonly onDown = (pointer: { x: number; y: number }): void => { + this.painting = true; + this.paint(pointer); + }; + private readonly onMove = (pointer: { x: number; y: number }): void => { + if (this.painting) this.paint(pointer); + }; + private readonly onEnd = (): void => { + this.painting = false; + }; + + override draw(context: RenderingContext): void { + context.render(this.node); + context.render(this.grid); + } + + override destroy(): void { + this.app.input.onPointerDown.remove(this.onDown); + this.app.input.onPointerMove.remove(this.onMove); + this.app.input.onPointerUp.remove(this.onEnd); + this.app.input.onPointerCancel.remove(this.onEnd); + this.hud?.dispose(); + this.panel?.dispose(); + this.grid.destroy(); + this.node?.destroy(); + super.destroy(); + } +} + +const app = new Application({ + scenes: { AutoTiledRoomScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(32, 44, 50), + extensions: [tilemapExtension], + loader: { basePath: 'assets/' }, +}); + +await app.start(AutoTiledRoomScene); diff --git a/examples/tilemap/tiled-map-import.js b/examples/tilemap/tiled-map-import.js index 279167828..2a7243665 100644 --- a/examples/tilemap/tiled-map-import.js +++ b/examples/tilemap/tiled-map-import.js @@ -1,108 +1,89 @@ // Auto-generated from tiled-map-import.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; import { ObjectKind } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; -// Loading a Tiled `.tmj` map through @codexo/exojs-tiled's *advanced* -// parsed-source path, then querying its object layer. -// -// - `loader.load(Asset.type('tiledSource', url))` returns a `TiledMap` - the -// fully parsed, diagnostic-friendly source model (map/tileset/layer -// metadata, custom properties, `.getProperty()`). This is the same file -// the common-case `Asset.type('tileMap', url)` binding loads internally. -// - `TiledMap.toTileMap()` converts it, synchronously, to the generic -// runtime `TileMap` that `TileMapNode` renders. -// - `ObjectLayer.query(filter)` finds objects by `type`, `kind`, or a -// custom `property`/`value` pair - the same object-layer data a "Tiled -// collision layer -> physics colliders" bridge would walk (see the -// Physics chapter's "Tiled Map + Physics Actor" example), here just -// queried and outlined instead of turned into colliders. -// -// harbor-plaza.tmj (examples/assets/json/maps/) is a small hand-authored -// map: a "Ground" tile layer plus a "Zones" object layer with two water -// docks (type "water", property "hazard"), a market (type "market", property -// "shop"), a spawn point, and an ellipse landmark. -const TILE = 64; -const COLUMNS = 20; -const ROWS = 12; -const FILTERS = [ - { label: 'all zones', query: {} }, - { label: 'type: water', query: { type: 'water' } }, - { label: 'type: market', query: { type: 'market' } }, - { label: 'property: hazard=true', query: { property: 'hazard', value: true } }, - { label: 'kind: point', query: { kind: ObjectKind.Point } }, -]; +const contains = (object, x, y) => { + if (object.kind === ObjectKind.Point) return Math.hypot(x - object.x, y - object.y) <= 20; + if (object.kind === ObjectKind.Ellipse) { + const nx = (x - object.x - object.width / 2) / (object.width / 2); + const ny = (y - object.y - object.height / 2) / (object.height / 2); + return nx * nx + ny * ny <= 1; + } + return x >= object.x && x <= object.x + object.width && y >= object.y && y <= object.y + object.height; +}; class TiledMapImportScene extends Scene { mapNode; overlay = new Graphics(); - zoneObjects = []; - filterIndex = 0; + labels = []; + zones = []; + selectedId = null; hud; async load() { - // Advanced path: parsed TiledMap -> map property lookup -> manual - // toTileMap() conversion (the common-case Asset.type('tileMap', ...) - // binding does the same conversion internally). - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); - const runtimeMap = source.toTileMap(); - this.mapNode = new TileMapNode(runtimeMap); - const zones = runtimeMap.getObjectLayer('Zones'); - if (!zones) { - throw new Error('harbor-plaza.tmj is missing its "Zones" object layer'); + const map = await this.loader.load(Asset.type('tileMap', 'json/maps/harbor-plaza.tmj')); + this.mapNode = new TileMapNode(map); + this.zones = [...(map.getObjectLayer('Zones')?.objects ?? [])]; + for (const object of this.zones) { + const label = new Text(object.name, { fillColor: new Color(31, 46, 50), fontSize: 19 }); + label.setAnchor(0.5).setPosition(object.x + object.width / 2, object.y + object.height / 2); + this.labels.push(label); } - this.zoneObjects = zones.objects.slice(); - this.overlay.lineWidth = 3; - this.overlay.lineColor = new Color(0xffd700); + this.redrawZones(); this.hud = mountControls({ - title: 'Tiled Map Import & Object Query', - controls: [{ keys: 'Click', action: 'cycle the ObjectLayer.query() filter' }], - status: `region: ${String(source.getProperty('region')?.value ?? '?')} — ${this.zoneObjects.length} objects in "Zones"`, - hint: 'ObjectLayer.query() filters by type / kind / a property+value pair — matching zones are outlined in gold.', - }); - this.app.input.onPointerTap.add(() => { - this.filterIndex = (this.filterIndex + 1) % FILTERS.length; - this.redrawQuery(zones.query(FILTERS[this.filterIndex].query)); + title: 'Load a Tiled Map', + controls: [{ keys: 'Click', action: 'inspect an authored object' }], + status: `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.`, + hint: 'The common tileMap asset type imports the .tmj and TileMapNode renders it. Object inspection is optional.', }); - this.redrawQuery(zones.query(FILTERS[this.filterIndex].query)); + this.app.input.onPointerTap.add(this.onTap); } - redrawQuery(matches) { + onTap = pointer => { + const selected = [...this.zones].reverse().find(object => contains(object, pointer.x, pointer.y)); + this.selectedId = selected?.id ?? null; + this.redrawZones(); + const properties = selected + ? Object.entries(selected.properties) + .map(([key, value]) => `${key}=${String(value)}`) + .join(', ') + : ''; + this.hud.setStatus( + selected + ? `${selected.name} (${selected.type || selected.kind})${properties ? ` - ${properties}` : ''}` + : 'Click a highlighted zone to inspect its authored data.', + ); + }; + redrawZones() { this.overlay.clear(); - this.overlay.lineWidth = 3; - this.overlay.lineColor = new Color(0xffd700); - for (const object of matches) { - if (object.kind === ObjectKind.Point) { - this.overlay.drawCircle(object.x, object.y, 10); - } else { - // Rectangle and ellipse zones both carry [x, y, width, height] - // bounds; draw whichever outline matches the object's own kind. - if (object.kind === ObjectKind.Ellipse) { - this.overlay.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); - } else { - this.overlay.drawRectangle(object.x, object.y, object.width, object.height); - } - } + for (const object of this.zones) { + const selected = object.id === this.selectedId; + this.overlay.lineWidth = selected ? 6 : 3; + this.overlay.lineColor = selected ? new Color(255, 191, 82) : new Color(48, 166, 184, 0.8); + this.overlay.fillColor = selected ? new Color(255, 191, 82, 0.28) : new Color(48, 166, 184, 0.14); + if (object.kind === ObjectKind.Point) this.overlay.drawCircle(object.x, object.y, selected ? 17 : 12); + else if (object.kind === ObjectKind.Ellipse) + this.overlay.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); + else this.overlay.drawRectangle(object.x, object.y, object.width, object.height); } - const names = matches.map(object => object.name || `#${object.id}`).join(', ') || 'none'; - this.hud.setStatus(`${FILTERS[this.filterIndex].label}: ${matches.length} match(es) — ${names}`); } draw(context) { context.render(this.mapNode); context.render(this.overlay); + for (const label of this.labels) context.render(label); + } + destroy() { + this.app.input.onPointerTap.remove(this.onTap); + this.hud?.dispose(); + this.labels.forEach(label => label.destroy()); + this.overlay.destroy(); + this.mapNode?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { TiledMapImportScene }, - canvas: { - width: COLUMNS * TILE, - height: ROWS * TILE, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(28, 36, 46), - // tiledExtension depends on tilemapExtension, so registering it alone is - // enough for both loading (.tmj) and rendering (TileMapNode). extensions: [tiledExtension], - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(TiledMapImportScene); diff --git a/examples/tilemap/tiled-map-import.ts b/examples/tilemap/tiled-map-import.ts index 751e70209..5ffe8f79d 100644 --- a/examples/tilemap/tiled-map-import.ts +++ b/examples/tilemap/tiled-map-import.ts @@ -1,127 +1,98 @@ -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs'; +import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { ObjectKind, type ObjectQuery, type TileMapObject } from '@codexo/exojs-tilemap'; +import { ObjectKind, type TileMapObject } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; -// Loading a Tiled `.tmj` map through @codexo/exojs-tiled's *advanced* -// parsed-source path, then querying its object layer. -// -// - `loader.load(Asset.type('tiledSource', url))` returns a `TiledMap` - the -// fully parsed, diagnostic-friendly source model (map/tileset/layer -// metadata, custom properties, `.getProperty()`). This is the same file -// the common-case `Asset.type('tileMap', url)` binding loads internally. -// - `TiledMap.toTileMap()` converts it, synchronously, to the generic -// runtime `TileMap` that `TileMapNode` renders. -// - `ObjectLayer.query(filter)` finds objects by `type`, `kind`, or a -// custom `property`/`value` pair - the same object-layer data a "Tiled -// collision layer -> physics colliders" bridge would walk (see the -// Physics chapter's "Tiled Map + Physics Actor" example), here just -// queried and outlined instead of turned into colliders. -// -// harbor-plaza.tmj (examples/assets/json/maps/) is a small hand-authored -// map: a "Ground" tile layer plus a "Zones" object layer with two water -// docks (type "water", property "hazard"), a market (type "market", property -// "shop"), a spawn point, and an ellipse landmark. - -const TILE = 64; -const COLUMNS = 20; -const ROWS = 12; - -const FILTERS: readonly { label: string; query: ObjectQuery }[] = [ - { label: 'all zones', query: {} }, - { label: 'type: water', query: { type: 'water' } }, - { label: 'type: market', query: { type: 'market' } }, - { label: 'property: hazard=true', query: { property: 'hazard', value: true } }, - { label: 'kind: point', query: { kind: ObjectKind.Point } }, -]; +const contains = (object: TileMapObject, x: number, y: number): boolean => { + if (object.kind === ObjectKind.Point) return Math.hypot(x - object.x, y - object.y) <= 20; + if (object.kind === ObjectKind.Ellipse) { + const nx = (x - object.x - object.width / 2) / (object.width / 2); + const ny = (y - object.y - object.height / 2) / (object.height / 2); + return nx * nx + ny * ny <= 1; + } + return x >= object.x && x <= object.x + object.width && y >= object.y && y <= object.y + object.height; +}; class TiledMapImportScene extends Scene { private mapNode!: TileMapNode; - private overlay = new Graphics(); - private zoneObjects: TileMapObject[] = []; - private filterIndex = 0; + private readonly overlay = new Graphics(); + private readonly labels: Text[] = []; + private zones: TileMapObject[] = []; + private selectedId: number | null = null; private hud!: ReturnType; override async load(): Promise { - // Advanced path: parsed TiledMap -> map property lookup -> manual - // toTileMap() conversion (the common-case Asset.type('tileMap', ...) - // binding does the same conversion internally). - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); - const runtimeMap = source.toTileMap(); - - this.mapNode = new TileMapNode(runtimeMap); - - const zones = runtimeMap.getObjectLayer('Zones'); - - if (!zones) { - throw new Error('harbor-plaza.tmj is missing its "Zones" object layer'); + const map = await this.loader.load(Asset.type('tileMap', 'json/maps/harbor-plaza.tmj')); + this.mapNode = new TileMapNode(map); + this.zones = [...(map.getObjectLayer('Zones')?.objects ?? [])]; + + for (const object of this.zones) { + const label = new Text(object.name, { fillColor: new Color(31, 46, 50), fontSize: 19 }); + label.setAnchor(0.5).setPosition(object.x + object.width / 2, object.y + object.height / 2); + this.labels.push(label); } - - this.zoneObjects = zones.objects.slice(); - - this.overlay.lineWidth = 3; - this.overlay.lineColor = new Color(0xffd700); - + this.redrawZones(); this.hud = mountControls({ - title: 'Tiled Map Import & Object Query', - controls: [{ keys: 'Click', action: 'cycle the ObjectLayer.query() filter' }], - status: `region: ${String(source.getProperty('region')?.value ?? '?')} — ${this.zoneObjects.length} objects in "Zones"`, - hint: 'ObjectLayer.query() filters by type / kind / a property+value pair — matching zones are outlined in gold.', + title: 'Load a Tiled Map', + controls: [{ keys: 'Click', action: 'inspect an authored object' }], + status: `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.`, + hint: 'The common tileMap asset type imports the .tmj and TileMapNode renders it. Object inspection is optional.', }); - - this.app.input.onPointerTap.add(() => { - this.filterIndex = (this.filterIndex + 1) % FILTERS.length; - this.redrawQuery(zones.query(FILTERS[this.filterIndex]!.query)); - }); - - this.redrawQuery(zones.query(FILTERS[this.filterIndex]!.query)); + this.app.input.onPointerTap.add(this.onTap); } - private redrawQuery(matches: TileMapObject[]): void { + private readonly onTap = (pointer: { x: number; y: number }): void => { + const selected = [...this.zones].reverse().find(object => contains(object, pointer.x, pointer.y)); + this.selectedId = selected?.id ?? null; + this.redrawZones(); + const properties = selected + ? Object.entries(selected.properties) + .map(([key, value]) => `${key}=${String(value)}`) + .join(', ') + : ''; + this.hud.setStatus( + selected + ? `${selected.name} (${selected.type || selected.kind})${properties ? ` - ${properties}` : ''}` + : 'Click a highlighted zone to inspect its authored data.', + ); + }; + + private redrawZones(): void { this.overlay.clear(); - this.overlay.lineWidth = 3; - this.overlay.lineColor = new Color(0xffd700); - - for (const object of matches) { - if (object.kind === ObjectKind.Point) { - this.overlay.drawCircle(object.x, object.y, 10); - } else { - // Rectangle and ellipse zones both carry [x, y, width, height] - // bounds; draw whichever outline matches the object's own kind. - if (object.kind === ObjectKind.Ellipse) { - this.overlay.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); - } else { - this.overlay.drawRectangle(object.x, object.y, object.width, object.height); - } - } + for (const object of this.zones) { + const selected = object.id === this.selectedId; + this.overlay.lineWidth = selected ? 6 : 3; + this.overlay.lineColor = selected ? new Color(255, 191, 82) : new Color(48, 166, 184, 0.8); + this.overlay.fillColor = selected ? new Color(255, 191, 82, 0.28) : new Color(48, 166, 184, 0.14); + if (object.kind === ObjectKind.Point) this.overlay.drawCircle(object.x, object.y, selected ? 17 : 12); + else if (object.kind === ObjectKind.Ellipse) + this.overlay.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); + else this.overlay.drawRectangle(object.x, object.y, object.width, object.height); } - - const names = matches.map(object => object.name || `#${object.id}`).join(', ') || 'none'; - - this.hud.setStatus(`${FILTERS[this.filterIndex]!.label}: ${matches.length} match(es) — ${names}`); } override draw(context: RenderingContext): void { context.render(this.mapNode); context.render(this.overlay); + for (const label of this.labels) context.render(label); + } + + override destroy(): void { + this.app.input.onPointerTap.remove(this.onTap); + this.hud?.dispose(); + this.labels.forEach(label => label.destroy()); + this.overlay.destroy(); + this.mapNode?.destroy(); + super.destroy(); } } const app = new Application({ scenes: { TiledMapImportScene }, - canvas: { - width: COLUMNS * TILE, - height: ROWS * TILE, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(28, 36, 46), - // tiledExtension depends on tilemapExtension, so registering it alone is - // enough for both loading (.tmj) and rendering (TileMapNode). extensions: [tiledExtension], - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(TiledMapImportScene); diff --git a/examples/tilemap/worker-streamed-terrain.js b/examples/tilemap/worker-streamed-terrain.js index 78fedfc3d..d6570e597 100644 --- a/examples/tilemap/worker-streamed-terrain.js +++ b/examples/tilemap/worker-streamed-terrain.js @@ -14,12 +14,8 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; import { fbm } from '@examples/terrain-noise'; const terrainWorkerSource = '"use strict";\n(() => {\n // ../shared/terrain-noise.ts\n function hash2D(seed2, x, y) {\n let h = seed2 ^ Math.imul(x, 668265261) ^ Math.imul(y, 374761393) | 0;\n h = Math.imul(h ^ h >>> 15, 2246822507);\n h = Math.imul(h ^ h >>> 13, 3266489909);\n h ^= h >>> 16;\n return (h >>> 0) / 4294967296;\n }\n function valueNoise(seed2, x, y) {\n const x0 = Math.floor(x);\n const y0 = Math.floor(y);\n const fx = x - x0;\n const fy = y - y0;\n const sx = fx * fx * (3 - 2 * fx);\n const sy = fy * fy * (3 - 2 * fy);\n const n00 = hash2D(seed2, x0, y0);\n const n10 = hash2D(seed2, x0 + 1, y0);\n const n01 = hash2D(seed2, x0, y0 + 1);\n const n11 = hash2D(seed2, x0 + 1, y0 + 1);\n const nx0 = n00 + (n10 - n00) * sx;\n const nx1 = n01 + (n11 - n01) * sx;\n return nx0 + (nx1 - nx0) * sy;\n }\n function fbm(seed2, x, y) {\n let value = 0;\n let amplitude = 0.5;\n let frequency = 1;\n for (let octave = 0; octave < 4; octave++) {\n value += amplitude * valueNoise(seed2 + octave, x * frequency, y * frequency);\n amplitude *= 0.5;\n frequency *= 2;\n }\n return value;\n }\n\n // worker-streamed-terrain.worker.ts\n var isInit = (message) => "type" in message && message.type === "terrain-init";\n var seed = 0;\n var featureSize = 1;\n var extraCost = 0;\n self.onmessage = (event) => {\n const message = event.data;\n if (isInit(message)) {\n seed = message.seed;\n featureSize = message.featureSize;\n extraCost = message.extraCost;\n return;\n }\n const { requestId, cx, cy, chunkWidth, chunkHeight } = message;\n try {\n const values = new Float64Array(chunkWidth * chunkHeight);\n for (let localTy = 0; localTy < chunkHeight; localTy++) {\n for (let localTx = 0; localTx < chunkWidth; localTx++) {\n const tx = cx * chunkWidth + localTx;\n const ty = cy * chunkHeight + localTy;\n let value = fbm(seed, tx / featureSize, ty / featureSize);\n for (let i = 0; i < extraCost; i++) {\n value = fbm(seed, tx / featureSize, ty / featureSize);\n }\n values[localTy * chunkWidth + localTx] = value;\n }\n }\n self.postMessage({ requestId, values }, [values.buffer]);\n } catch (error) {\n self.postMessage({ requestId, error: String(error) });\n }\n };\n})();\n'; -// The same infinite, procedurally generated world as "Infinite Procedural -// Terrain", but the noise sampling can run off the main thread via -// createWorkerSampledChunkSource. Toggle "Provider" between sync/worker and -// raise "Sample cost" to make each tile artificially expensive to sample - -// on the sync path the main thread stalls and the spinning marker + camera -// motion visibly hitch; on the worker path they stay smooth. +// Both providers sample the same deterministic terrain. Raise "Sample cost" +// to compare main-thread frame time against worker-backed generation. // // Both providers call the same fbm from @examples/terrain-noise: the worker // gets it bundled into its source string at build time, which is what keeps @@ -54,8 +50,8 @@ class WorkerStreamedTerrainScene extends Scene { tileset; streamer; seed = 1337; - providerMode = 'worker'; - extraCost = 200; + providerMode = 'sync'; + extraCost = 0; workerSourceHandle = null; moveX = 0; moveY = 0; @@ -163,18 +159,18 @@ class WorkerStreamedTerrainScene extends Scene { } setupHud() { this.hud = mountControls({ - title: 'Worker-Streamed Terrain', + title: 'Streamed Terrain', controls: [ { keys: 'WASD', action: 'fly across the endless world' }, - { keys: 'panel', action: 'switch provider / raise sample cost' }, + { keys: 'panel', action: 'change seed, provider, and sample cost' }, ], status: '', - hint: 'createWorkerSampledChunkSource runs the noise sampler on a Worker thread; createSampledChunkSource runs it on the main thread. Raise the sample cost and switch providers to see which one keeps the frame time flat.', + hint: 'Both providers generate the same unbounded world from the current seed. Raise sample cost, then switch to worker to compare frame time while moving.', }); const panel = mountControlPanel({ title: 'Provider' }); panel.addCycle({ label: 'Provider', - options: ['worker', 'sync'], + options: ['sync', 'worker'], index: 0, onChange: (_, mode) => { this.providerMode = mode; @@ -186,12 +182,19 @@ class WorkerStreamedTerrainScene extends Scene { min: 0, max: 500, step: 50, - value: 200, + value: 0, onChange: value => { this.extraCost = value; this.rebuildStreamer(); }, }); + panel.addButton({ + label: 'New seed', + onClick: () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, + }); } update(delta) { if (this.moveX !== 0 || this.moveY !== 0) { @@ -209,13 +212,19 @@ class WorkerStreamedTerrainScene extends Scene { const tx = Math.floor(this.explorer.x / TILE); const ty = Math.floor(this.explorer.y / TILE); this.hud.setStatus( - `${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · cost ${this.extraCost}`, + `${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · seed ${this.seed} · cost ${this.extraCost}`, ); } } draw(context) { context.render(this.worldRoot, { view: this.camera }); } + destroy() { + this.streamer?.destroy(); + this.workerSourceHandle?.destroy(); + this.workerSourceHandle = null; + super.destroy(); + } } const app = new Application({ scenes: { WorkerStreamedTerrainScene }, diff --git a/examples/tilemap/worker-streamed-terrain.ts b/examples/tilemap/worker-streamed-terrain.ts index f1c913cae..74908db33 100644 --- a/examples/tilemap/worker-streamed-terrain.ts +++ b/examples/tilemap/worker-streamed-terrain.ts @@ -31,12 +31,8 @@ import { fbm } from '@examples/terrain-noise'; import terrainWorkerSource from './worker-streamed-terrain.worker.ts?worker'; -// The same infinite, procedurally generated world as "Infinite Procedural -// Terrain", but the noise sampling can run off the main thread via -// createWorkerSampledChunkSource. Toggle "Provider" between sync/worker and -// raise "Sample cost" to make each tile artificially expensive to sample - -// on the sync path the main thread stalls and the spinning marker + camera -// motion visibly hitch; on the worker path they stay smooth. +// Both providers sample the same deterministic terrain. Raise "Sample cost" +// to compare main-thread frame time against worker-backed generation. // // Both providers call the same fbm from @examples/terrain-noise: the worker // gets it bundled into its source string at build time, which is what keeps @@ -75,8 +71,8 @@ class WorkerStreamedTerrainScene extends Scene { private tileset!: TileSet; private streamer!: ChunkStreamer; private seed = 1337; - private providerMode: 'worker' | 'sync' = 'worker'; - private extraCost = 200; + private providerMode: 'worker' | 'sync' = 'sync'; + private extraCost = 0; private workerSourceHandle: (ChunkSource & { destroy(): void }) | null = null; private moveX = 0; private moveY = 0; @@ -198,18 +194,18 @@ class WorkerStreamedTerrainScene extends Scene { private setupHud(): void { this.hud = mountControls({ - title: 'Worker-Streamed Terrain', + title: 'Streamed Terrain', controls: [ { keys: 'WASD', action: 'fly across the endless world' }, - { keys: 'panel', action: 'switch provider / raise sample cost' }, + { keys: 'panel', action: 'change seed, provider, and sample cost' }, ], status: '', - hint: 'createWorkerSampledChunkSource runs the noise sampler on a Worker thread; createSampledChunkSource runs it on the main thread. Raise the sample cost and switch providers to see which one keeps the frame time flat.', + hint: 'Both providers generate the same unbounded world from the current seed. Raise sample cost, then switch to worker to compare frame time while moving.', }); const panel = mountControlPanel({ title: 'Provider' }); panel.addCycle({ label: 'Provider', - options: ['worker', 'sync'], + options: ['sync', 'worker'], index: 0, onChange: (_, mode) => { this.providerMode = mode as 'worker' | 'sync'; @@ -221,12 +217,19 @@ class WorkerStreamedTerrainScene extends Scene { min: 0, max: 500, step: 50, - value: 200, + value: 0, onChange: value => { this.extraCost = value; this.rebuildStreamer(); }, }); + panel.addButton({ + label: 'New seed', + onClick: () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, + }); } override update(delta: Seconds): void { @@ -250,7 +253,7 @@ class WorkerStreamedTerrainScene extends Scene { const tx = Math.floor(this.explorer.x / TILE); const ty = Math.floor(this.explorer.y / TILE); this.hud.setStatus( - `${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · cost ${this.extraCost}`, + `${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · seed ${this.seed} · cost ${this.extraCost}`, ); } } @@ -258,6 +261,13 @@ class WorkerStreamedTerrainScene extends Scene { override draw(context: RenderingContext): void { context.render(this.worldRoot, { view: this.camera }); } + + override destroy(): void { + this.streamer?.destroy(); + this.workerSourceHandle?.destroy(); + this.workerSourceHandle = null; + super.destroy(); + } } const app = new Application({ diff --git a/examples/tweens-animation/easing-curves.js b/examples/tweens-animation/easing-curves.js index b1027b7a2..2bb49d093 100644 --- a/examples/tweens-animation/easing-curves.js +++ b/examples/tweens-animation/easing-curves.js @@ -1,5 +1,5 @@ // Auto-generated from easing-curves.ts - edit the .ts source, not this file. -import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; // Every built-in Ease function, in source order. const EASINGS = [ @@ -35,45 +35,39 @@ const EASINGS = [ ['elasticOut', Ease.elasticOut], ['elasticInOut', Ease.elasticInOut], ]; -// 30 easings laid out 10 wide × 3 tall to spread across the wider 16:9 frame. -const COLS = 10; -const ROWS = 3; -const HEADER = 8; -const SAMPLES = 22; -// Value range plotted per cell (back/elastic overshoot beyond [0, 1]). +const SAMPLES = 80; const V_MIN = -0.45; const V_MAX = 1.45; class EasingCurvesScene extends Scene { graphics = new Graphics(); - labels = []; + sprite; + hud; + selected = 0; t = 0; direction = 1; - cellWidth = 0; - cellHeight = 0; + onTap = pointer => this.select(pointer.x < this.app.width / 2 ? -1 : 1); init() { - const app = this.app; - const { width, height } = app; - this.cellWidth = width / COLS; - this.cellHeight = (height - HEADER) / ROWS; - this.labels = EASINGS.map(([name], index) => { - const { x, y } = this.cell(index); - const label = new Text(name, { fillColor: new Color(200, 214, 240), fontSize: 11 }); - label.setPosition(x + 10, y + 4); - return label; - }); - mountControls({ - title: `Easing Curves — all ${EASINGS.length}`, - hint: 'Each cell plots f(t) over 0→1; the dot traces the curve as t sweeps back and forth.', + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(2); + this.inputs.onTrigger(Keyboard.Left, () => this.select(-1)); + this.inputs.onTrigger(Keyboard.Right, () => this.select(1)); + this.app.input.onPointerTap.add(this.onTap); + this.hud = mountControls({ + title: 'Easing Explorer', + controls: [ + { keys: 'Left / Right', action: 'select a curve' }, + { keys: 'Click left / right half', action: 'select a curve' }, + ], + status: '', + hint: 'The ship moves along the selected easing curve. Overshoot remains visible above and below the 0–1 range.', }); + this.refreshHud(); } - cell(index) { - const col = index % COLS; - const row = Math.floor(index / COLS); - return { x: col * this.cellWidth, y: HEADER + row * this.cellHeight }; + select(direction) { + this.selected = (this.selected + direction + EASINGS.length) % EASINGS.length; + this.refreshHud(); } - plotY(value, plotTop, plotHeight) { - const norm = (value - V_MIN) / (V_MAX - V_MIN); - return plotTop + plotHeight * (1 - Math.max(0, Math.min(1, norm))); + refreshHud() { + this.hud.setStatus(`${this.selected + 1} / ${EASINGS.length} · ${EASINGS[this.selected][0]}`); } update(delta) { this.t += this.direction * delta * 0.6; @@ -86,39 +80,35 @@ class EasingCurvesScene extends Scene { } } draw(context) { + const { width, height } = this.app; + const left = width * 0.12; + const right = width * 0.88; + const top = height * 0.14; + const bottom = height * 0.9; + const plotY = value => bottom - ((value - V_MIN) / (V_MAX - V_MIN)) * (bottom - top); + const ease = EASINGS[this.selected][1]; const g = this.graphics; g.clear(); - const plotPadX = 10; - const plotW = this.cellWidth - plotPadX * 2; - const plotTop = 22; - const plotH = this.cellHeight - plotTop - 8; - // Plot backgrounds. - g.fillColor = new Color(30, 35, 48); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - g.drawRectangle(x + plotPadX, y + plotTop, plotW, plotH); - } - // Curve sample dots. - g.fillColor = new Color(96, 120, 160); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - const easing = EASINGS[i][1]; - for (let s = 0; s <= SAMPLES; s++) { - const t = s / SAMPLES; - g.drawCircle(x + plotPadX + t * plotW, this.plotY(easing(t), y + plotTop, plotH), 1.4); - } - } - // Moving dot at the current t. - g.fillColor = new Color(90, 210, 255); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - const easing = EASINGS[i][1]; - g.drawCircle(x + plotPadX + this.t * plotW, this.plotY(easing(this.t), y + plotTop, plotH), 3); + g.lineWidth = 2; + g.lineColor = new Color(72, 86, 110); + g.drawLine(left, plotY(0), right, plotY(0)); + g.drawLine(left, plotY(1), right, plotY(1)); + g.drawLine(left, top, left, bottom); + g.lineWidth = 4; + g.lineColor = new Color(90, 210, 255); + for (let sample = 1; sample <= SAMPLES; sample++) { + const previous = (sample - 1) / SAMPLES; + const current = sample / SAMPLES; + g.drawLine(left + previous * (right - left), plotY(ease(previous)), left + current * (right - left), plotY(ease(current))); } + this.sprite.setPosition(left + this.t * (right - left), plotY(ease(this.t))); context.render(g); - for (const label of this.labels) { - context.render(label); - } + context.render(this.sprite); + } + destroy() { + this.app.input.onPointerTap.remove(this.onTap); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ @@ -130,5 +120,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 21, 30), + loader: { basePath: 'assets/' }, }); await app.start(EasingCurvesScene); diff --git a/examples/tweens-animation/easing-curves.ts b/examples/tweens-animation/easing-curves.ts index 255693e87..b621a9ff1 100644 --- a/examples/tweens-animation/easing-curves.ts +++ b/examples/tweens-animation/easing-curves.ts @@ -1,4 +1,4 @@ -import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs'; +import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; // Every built-in Ease function, in source order. @@ -36,56 +36,43 @@ const EASINGS: [string, (t: number) => number][] = [ ['elasticInOut', Ease.elasticInOut], ]; -// 30 easings laid out 10 wide × 3 tall to spread across the wider 16:9 frame. -const COLS = 10; -const ROWS = 3; -const HEADER = 8; -const SAMPLES = 22; -// Value range plotted per cell (back/elastic overshoot beyond [0, 1]). +const SAMPLES = 80; const V_MIN = -0.45; const V_MAX = 1.45; class EasingCurvesScene extends Scene { private graphics = new Graphics(); - private labels: Text[] = []; + private sprite!: Sprite; + private hud!: ReturnType; + private selected = 0; private t = 0; private direction = 1; - private cellWidth = 0; - private cellHeight = 0; + private readonly onTap = (pointer: { x: number }): void => this.select(pointer.x < this.app.width / 2 ? -1 : 1); override init(): void { - const app = this.app; - const { width, height } = app; - - this.cellWidth = width / COLS; - this.cellHeight = (height - HEADER) / ROWS; - - this.labels = EASINGS.map(([name], index) => { - const { x, y } = this.cell(index); - const label = new Text(name, { fillColor: new Color(200, 214, 240), fontSize: 11 }); - - label.setPosition(x + 10, y + 4); - - return label; - }); - - mountControls({ - title: `Easing Curves — all ${EASINGS.length}`, - hint: 'Each cell plots f(t) over 0→1; the dot traces the curve as t sweeps back and forth.', + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(2); + this.inputs.onTrigger(Keyboard.Left, () => this.select(-1)); + this.inputs.onTrigger(Keyboard.Right, () => this.select(1)); + this.app.input.onPointerTap.add(this.onTap); + this.hud = mountControls({ + title: 'Easing Explorer', + controls: [ + { keys: 'Left / Right', action: 'select a curve' }, + { keys: 'Click left / right half', action: 'select a curve' }, + ], + status: '', + hint: 'The ship moves along the selected easing curve. Overshoot remains visible above and below the 0–1 range.', }); + this.refreshHud(); } - private cell(index: number): { x: number; y: number } { - const col = index % COLS; - const row = Math.floor(index / COLS); - - return { x: col * this.cellWidth, y: HEADER + row * this.cellHeight }; + private select(direction: number): void { + this.selected = (this.selected + direction + EASINGS.length) % EASINGS.length; + this.refreshHud(); } - private plotY(value: number, plotTop: number, plotHeight: number): number { - const norm = (value - V_MIN) / (V_MAX - V_MIN); - - return plotTop + plotHeight * (1 - Math.max(0, Math.min(1, norm))); + private refreshHud(): void { + this.hud.setStatus(`${this.selected + 1} / ${EASINGS.length} · ${EASINGS[this.selected][0]}`); } override update(delta: Seconds): void { @@ -101,50 +88,39 @@ class EasingCurvesScene extends Scene { } override draw(context: RenderingContext): void { + const { width, height } = this.app; + const left = width * 0.12; + const right = width * 0.88; + const top = height * 0.14; + const bottom = height * 0.9; + const plotY = (value: number): number => bottom - ((value - V_MIN) / (V_MAX - V_MIN)) * (bottom - top); + const ease = EASINGS[this.selected][1]; const g = this.graphics; g.clear(); - - const plotPadX = 10; - const plotW = this.cellWidth - plotPadX * 2; - const plotTop = 22; - const plotH = this.cellHeight - plotTop - 8; - - // Plot backgrounds. - g.fillColor = new Color(30, 35, 48); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - - g.drawRectangle(x + plotPadX, y + plotTop, plotW, plotH); - } - - // Curve sample dots. - g.fillColor = new Color(96, 120, 160); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - const easing = EASINGS[i][1]; - - for (let s = 0; s <= SAMPLES; s++) { - const t = s / SAMPLES; - - g.drawCircle(x + plotPadX + t * plotW, this.plotY(easing(t), y + plotTop, plotH), 1.4); - } - } - - // Moving dot at the current t. - g.fillColor = new Color(90, 210, 255); - for (let i = 0; i < EASINGS.length; i++) { - const { x, y } = this.cell(i); - const easing = EASINGS[i][1]; - - g.drawCircle(x + plotPadX + this.t * plotW, this.plotY(easing(this.t), y + plotTop, plotH), 3); + g.lineWidth = 2; + g.lineColor = new Color(72, 86, 110); + g.drawLine(left, plotY(0), right, plotY(0)); + g.drawLine(left, plotY(1), right, plotY(1)); + g.drawLine(left, top, left, bottom); + + g.lineWidth = 4; + g.lineColor = new Color(90, 210, 255); + for (let sample = 1; sample <= SAMPLES; sample++) { + const previous = (sample - 1) / SAMPLES; + const current = sample / SAMPLES; + g.drawLine(left + previous * (right - left), plotY(ease(previous)), left + current * (right - left), plotY(ease(current))); } + this.sprite.setPosition(left + this.t * (right - left), plotY(ease(this.t))); context.render(g); + context.render(this.sprite); + } - for (const label of this.labels) { - context.render(label); - } + override destroy(): void { + this.app.input.onPointerTap.remove(this.onTap); + this.hud.dispose(); + super.destroy(); } } @@ -157,6 +133,7 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 21, 30), + loader: { basePath: 'assets/' }, }); await app.start(EasingCurvesScene); diff --git a/examples/tweens-animation/frame-animation.js b/examples/tweens-animation/frame-animation.js index 7dee4037b..568790208 100644 --- a/examples/tweens-animation/frame-animation.js +++ b/examples/tweens-animation/frame-animation.js @@ -1,40 +1,64 @@ // Auto-generated from frame-animation.ts - edit the .ts source, not this file. -import { AnimatedSprite, Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Spritesheet } from '@codexo/exojs'; +import { AnimatedSprite, Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Spritesheet } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -const walkFps = 8; +const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow']; +const WALK_FPS = 8; class FrameAnimationScene extends Scene { sprite; - frameCount = 0; hud; + characterIndex = 0; + playing = true; async load() { - const app = this.app; - const { width, height } = app; const texture = this.loader.get('image/platformer-characters.png'); const data = await this.loader.load(Asset.type('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; - this.sprite = new AnimatedSprite(texture, { walk: { frames: walkFrames, fps: walkFps } }); - this.sprite + const clips = Object.fromEntries( + CHARACTERS.map(character => [ + character, + { + frames: ['a', 'b'].map(frame => sheet.getFrame(`character_${character}_walk_${frame}`)), + fps: WALK_FPS, + }, + ]), + ); + this.sprite = new AnimatedSprite(texture, clips) .setAnchor(0.5) .setScale(3) - .setPosition(width / 2, height / 2); + .setPosition(this.app.width / 2, this.app.height / 2); + this.addChild(this.sprite); + this.sprite.onFrame.add((_clip, frame) => this.updateHud(frame)); this.hud = mountControls({ - title: 'Frame Animation', - controls: [{ keys: 'Auto', action: `looping walk cycle @ ${walkFps} fps` }], - status: `Frame: 1/${this.frameCount}`, - hint: 'AnimatedSprite steps through spritesheet frames on a timer; onFrame drives the live readout.', + title: 'Sprite Animation', + controls: [ + { keys: 'Right', action: 'change character' }, + { keys: 'Space', action: 'pause / resume' }, + ], + hint: 'AnimatedSprite plays named clips from a spritesheet. The scene tree advances it automatically.', }); - // The onFrame signal fires on every frame advance with the 0-based index. - this.sprite.onFrame.add((_clip, frame) => this.hud.setStatus(`Frame: ${frame + 1}/${this.frameCount}`)); - // In the scene tree, playback is advanced by the engine's - // AnimationSystem once per frame -- no update() call of our own. - this.addChild(this.sprite); - this.sprite.play('walk'); + this.inputs.onTrigger(Keyboard.Space, () => { + this.playing = !this.playing; + if (this.playing) this.sprite.resume(); + else this.sprite.pause(); + this.updateHud(this.sprite.currentFrame); + }); + this.inputs.onTrigger(Keyboard.Right, () => this.selectCharacter()); + this.sprite.play(CHARACTERS[0]); + } + selectCharacter() { + this.characterIndex = (this.characterIndex + 1) % CHARACTERS.length; + this.sprite.play(CHARACTERS[this.characterIndex]); + if (!this.playing) this.sprite.pause(); + } + updateHud(frame) { + this.hud.setStatus(`${CHARACTERS[this.characterIndex]} · frame ${frame + 1}/2 · ${this.playing ? 'playing' : 'paused'}`); } draw(context) { context.render(this.root); } + destroy() { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { FrameAnimationScene }, @@ -45,8 +69,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(FrameAnimationScene); diff --git a/examples/tweens-animation/frame-animation.ts b/examples/tweens-animation/frame-animation.ts index 719b1d66d..b972daa44 100644 --- a/examples/tweens-animation/frame-animation.ts +++ b/examples/tweens-animation/frame-animation.ts @@ -4,6 +4,7 @@ import { Asset, Color, FixedResolutionCanvasSizing, + Keyboard, type RenderingContext, Scene, Spritesheet, @@ -11,48 +12,72 @@ import { } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; -const walkFps = 8; +const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow'] as const; +const WALK_FPS = 8; class FrameAnimationScene extends Scene { private sprite!: AnimatedSprite; - private frameCount = 0; private hud!: ReturnType; + private characterIndex = 0; + private playing = true; override async load(): Promise { - const app = this.app; - const { width, height } = app; const texture = this.loader.get('image/platformer-characters.png'); const data = (await this.loader.load(Asset.type('json', 'json/platformer-characters.json'))) as SpritesheetData; const sheet = new Spritesheet(texture, data); + const clips = Object.fromEntries( + CHARACTERS.map(character => [ + character, + { + frames: ['a', 'b'].map(frame => sheet.getFrame(`character_${character}_walk_${frame}`)), + fps: WALK_FPS, + }, + ]), + ); - const walkFrames = ['character_beige_walk_a', 'character_beige_walk_b'].map(name => sheet.getFrame(name)); - - this.frameCount = walkFrames.length; - this.sprite = new AnimatedSprite(texture, { walk: { frames: walkFrames, fps: walkFps } }); - this.sprite + this.sprite = new AnimatedSprite(texture, clips) .setAnchor(0.5) .setScale(3) - .setPosition(width / 2, height / 2); + .setPosition(this.app.width / 2, this.app.height / 2); + this.addChild(this.sprite); + this.sprite.onFrame.add((_clip, frame) => this.updateHud(frame)); this.hud = mountControls({ - title: 'Frame Animation', - controls: [{ keys: 'Auto', action: `looping walk cycle @ ${walkFps} fps` }], - status: `Frame: 1/${this.frameCount}`, - hint: 'AnimatedSprite steps through spritesheet frames on a timer; onFrame drives the live readout.', + title: 'Sprite Animation', + controls: [ + { keys: 'Right', action: 'change character' }, + { keys: 'Space', action: 'pause / resume' }, + ], + hint: 'AnimatedSprite plays named clips from a spritesheet. The scene tree advances it automatically.', + }); + this.inputs.onTrigger(Keyboard.Space, () => { + this.playing = !this.playing; + if (this.playing) this.sprite.resume(); + else this.sprite.pause(); + this.updateHud(this.sprite.currentFrame); }); + this.inputs.onTrigger(Keyboard.Right, () => this.selectCharacter()); + this.sprite.play(CHARACTERS[0]); + } - // The onFrame signal fires on every frame advance with the 0-based index. - this.sprite.onFrame.add((_clip, frame) => this.hud.setStatus(`Frame: ${frame + 1}/${this.frameCount}`)); + private selectCharacter(): void { + this.characterIndex = (this.characterIndex + 1) % CHARACTERS.length; + this.sprite.play(CHARACTERS[this.characterIndex]); + if (!this.playing) this.sprite.pause(); + } - // In the scene tree, playback is advanced by the engine's - // AnimationSystem once per frame -- no update() call of our own. - this.addChild(this.sprite); - this.sprite.play('walk'); + private updateHud(frame: number): void { + this.hud.setStatus(`${CHARACTERS[this.characterIndex]} · frame ${frame + 1}/2 · ${this.playing ? 'playing' : 'paused'}`); } override draw(context: RenderingContext): void { context.render(this.root); } + + override destroy(): void { + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ @@ -64,9 +89,7 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); await app.start(FrameAnimationScene); diff --git a/examples/tweens-animation/tween-basics.js b/examples/tweens-animation/tween-basics.js index 0ec221222..21e161327 100644 --- a/examples/tweens-animation/tween-basics.js +++ b/examples/tweens-animation/tween-basics.js @@ -1,38 +1,63 @@ // Auto-generated from tween-basics.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; -class TweenBasicsScene extends Scene { +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; +class InteractiveTweensScene extends Scene { sprite; - text; - forward; - backward; + target; + hud; + moveTween = null; + roundTrip = false; + onPointerDown = (_pointer, x, y) => this.moveTo(x, y); init() { - const app = this.app; - const { width, height } = app; - const left = width * 0.1; - const right = width * 0.9; - 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 = app.tweens.create(this.sprite.position).to({ x: right }, 1.2); - this.backward = app.tweens.create(this.sprite.position).to({ x: left }, 1.2); - this.forward - .onComplete(() => { - this.text.text = 'Completed -> reverse'; - this.backward.start(); - }) - .start(); - this.backward.onComplete(() => { - this.text.text = 'Completed -> forward'; - this.forward.start(); + const { width, height } = this.app; + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.target = new Graphics(); + this.hud = mountControls({ + title: 'Interactive Tweens', + controls: [ + { keys: 'Click', action: 'move to target; click again to interrupt' }, + { keys: 'Space', action: 'toggle round trip' }, + ], + hint: 'A new tween starts from the current position. Round trip uses repeat(1) with yoyo().', + }); + this.updateHud('Ready'); + this.app.input.onPointerDown.add(this.onPointerDown); + this.inputs.onTrigger(Keyboard.Space, () => { + this.roundTrip = !this.roundTrip; + this.updateHud('Ready'); }); } + moveTo(x, y) { + this.moveTween?.stop(); + this.target.clear(); + this.target.lineWidth = 2; + this.target.lineColor = new Color(120, 220, 255); + this.target.drawCircle(x, y, 24); + this.moveTween = this.app.tweens + .create(this.sprite.position) + .to({ x, y }, 0.7) + .repeat(this.roundTrip ? 1 : 0) + .yoyo(this.roundTrip) + .onComplete(() => this.updateHud('Complete')) + .start(); + this.updateHud('Moving'); + } + updateHud(state) { + this.hud.setStatus(`${state} · ${this.roundTrip ? 'round trip' : 'one way'}`); + } draw(context) { + context.render(this.target); context.render(this.sprite); - context.render(this.text); + } + destroy() { + this.moveTween?.stop(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ - scenes: { TweenBasicsScene }, + scenes: { InteractiveTweensScene }, canvas: { width: 1280, height: 720, @@ -40,8 +65,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TweenBasicsScene); +await app.start(InteractiveTweensScene); diff --git a/examples/tweens-animation/tween-basics.ts b/examples/tweens-animation/tween-basics.ts index b507473fb..8f9864aae 100644 --- a/examples/tweens-animation/tween-basics.ts +++ b/examples/tweens-animation/tween-basics.ts @@ -1,42 +1,69 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Text, Tween } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, Sprite, Tween } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; -class TweenBasicsScene extends Scene { +class InteractiveTweensScene extends Scene { private sprite!: Sprite; - private text!: Text; - private forward!: Tween; - private backward!: Tween; + private target!: Graphics; + private hud!: ReturnType; + private moveTween: Tween | null = null; + private roundTrip = false; + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => this.moveTo(x, y); override init(): void { - const app = this.app; - const { width, height } = app; - const left = width * 0.1; - const right = width * 0.9; - - 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 = app.tweens.create(this.sprite.position).to({ x: right }, 1.2); - this.backward = app.tweens.create(this.sprite.position).to({ x: left }, 1.2); - this.forward - .onComplete(() => { - this.text.text = 'Completed -> reverse'; - this.backward.start(); - }) - .start(); - this.backward.onComplete(() => { - this.text.text = 'Completed -> forward'; - this.forward.start(); + const { width, height } = this.app; + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.target = new Graphics(); + this.hud = mountControls({ + title: 'Interactive Tweens', + controls: [ + { keys: 'Click', action: 'move to target; click again to interrupt' }, + { keys: 'Space', action: 'toggle round trip' }, + ], + hint: 'A new tween starts from the current position. Round trip uses repeat(1) with yoyo().', + }); + this.updateHud('Ready'); + this.app.input.onPointerDown.add(this.onPointerDown); + this.inputs.onTrigger(Keyboard.Space, () => { + this.roundTrip = !this.roundTrip; + this.updateHud('Ready'); }); } + private moveTo(x: number, y: number): void { + this.moveTween?.stop(); + this.target.clear(); + this.target.lineWidth = 2; + this.target.lineColor = new Color(120, 220, 255); + this.target.drawCircle(x, y, 24); + this.moveTween = this.app.tweens + .create(this.sprite.position) + .to({ x, y }, 0.7) + .repeat(this.roundTrip ? 1 : 0) + .yoyo(this.roundTrip) + .onComplete(() => this.updateHud('Complete')) + .start(); + this.updateHud('Moving'); + } + + private updateHud(state: string): void { + this.hud.setStatus(`${state} · ${this.roundTrip ? 'round trip' : 'one way'}`); + } + override draw(context: RenderingContext): void { + context.render(this.target); context.render(this.sprite); - context.render(this.text); + } + + override destroy(): void { + this.moveTween?.stop(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + super.destroy(); } } const app = new Application({ - scenes: { TweenBasicsScene }, + scenes: { InteractiveTweensScene }, canvas: { width: 1280, height: 720, @@ -44,9 +71,7 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TweenBasicsScene); +await app.start(InteractiveTweensScene); diff --git a/examples/tweens-animation/tween-chains.js b/examples/tweens-animation/tween-chains.js index 27047f4ad..41b0ff7b2 100644 --- a/examples/tweens-animation/tween-chains.js +++ b/examples/tweens-animation/tween-chains.js @@ -1,61 +1,62 @@ // Auto-generated from tween-chains.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; -class TweenChainsScene extends Scene { +import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Scene, Sprite } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; +const WAYPOINTS = [ + { x: 0.2, y: 0.3 }, + { x: 0.8, y: 0.3 }, + { x: 0.8, y: 0.72 }, + { x: 0.2, y: 0.72 }, +]; +class TweenSequencesScene extends Scene { sprite; + path; + steps = []; + hud; init() { - const app = this.app; - const { width, height } = app; - // 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(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(left, top); - const a = app.tweens - .create(this.sprite.position) - .to({ x: right, y: top }, 0.6) - .onComplete(() => { - this.sprite.setRotation(90); - }); - const b = app.tweens - .create(this.sprite.position) - .to({ x: right, y: bottom }, 0.6) - .onComplete(() => { - this.sprite.setRotation(180); - }); - const c = app.tweens - .create(this.sprite.position) - .to({ x: left, y: bottom }, 0.6) - .onComplete(() => { - this.sprite.setRotation(270); - }); - const d = app.tweens - .create(this.sprite.position) - .to({ x: left, y: top }, 0.6) - .onComplete(() => { - this.sprite.setRotation(0); - }); - a.chain(b); - b.chain(c); - c.chain(d); - d.onComplete(() => a.start()); - a.start(); + const points = WAYPOINTS.map(point => ({ x: point.x * this.app.width, y: point.y * this.app.height })); + this.path = new Graphics(); + this.path.lineWidth = 3; + this.path.lineColor = new Color(60, 100, 150); + for (let i = 0; i < points.length; i++) { + const next = points[(i + 1) % points.length]; + this.path.drawLine(points[i].x, points[i].y, next.x, next.y); + } + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(points[0].x, points[0].y); + this.hud = mountControls({ + title: 'Tween Sequences', + status: 'Leg 1 / 4', + hint: 'Four tweens are chained once, then the final completion restarts the sequence.', + }); + this.steps = points + .slice(1) + .concat(points[0]) + .map((point, index) => + this.app.tweens + .create(this.sprite.position) + .to(point, 0.75) + .easing(Ease.sineInOut) + .onStart(() => this.hud.setStatus(`Leg ${index + 1} / ${points.length}`)), + ); + for (let i = 0; i < this.steps.length - 1; i++) { + this.steps[i].chain(this.steps[i + 1]); + } + this.steps.at(-1).onComplete(() => this.steps[0].start()); + this.steps[0].start(); } draw(context) { + context.render(this.path); context.render(this.sprite); } + destroy() { + this.steps.forEach(tween => tween.stop()); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ - scenes: { TweenChainsScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { TweenSequencesScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TweenChainsScene); +await app.start(TweenSequencesScene); diff --git a/examples/tweens-animation/tween-chains.ts b/examples/tweens-animation/tween-chains.ts index 0f368cf8a..cc1982764 100644 --- a/examples/tweens-animation/tween-chains.ts +++ b/examples/tweens-animation/tween-chains.ts @@ -1,68 +1,70 @@ -import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sprite, Tween } from '@codexo/exojs'; +import { mountControls } from '@examples/runtime'; -class TweenChainsScene extends Scene { +const WAYPOINTS = [ + { x: 0.2, y: 0.3 }, + { x: 0.8, y: 0.3 }, + { x: 0.8, y: 0.72 }, + { x: 0.2, y: 0.72 }, +]; + +class TweenSequencesScene extends Scene { private sprite!: Sprite; + private path!: Graphics; + private steps: Tween[] = []; + private hud!: ReturnType; override init(): void { - const app = this.app; - const { width, height } = app; - // 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(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(left, top); + const points = WAYPOINTS.map(point => ({ x: point.x * this.app.width, y: point.y * this.app.height })); + this.path = new Graphics(); + this.path.lineWidth = 3; + this.path.lineColor = new Color(60, 100, 150); + for (let i = 0; i < points.length; i++) { + const next = points[(i + 1) % points.length]; + this.path.drawLine(points[i].x, points[i].y, next.x, next.y); + } - const a = app.tweens - .create(this.sprite.position) - .to({ x: right, y: top }, 0.6) - .onComplete(() => { - this.sprite.setRotation(90); - }); - const b = app.tweens - .create(this.sprite.position) - .to({ x: right, y: bottom }, 0.6) - .onComplete(() => { - this.sprite.setRotation(180); - }); - const c = app.tweens - .create(this.sprite.position) - .to({ x: left, y: bottom }, 0.6) - .onComplete(() => { - this.sprite.setRotation(270); - }); - const d = app.tweens - .create(this.sprite.position) - .to({ x: left, y: top }, 0.6) - .onComplete(() => { - this.sprite.setRotation(0); - }); + this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(points[0].x, points[0].y); + this.hud = mountControls({ + title: 'Tween Sequences', + status: 'Leg 1 / 4', + hint: 'Four tweens are chained once, then the final completion restarts the sequence.', + }); - a.chain(b); - b.chain(c); - c.chain(d); - d.onComplete(() => a.start()); - a.start(); + this.steps = points + .slice(1) + .concat(points[0]) + .map((point, index) => + this.app.tweens + .create(this.sprite.position) + .to(point, 0.75) + .easing(Ease.sineInOut) + .onStart(() => this.hud.setStatus(`Leg ${index + 1} / ${points.length}`)), + ); + for (let i = 0; i < this.steps.length - 1; i++) { + this.steps[i].chain(this.steps[i + 1]); + } + this.steps.at(-1)!.onComplete(() => this.steps[0].start()); + this.steps[0].start(); } override draw(context: RenderingContext): void { + context.render(this.path); context.render(this.sprite); } + + override destroy(): void { + this.steps.forEach(tween => tween.stop()); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ - scenes: { TweenChainsScene }, - canvas: { - width: 1280, - height: 720, - mount: document.body, - sizing: new FixedResolutionCanvasSizing(), - }, + scenes: { TweenSequencesScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, + loader: { basePath: 'assets/' }, }); -await app.start(TweenChainsScene); +await app.start(TweenSequencesScene); diff --git a/examples/ui/settings-menu.js b/examples/ui/settings-menu.js new file mode 100644 index 000000000..b71d8af75 --- /dev/null +++ b/examples/ui/settings-menu.js @@ -0,0 +1,127 @@ +// Auto-generated from settings-menu.ts - edit the .ts source, not this file. +import { + Application, + Button, + Color, + Dropdown, + FixedResolutionCanvasSizing, + Graphics, + Keyboard, + Label, + Panel, + Scene, + Slider, + Stack, + Toggle, +} from '@codexo/exojs'; +const STORAGE_KEY = 'exojs-playground-settings'; +const defaults = { volume: 0.7, motion: true, theme: 'blue' }; +const themeColors = { + blue: new Color(92, 170, 255), + amber: new Color(255, 180, 92), + violet: new Color(186, 126, 255), +}; +const loadSettings = () => { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); + return { + volume: typeof saved.volume === 'number' && Number.isFinite(saved.volume) ? Math.max(0, Math.min(1, saved.volume)) : defaults.volume, + motion: typeof saved.motion === 'boolean' ? saved.motion : defaults.motion, + theme: saved.theme === 'amber' || saved.theme === 'violet' ? saved.theme : defaults.theme, + }; + } catch { + return { ...defaults }; + } +}; +class SettingsMenuScene extends Scene { + settings = loadSettings(); + preview; + volume; + motion; + theme; + status; + angle = 0; + init() { + const { width, height } = this.app; + this.preview = new Graphics().setPosition(width * 0.79, height / 2); + this.addChild(this.preview); + const panel = new Panel({ width: 420, height: 460, color: new Color(28, 38, 56), cornerRadius: 20 }); + const stack = new Stack({ direction: 'column', spacing: 14, padding: 24 }); + stack.addItem(new Label('Settings', { fontSize: 34, fillColor: Color.white })); + stack.addItem(new Label('Master volume', { fontSize: 21, fillColor: Color.white })); + this.volume = new Slider({ width: 320, min: 0, max: 1, step: 0.05, value: this.settings.volume }); + stack.addItem(this.volume); + this.motion = new Toggle({ label: 'Animate preview', checked: this.settings.motion }); + stack.addItem(this.motion); + stack.addItem(new Label('Accent colour', { fontSize: 21, fillColor: Color.white })); + this.theme = new Dropdown({ + width: 320, + items: [ + { label: 'Blue', value: 'blue' }, + { label: 'Amber', value: 'amber' }, + { label: 'Violet', value: 'violet' }, + ], + selectedIndex: ['blue', 'amber', 'violet'].indexOf(this.settings.theme), + }); + stack.addItem(this.theme); + const reset = new Button({ label: 'Reset settings', width: 320, height: 48 }); + stack.addItem(reset); + this.status = new Label('Tab or D-pad to focus; arrows adjust controls.', { fontSize: 16, fillColor: new Color(180, 205, 230) }); + stack.addItem(this.status); + panel.addChild(stack); + panel.anchorIn(this.ui, 'center'); + this.ui.addChild(panel); + // Capture browser defaults so Tab stays in the canvas and Space activates the focused widget. + this.inputs.onTrigger(Keyboard.Tab, () => { + this.status.text = 'Tab moves focus between controls.'; + }); + this.inputs.onTrigger(Keyboard.Space, () => { + this.status.text = 'Space activates the focused control.'; + }); + this.volume.onChange.add(value => { + this.settings.volume = value; + this.apply(); + }); + this.motion.onChange.add(value => { + this.settings.motion = value; + this.apply(); + }); + this.theme.onChange.add(value => { + this.settings.theme = value; + this.apply(); + }); + reset.onClick.add(() => { + this.volume.value = defaults.volume; + this.motion.checked = defaults.motion; + this.theme.selectedIndex = 0; + this.settings = { ...defaults }; + this.apply(); + }); + this.apply(); + } + apply() { + this.app.audio.master.volume = this.settings.volume; + this.preview.clear(); + this.preview.fillColor = themeColors[this.settings.theme]; + this.preview.drawRectangle(-62, -62, 124, 124); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings)); + this.status.text = 'Saved in this browser. Tab or D-pad to focus.'; + } catch { + this.status.text = 'Storage unavailable. Settings work for this visit.'; + } + } + update(delta) { + if (this.settings.motion) this.angle += delta * 45; + this.preview.setRotation(this.angle); + } + draw(context) { + context.render(this.root); + } +} +const app = new Application({ + scenes: { SettingsMenuScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 20, 32), +}); +await app.start(SettingsMenuScene); diff --git a/examples/ui/settings-menu.ts b/examples/ui/settings-menu.ts new file mode 100644 index 000000000..ba8df1bb4 --- /dev/null +++ b/examples/ui/settings-menu.ts @@ -0,0 +1,147 @@ +import { + Application, + Button, + Color, + Dropdown, + FixedResolutionCanvasSizing, + Graphics, + Keyboard, + Label, + Panel, + type RenderingContext, + Scene, + type Seconds, + Slider, + Stack, + Toggle, +} from '@codexo/exojs'; + +type Theme = 'blue' | 'amber' | 'violet'; +interface Settings { + volume: number; + motion: boolean; + theme: Theme; +} + +const STORAGE_KEY = 'exojs-playground-settings'; +const defaults: Settings = { volume: 0.7, motion: true, theme: 'blue' }; +const themeColors: Record = { + blue: new Color(92, 170, 255), + amber: new Color(255, 180, 92), + violet: new Color(186, 126, 255), +}; + +const loadSettings = (): Settings => { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial; + return { + volume: typeof saved.volume === 'number' && Number.isFinite(saved.volume) ? Math.max(0, Math.min(1, saved.volume)) : defaults.volume, + motion: typeof saved.motion === 'boolean' ? saved.motion : defaults.motion, + theme: saved.theme === 'amber' || saved.theme === 'violet' ? saved.theme : defaults.theme, + }; + } catch { + return { ...defaults }; + } +}; + +class SettingsMenuScene extends Scene { + private settings = loadSettings(); + private preview!: Graphics; + private volume!: Slider; + private motion!: Toggle; + private theme!: Dropdown; + private status!: Label; + private angle = 0; + + override init(): void { + const { width, height } = this.app; + this.preview = new Graphics().setPosition(width * 0.79, height / 2); + this.addChild(this.preview); + + const panel = new Panel({ width: 420, height: 460, color: new Color(28, 38, 56), cornerRadius: 20 }); + const stack = new Stack({ direction: 'column', spacing: 14, padding: 24 }); + stack.addItem(new Label('Settings', { fontSize: 34, fillColor: Color.white })); + stack.addItem(new Label('Master volume', { fontSize: 21, fillColor: Color.white })); + this.volume = new Slider({ width: 320, min: 0, max: 1, step: 0.05, value: this.settings.volume }); + stack.addItem(this.volume); + this.motion = new Toggle({ label: 'Animate preview', checked: this.settings.motion }); + stack.addItem(this.motion); + stack.addItem(new Label('Accent colour', { fontSize: 21, fillColor: Color.white })); + this.theme = new Dropdown({ + width: 320, + items: [ + { label: 'Blue', value: 'blue' }, + { label: 'Amber', value: 'amber' }, + { label: 'Violet', value: 'violet' }, + ], + selectedIndex: ['blue', 'amber', 'violet'].indexOf(this.settings.theme), + }); + stack.addItem(this.theme); + const reset = new Button({ label: 'Reset settings', width: 320, height: 48 }); + stack.addItem(reset); + this.status = new Label('Tab or D-pad to focus; arrows adjust controls.', { fontSize: 16, fillColor: new Color(180, 205, 230) }); + stack.addItem(this.status); + panel.addChild(stack); + panel.anchorIn(this.ui, 'center'); + this.ui.addChild(panel); + + // Capture browser defaults so Tab stays in the canvas and Space activates the focused widget. + this.inputs.onTrigger(Keyboard.Tab, () => { + this.status.text = 'Tab moves focus between controls.'; + }); + this.inputs.onTrigger(Keyboard.Space, () => { + this.status.text = 'Space activates the focused control.'; + }); + + this.volume.onChange.add(value => { + this.settings.volume = value; + this.apply(); + }); + this.motion.onChange.add(value => { + this.settings.motion = value; + this.apply(); + }); + this.theme.onChange.add(value => { + this.settings.theme = value; + this.apply(); + }); + reset.onClick.add(() => { + this.volume.value = defaults.volume; + this.motion.checked = defaults.motion; + this.theme.selectedIndex = 0; + this.settings = { ...defaults }; + this.apply(); + }); + this.apply(); + } + + private apply(): void { + this.app.audio.master.volume = this.settings.volume; + this.preview.clear(); + this.preview.fillColor = themeColors[this.settings.theme]; + this.preview.drawRectangle(-62, -62, 124, 124); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings)); + this.status.text = 'Saved in this browser. Tab or D-pad to focus.'; + } catch { + this.status.text = 'Storage unavailable. Settings work for this visit.'; + } + } + + override update(delta: Seconds): void { + if (this.settings.motion) this.angle += delta * 45; + this.preview.setRotation(this.angle); + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { SettingsMenuScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 20, 32), +}); + +await app.start(SettingsMenuScene); diff --git a/examples/ui/text-entry-and-scrolling.js b/examples/ui/text-entry-and-scrolling.js new file mode 100644 index 000000000..0d5d7c533 --- /dev/null +++ b/examples/ui/text-entry-and-scrolling.js @@ -0,0 +1,114 @@ +// Auto-generated from text-entry-and-scrolling.ts - edit the .ts source, not this file. +import { + Application, + Color, + FixedResolutionCanvasSizing, + Keyboard, + Label, + Panel, + Scene, + ScrollContainer, + Stack, + Text, + TextArea, + TextInput, +} from '@codexo/exojs'; +class TextEntryScene extends Scene { + name; + note; + list; + status; + count = 0; + init() { + const { width } = this.app; + const form = new Panel({ width: 430, height: 480, color: new Color(30, 42, 62), cornerRadius: 18 }); + form.setPosition(64, 100); + const fields = new Stack({ direction: 'column', spacing: 16, padding: 24 }); + fields.addItem(new Label('Add a note', { fontSize: 32, fillColor: Color.white })); + fields.addItem(new Label('Name', { fontSize: 20, fillColor: Color.white })); + this.name = new TextInput({ width: 350, placeholder: 'Your name', maxLength: 24, enterKeyHint: 'next' }); + fields.addItem(this.name); + fields.addItem(new Label('Note', { fontSize: 20, fillColor: Color.white })); + this.note = new TextArea({ width: 350, height: 110, placeholder: 'Write a short note', maxLength: 80 }); + fields.addItem(this.note); + const add = new Panel({ width: 350, height: 48, color: new Color(54, 120, 220), cornerRadius: 8 }); + add.interactive = true; + add.focusable = true; + add.cursor = 'pointer'; + add.addChild(new Label('Add to list', { fontSize: 20, fillColor: Color.white }).setPosition(127, 10)); + fields.addItem(add); + this.status = new Label('Tab between fields; wheel to scroll the list.', { fontSize: 17, fillColor: new Color(180, 205, 230) }); + fields.addItem(this.status); + form.addChild(fields); + this.ui.addChild(form); + this.list = new ScrollContainer({ + width: 540, + height: 420, + background: new Color(25, 35, 52), + scrollbars: 'always', + }); + this.list.setPosition(width - 610, 142); + this.ui.addChild(this.list); + const title = new Label('Notes', { fontSize: 32, fillColor: Color.white }); + title.setPosition(width - 610, 100); + this.ui.addChild(title); + for (const [name, note] of [ + ['Mira', 'The first map is ready.'], + ['Ash', 'Try the keyboard controls.'], + ['Kai', 'The UI stays fixed above the scene.'], + ['Jules', 'Drag the scrollbar thumb.'], + ['Ari', 'The list grows as notes are added.'], + ['Nia', 'Click a field to edit it.'], + ]) + this.addNote(name, note); + this.name.onSubmit.add(() => this.note.focus()); + const submit = () => { + const name = this.name.value.trim(); + const note = this.note.value.trim(); + if (!name || !note) { + this.status.text = 'Enter a name and a note first.'; + return; + } + this.addNote(name, note); + this.name.value = ''; + this.note.value = ''; + this.status.text = 'Note added.'; + }; + add.onPointerTap.add(submit); + add.onKeyDown.add(event => { + if (event.channel === Number(Keyboard.Enter)) { + event.preventDefault(); + submit(); + } + }); + add.onFocus.add(() => { + add.setFill({ borderColor: Color.white, borderWidth: 3 }); + }); + add.onBlur.add(() => { + add.setFill({ borderWidth: 0 }); + }); + // Claim Tab so browser focus stays on the canvas while UI focus moves. + this.inputs.onTrigger(Keyboard.Tab, () => { + this.status.text = 'Tab moves between the fields and button.'; + }); + } + addNote(name, note) { + const text = new Text(`${name}: ${note.replace(/\s+/g, ' ')}`, { + fontSize: 20, + fillColor: this.count % 2 === 0 ? Color.white : new Color(180, 210, 255), + maxWidth: 480, + }); + text.setPosition(20, 24 + this.count * 76); + this.list.content.addChild(text); + this.count++; + } + draw(context) { + context.render(this.root); + } +} +const app = new Application({ + scenes: { TextEntryScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 20, 32), +}); +await app.start(TextEntryScene); diff --git a/examples/ui/text-entry-and-scrolling.ts b/examples/ui/text-entry-and-scrolling.ts new file mode 100644 index 000000000..97e3f7d47 --- /dev/null +++ b/examples/ui/text-entry-and-scrolling.ts @@ -0,0 +1,123 @@ +import { + Application, + Color, + FixedResolutionCanvasSizing, + Keyboard, + Label, + Panel, + type RenderingContext, + Scene, + ScrollContainer, + Stack, + Text, + TextArea, + TextInput, +} from '@codexo/exojs'; + +class TextEntryScene extends Scene { + private name!: TextInput; + private note!: TextArea; + private list!: ScrollContainer; + private status!: Label; + private count = 0; + + override init(): void { + const { width } = this.app; + const form = new Panel({ width: 430, height: 480, color: new Color(30, 42, 62), cornerRadius: 18 }); + form.setPosition(64, 100); + const fields = new Stack({ direction: 'column', spacing: 16, padding: 24 }); + fields.addItem(new Label('Add a note', { fontSize: 32, fillColor: Color.white })); + fields.addItem(new Label('Name', { fontSize: 20, fillColor: Color.white })); + this.name = new TextInput({ width: 350, placeholder: 'Your name', maxLength: 24, enterKeyHint: 'next' }); + fields.addItem(this.name); + fields.addItem(new Label('Note', { fontSize: 20, fillColor: Color.white })); + this.note = new TextArea({ width: 350, height: 110, placeholder: 'Write a short note', maxLength: 80 }); + fields.addItem(this.note); + const add = new Panel({ width: 350, height: 48, color: new Color(54, 120, 220), cornerRadius: 8 }); + add.interactive = true; + add.focusable = true; + add.cursor = 'pointer'; + add.addChild(new Label('Add to list', { fontSize: 20, fillColor: Color.white }).setPosition(127, 10)); + fields.addItem(add); + this.status = new Label('Tab between fields; wheel to scroll the list.', { fontSize: 17, fillColor: new Color(180, 205, 230) }); + fields.addItem(this.status); + form.addChild(fields); + this.ui.addChild(form); + + this.list = new ScrollContainer({ + width: 540, + height: 420, + background: new Color(25, 35, 52), + scrollbars: 'always', + }); + this.list.setPosition(width - 610, 142); + this.ui.addChild(this.list); + const title = new Label('Notes', { fontSize: 32, fillColor: Color.white }); + title.setPosition(width - 610, 100); + this.ui.addChild(title); + + for (const [name, note] of [ + ['Mira', 'The first map is ready.'], + ['Ash', 'Try the keyboard controls.'], + ['Kai', 'The UI stays fixed above the scene.'], + ['Jules', 'Drag the scrollbar thumb.'], + ['Ari', 'The list grows as notes are added.'], + ['Nia', 'Click a field to edit it.'], + ]) + this.addNote(name, note); + + this.name.onSubmit.add(() => this.note.focus()); + const submit = (): void => { + const name = this.name.value.trim(); + const note = this.note.value.trim(); + if (!name || !note) { + this.status.text = 'Enter a name and a note first.'; + return; + } + this.addNote(name, note); + this.name.value = ''; + this.note.value = ''; + this.status.text = 'Note added.'; + }; + add.onPointerTap.add(submit); + add.onKeyDown.add(event => { + if (event.channel === Number(Keyboard.Enter)) { + event.preventDefault(); + submit(); + } + }); + add.onFocus.add(() => { + add.setFill({ borderColor: Color.white, borderWidth: 3 }); + }); + add.onBlur.add(() => { + add.setFill({ borderWidth: 0 }); + }); + // Claim Tab so browser focus stays on the canvas while UI focus moves. + this.inputs.onTrigger(Keyboard.Tab, () => { + this.status.text = 'Tab moves between the fields and button.'; + }); + } + + private addNote(name: string, note: string): void { + const text = new Text(`${name}: ${note.replace(/\s+/g, ' ')}`, { + fontSize: 20, + fillColor: this.count % 2 === 0 ? Color.white : new Color(180, 210, 255), + maxWidth: 480, + }); + text.setPosition(20, 24 + this.count * 76); + this.list.content.addChild(text); + this.count++; + } + + override draw(context: RenderingContext): void { + context.render(this.root); + } +} + +const app = new Application({ + scenes: { TextEntryScene }, + canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(14, 20, 32), +}); + +await app.start(TextEntryScene); diff --git a/site/public/preview.html b/site/public/preview.html index 0e5d4e4ee..65029a253 100644 --- a/site/public/preview.html +++ b/site/public/preview.html @@ -165,7 +165,7 @@ // has no shell, so the catalog is resolved here against this // page's location before the example runs. var prelude = - "import { assets as rawAssets } from './assets/catalog.js';\n" + + "import { assets as rawAssets } from './assets/assets.js';\n" + "import { resolveAssetCatalog } from './assets/resolver.js';\n" + "window.assets = resolveAssetCatalog(rawAssets, new URL('assets/', window.location.href).href);\n"; diff --git a/site/src/components/BottomSheet.tsx b/site/src/components/BottomSheet.tsx index 1a6d924a1..61c384387 100644 --- a/site/src/components/BottomSheet.tsx +++ b/site/src/components/BottomSheet.tsx @@ -107,9 +107,7 @@ export const BottomSheet = ({ children, open, title, opener, onOpenChange }: Bot if (!mounted) return <>; - // Portaled to : the sticky header uses backdrop-filter, which makes - // it the containing block for fixed-position descendants - rendered in - // place, the sheet would be trapped inside the 62px header strip. + // Portaled to so sticky ancestors cannot clip the full-screen sheet. return createPortal(
diff --git a/site/src/components/ExampleBrowser.module.scss b/site/src/components/ExampleBrowser.module.scss index bc5911ebe..e607dd6a0 100644 --- a/site/src/components/ExampleBrowser.module.scss +++ b/site/src/components/ExampleBrowser.module.scss @@ -171,7 +171,7 @@ } .sheet-navigation { - min-height: min(64dvh, 560px); + height: min(64dvh, 560px); border-right: 0; } } diff --git a/site/src/components/ExoHeader.module.scss b/site/src/components/ExoHeader.module.scss index cb789a0ae..888a92230 100644 --- a/site/src/components/ExoHeader.module.scss +++ b/site/src/components/ExoHeader.module.scss @@ -4,8 +4,7 @@ top: 0; z-index: 50; border-bottom: 1px solid var(--color-border-soft); - background: color-mix(in oklab, var(--color-bg), transparent 18%); - backdrop-filter: blur(14px); + background: var(--color-bg); } .header { diff --git a/site/src/components/Navigation.tsx b/site/src/components/Navigation.tsx index 27b190c00..cafa36de2 100644 --- a/site/src/components/Navigation.tsx +++ b/site/src/components/Navigation.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; -import { buildPlaygroundNavModel, isExampleRouteActive, type PlaygroundNavCategory } from '../lib/playground-nav'; +import { buildPlaygroundNavModel, isExampleRouteActive } from '../lib/playground-nav'; import { getExampleAvailability } from '../lib/runtime-support'; import type { Example, ExamplesMap } from '../lib/types'; import { buildExampleHref } from '../lib/url-state'; @@ -20,42 +20,30 @@ export interface NavigationProps { } export const Navigation = ({ activeExample, examples, loaded, loadError, onSelectExample, selectedVersion }: NavigationProps): JSX.Element => { - const [overriddenCategories, setOverriddenCategories] = useState>(() => new Map()); - + const listRef = useRef(null); const allExamples = useMemo(() => Array.from(examples.values()).flat(), [examples]); const categories = useMemo(() => buildPlaygroundNavModel(allExamples), [allExamples]); - const isCategoryExpanded = (category: PlaygroundNavCategory): boolean => { - if (overriddenCategories.has(category.slug)) return overriddenCategories.get(category.slug) === true; - return category.examples.some(example => isExampleRouteActive(example.path, activeExample?.path)); - }; - - const toggleCategory = (category: PlaygroundNavCategory): void => { - setOverriddenCategories(current => { - const next = new Map(current); - const expanded = next.get(category.slug) ?? isCategoryExpanded(category); - next.set(category.slug, !expanded); - return next; - }); - }; + useEffect(() => { + const list = listRef.current; + const active = list?.querySelector('[aria-current="page"]'); + if (!list || !active) return; + const listBounds = list.getBoundingClientRect(); + const activeBounds = active.getBoundingClientRect(); + if (activeBounds.top < listBounds.top + 36) list.scrollTop -= listBounds.top + 36 - activeBounds.top; + else if (activeBounds.bottom > listBounds.bottom) list.scrollTop += activeBounds.bottom - listBounds.bottom; + }, [activeExample?.path, loaded]); return (
-