diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee797116d..a7ab093ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,9 @@ name: CI # runs locally. A lane is added there, never here. # # plan ─┬─► gates (matrix) -# ├─► test (matrix) ─► skip-budget -# └─► build ─┬─► verify (matrix) +# ├─► test (matrix) ──┬─► skip-budget +# └─► build ─┬────────┘ +# ├─► verify (matrix) # └─► site ─► smoke # all ─► verdict (the required check) # @@ -202,8 +203,8 @@ jobs: # One budget over every lane's report: a test that moves from one lane to # another must not change the count. skip-budget: - needs: [plan, test] - if: ${{ !cancelled() && needs.plan.outputs.skipBudget == 'true' && needs.test.result == 'success' }} + needs: [plan, test, build] + if: ${{ !cancelled() && needs.plan.outputs.skipBudget == 'true' && needs.test.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -225,6 +226,8 @@ jobs: steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup + with: + browser: chromium - name: Build core env: @@ -241,12 +244,32 @@ jobs: EXOJS_REQUIRE_PRODUCTION_BUILD: '1' run: pnpm test:production-stripping + - name: Verify the packed Core, its runtime subpaths and the legacy shims against the built dist + env: + EXOJS_REQUIRE_PRODUCTION_BUILD: '1' + run: pnpm test:release-dist --reporter=minimal --reporter=junit --outputFile.junit=./test-results/build.junit.xml + + - name: Keep the JUnit report for the skip budget + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-build + path: test-results/*.junit.xml + retention-days: 1 + if-no-files-found: ignore + + - name: Verify the built ESM tree through a browser import map + run: pnpm test:esm-importmap + - uses: actions/upload-artifact@v7 with: name: dist + # The demo-assets build copies its standard media into media/, which + # the package exports and packs; the verify jobs pack from this tree. path: | dist/ packages/*/dist/ + packages/exojs-demo-assets/media/ retention-days: 1 if-no-files-found: error @@ -272,21 +295,42 @@ jobs: if: ${{ !cancelled() && needs.plan.outputs.site == 'true' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 + outputs: + # A re-run of failed jobs keeps this job's earlier attempt, so the smoke + # and the publisher find the candidate by this name, not by recomputing it. + candidate: site-${{ steps.channel.outputs.channel }}-${{ github.sha }}-${{ github.run_attempt }} steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup with: dist: 'true' + # main builds Stable at /ExoJS/, next builds Next at /ExoJS/next/ (a pull + # request builds the channel of the branch it targets); any other ref + # builds Stable. The table lives in scripts/pages/site-channel.ts. + - id: channel + env: + SITE_REF: ${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }} + run: node scripts/pages/site-channel.ts >> "$GITHUB_OUTPUT" + # Typechecks the site against the published entry points, so it needs # the dist the build job produced. - run: pnpm gates site - env: EXOJS_PACKAGE_PATH: .. + EXOJS_SITE_CHANNEL: ${{ steps.channel.outputs.channel }} + EXOJS_SITE_BASE: ${{ steps.channel.outputs.base }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: pnpm site:build + # The tree must match its own site-build.json, carry this commit, channel + # and base, contain no links or special files and fit the site budget. + - name: Verify the site candidate + env: + SITE_CHANNEL: ${{ steps.channel.outputs.channel }} + run: node scripts/pages/channel-artifact.ts verify --dir site/dist --channel "$SITE_CHANNEL" --sha "$GITHUB_SHA" + - name: Upload tooling logs on failure if: ${{ failure() }} uses: actions/upload-artifact@v7 @@ -297,12 +341,14 @@ jobs: include-hidden-files: true if-no-files-found: warn - # Deploy Pages ships exactly this artifact; it builds nothing itself. + # Deploy Pages ships exactly this artifact; it builds nothing itself. A + # push to main or next is a publishable channel candidate and is kept for + # 90 days, so the publisher never depends on a short-lived download. - uses: actions/upload-artifact@v7 with: - name: site-dist-${{ github.sha }} + name: site-${{ steps.channel.outputs.channel }}-${{ github.sha }}-${{ github.run_attempt }} path: site/dist/ - retention-days: 1 + retention-days: ${{ github.event_name == 'push' && 90 || 1 }} if-no-files-found: error # Every catalog example through the real playground route, against the site @@ -321,13 +367,21 @@ jobs: - uses: ./.github/actions/setup with: browser: chromium + # The exact candidate tree, served at the base its site-build.json names. - uses: actions/download-artifact@v8 with: - name: site-dist-${{ github.sha }} + name: ${{ needs.site.outputs.candidate }} path: site/dist - run: pnpm test:examples:smoke --renderer webgl2 ${{ needs.plan.outputs.smokeSample == 'true' && '--sample' || '' }} + # The playground shell around the examples: console capture across the + # preview iframe and the Output panel's layouts, and the Asset Browser's + # previews, cancelled loads and generated snippets. Runs after a failed + # smoke too, so one run reports both. + - run: pnpm test:playground:e2e + if: ${{ !cancelled() }} + - name: Keep the smoke report if: ${{ !cancelled() }} uses: actions/upload-artifact@v7 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index d39beba64..797f76043 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,83 +1,125 @@ name: Deploy Pages -# Deploys the site EXACTLY as CI built and validated it — this workflow builds -# no source code. CI's `site` job (and a release's `prepare` job) runs the site -# gates and `site:build` and uploads `site/dist/**` as `site-dist-`; here -# that same artifact is downloaded from the run that triggered this one and -# handed to Pages unchanged. +# The one publisher of the project site. GitHub Pages serves a single tree, so +# every deployment carries both channels: Stable at /ExoJS/ and Next at +# /ExoJS/next/, each exactly as a green CI push to main or next (or a stable +# Release) built and smoked it. This workflow builds no site code. # -# The defect this prevents: the workflow used to check the sources out and -# rebuild the library and the site itself, so what went live was a second build -# that no gate had ever seen. A green CI run said nothing about the deployed -# bytes. +# The triggering run is only a wake-up call. On every start the publisher +# re-decides BOTH channels from GitHub's own metadata (scripts/pages/ +# select-channels.ts): each channel gets the newest qualified candidate on its +# branch's current history, or keeps what is already published. A queued or +# re-run event can therefore neither roll a channel back nor drop the other +# channel's update, and a green run without a site candidate changes nothing. # -# Fail-closed by construction: there is no `workflow_dispatch` path, because a -# manual run has no triggering run to read the artifact from. If the artifact is -# missing or belongs to a different commit, the download step fails and nothing -# is deployed. To re-deploy, re-run the CI workflow for the commit. +# Trust: only successful same-repository push runs of CI (main/next) and +# stable Release tags qualify; a pull request, a fork or a manual dispatch +# never does. Candidates are downloaded by the trusted scripts of the +# default-branch checkout below, never executed, and checked against the +# digest GitHub reports, their own site-build.json and the archive rules +# before anything is composed. Any doubt deploys nothing and leaves the live +# site as it is. +# +# A deployment also keeps its composed tree (the Pages artifact) and its +# channels.json for 90 days, so a channel whose candidate expired can be +# carried over unchanged from the last published snapshot. on: workflow_run: workflows: ['Release', 'CI'] types: [completed] -permissions: - contents: read - # Reading an artifact from ANOTHER workflow run needs `actions: read` plus an - # explicit token on the download step. - actions: read - pages: write - id-token: write +permissions: {} +# One queue for the whole site, and a running deployment is never cancelled. +# Ordering does not rest on this: each run re-decides both channels when it +# starts. concurrency: - group: pages-${{ github.event.workflow_run.head_sha }} - cancel-in-progress: true + group: pages-combined + cancel-in-progress: false jobs: - deploy: - name: Deploy to GitHub Pages + compose: + name: Compose Stable and Next if: >- ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && - ( - github.event.workflow_run.name == 'Release' || - ( - github.event.workflow_run.name == 'CI' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - ) - ) + github.event.workflow_run.event == 'push' && + github.event.workflow_run.repository.full_name == github.repository && + github.event.workflow_run.head_repository.full_name == github.repository }} runs-on: ubuntu-latest - timeout-minutes: 10 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + timeout-minutes: 30 + permissions: + contents: read + # Listing runs and reading artifacts of other workflow runs. + actions: read + outputs: + deploy: ${{ steps.select.outputs.deploy }} steps: - # `run-id` pins the download to the exact run that triggered this one, and - # the commit in the artifact name pins it to that run's commit — an older - # or unrelated build cannot be picked up. A missing artifact fails here. - - name: Download validated site artifact - uses: actions/download-artifact@v8 + # The publisher's own scripts, from the default branch - never from a + # candidate. Dependency-free TypeScript that node strips: no install. + - uses: actions/checkout@v6 with: - name: site-dist-${{ github.event.workflow_run.head_sha }} - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - path: site-dist + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout-cone-mode: false + sparse-checkout: | + /.nvmrc + /scripts/pages/ + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Select both channels + id: select + env: + GITHUB_TOKEN: ${{ github.token }} + TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + run: node scripts/pages/select-channels.ts --out "$RUNNER_TEMP/selection.json" - - name: Verify the artifact carries a built site - run: | - test -f site-dist/index.html || { - echo "::error::site-dist-${{ github.event.workflow_run.head_sha }} has no index.html — refusing to deploy." - exit 1 - } + - name: Fetch, verify and compose the channels + if: steps.select.outputs.deploy == 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node scripts/pages/compose-site.ts + --selection "$RUNNER_TEMP/selection.json" + --work "$RUNNER_TEMP/channels" + --out "$RUNNER_TEMP/site" + --channels-out "$RUNNER_TEMP/channels-record" - name: Package for Pages + if: steps.select.outputs.deploy == 'true' uses: actions/upload-pages-artifact@v5 with: - path: site-dist + path: ${{ runner.temp }}/site + retention-days: 90 + - name: Keep the deployed pairing + if: steps.select.outputs.deploy == 'true' + uses: actions/upload-artifact@v7 + with: + name: pages-channels-${{ github.run_id }} + path: ${{ runner.temp }}/channels-record/channels.json + retention-days: 90 + if-no-files-found: error + + deploy: + name: Deploy to GitHub Pages + needs: compose + if: needs.compose.outputs.deploy == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: - name: Deploy id: deployment uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a17d317c..0f457b373 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,33 +81,36 @@ jobs: pnpm verify:lockstep pnpm verify:release-matrix - - name: Build core, extensions and the site + - name: Build core (with its runtime modules), adapters and the site env: EXOJS_PACKAGE_PATH: .. + # A release tag sits on main: its site is the Stable channel. + EXOJS_SITE_CHANNEL: stable # One line per package: verify:release-matrix checks each against the # lockstep list, so a package added without a build line fails there. run: | pnpm build - pnpm --filter @codexo/exojs-particles build - pnpm --filter @codexo/exojs-tilemap build pnpm --filter @codexo/exojs-tiled build - pnpm --filter @codexo/exojs-physics build - pnpm --filter @codexo/exojs-tilemap-physics build - pnpm --filter @codexo/exojs-lighting build - pnpm --filter @codexo/exojs-pathfinding build - pnpm --filter @codexo/exojs-audio-fx build pnpm --filter @codexo/exojs-aseprite build pnpm --filter @codexo/exojs-ldtk build pnpm --filter @codexo/exojs-react build pnpm --filter create-exo-app build + # Not a lockstep package, but the site serves its catalog and media. + pnpm --filter @codexo/exojs-demo-assets build pnpm site:build - # Deploy Pages ships the site of a successful release from this artifact. + - name: Verify the Stable site candidate + env: + RELEASE_SHA: ${{ needs.trust.outputs.sha }} + run: node scripts/pages/channel-artifact.ts verify --dir site/dist --channel stable --sha "$RELEASE_SHA" + + # Deploy Pages may ship the Stable site of a successful release from this + # artifact, and falls back to it when the CI candidate has expired. - uses: actions/upload-artifact@v7 with: - name: site-dist-${{ needs.trust.outputs.sha }} + name: site-stable-${{ needs.trust.outputs.sha }}-${{ github.run_attempt }} path: site/dist/ - retention-days: 1 + retention-days: 90 if-no-files-found: error # The site build regenerates tracked content with platform-dependent diff --git a/.gitignore b/.gitignore index a7ab7f3cf..67d29d11a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules dist/* packages/*/dist +# Assembled from examples/assets by the demo-assets build and archive scripts. +packages/exojs-demo-assets/media/ +packages/exojs-demo-assets/archives/ coverage __screenshots__ npm-debug.log* diff --git a/.prettierignore b/.prettierignore index 63ab37119..4722a68c0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,9 +6,15 @@ coverage examples/assets/** # Generated Monaco type shims; their exact shape is pinned by a drift test. -examples/shared/assets-global.d.ts examples/shared/runtime.d.ts +# Generated demo catalog sources and manifest; scripts/generate.ts owns their +# exact shape and a drift test pins it. +packages/exojs-demo-assets/src/catalog.generated.ts +packages/exojs-demo-assets/src/standard.generated.ts +packages/exojs-demo-assets/manifest.json +packages/exojs-demo-assets/THIRD_PARTY_NOTICES.md + # Astro's generated cache, and the vendored bundles plus copied example catalog # the site serves - none of it authored here. site/.astro/** @@ -17,6 +23,9 @@ site/public/** # Generated API reference; the generator owns its shape and a sync gate pins it. site/src/content/api/** +# Generated legacy shim packages; `legacy-shims:check` pins them to the generator. +packages/legacy/** + # The changelog is a historical record with its own gate (`lint:changelog`); its # embedded code samples keep the width they were written at. CHANGELOG.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1189c835..355993fb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,9 @@ import type { Extension } from '@codexo/exojs/extensions'; Forbidden: `../x`, `@/x`, bare `core/x`, `@codexo/exojs/src/x`. ESLint enforces this. -How it resolves: each package maps `#*` in its `package.json#imports` to a package-specific source condition (`./src/*.ts`) and to `./dist/esm/*` for consumers. tsc uses `customConditions`, Vitest uses `resolve`/`ssr.resolve.conditions`, and Rollup uses node-resolve `exportConditions` — all pointing `#` at source in-repo. The shipped `.d.ts` keep `#` verbatim and resolve through the package's own imports map, so there is no declaration alias-rewrite step. +How it resolves: each package maps `#*` in its `package.json#imports` to a package-specific source condition (`./src/*.ts`) and, where the package ships its own tree, to `./dist/esm/*` for consumers. tsc uses `customConditions`, Vitest uses `resolve`/`ssr.resolve.conditions`, and Rolldown uses `resolve.conditionNames` — all pointing `#` at source in-repo. The public `@codexo/exojs` specifiers, including the runtime-module subpaths, map to their sources through `@codexo/exojs-config/typescript/workspace-paths.json` for tsc and through the Vitest aliases. + +Core's shipped `.d.ts` keep their own `#` imports verbatim and resolve through the Core imports map. The runtime modules are different: their sources live in `packages/exojs-/` but ship inside the Core tarball under `dist/esm//`, so the build rewrites every specifier that leaves a module (`@codexo/exojs`, a Core subpath, a sibling module, the module's own `#` imports) to a relative path, in the emitted JavaScript and in the declarations alike. The declaration rewrite is AST-based (`scripts/lib/module-specifiers.ts`) and resolves each specifier the way an installed consumer would, through the Core `exports` map. ## File layout: one file, one primary symbol @@ -106,8 +108,8 @@ An error class never hides inside the file of the class that throws it. A caller After one root `pnpm install`, every package builds, tests and packs on its own: ```bash -pnpm --filter @codexo/exojs build # or -particles / -tiled -pnpm --filter @codexo/exojs-particles test +pnpm --filter @codexo/exojs build # Core, including its runtime modules +pnpm --filter @codexo/exojs-module-particles test pnpm --filter @codexo/exojs-tiled pack # …or from the package directory: pnpm build && pnpm test && pnpm pack @@ -140,13 +142,15 @@ Prettier owns the mechanical layout: indentation, quotes, semicolons, commas, wr ## Examples and assets -Example authors use the injected, typed `assets` global directly — no import: +Examples load their media through the optional content package `@codexo/exojs-demo-assets`, imported like any other dependency: ```ts -assets.demo.textures.bunny; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; + +const ship = this.loader.get(assets.ship); ``` -The global is installed only inside the controlled example runtimes (Playground, Example/Guide preview, Asset Browser, smoke harness, Full Release harness). It is not part of the engine public API. The canonical catalog is `examples/assets/assets.ts`; `pnpm --filter @codexo/exojs-examples examples:sync` regenerates the `.js` sources and the runtime catalog. +Each property is a passive descriptor; reading it fetches nothing, and the scene's loader claims and loads it. There is no injected global, so an example copied out of the Playground runs anywhere the package is installed. The media under `examples/assets/` are the authoring source of that package: `packages/exojs-demo-assets/catalog.json` lists what ships, and its generator derives the typed catalog, the manifest, the notices and the packed `media/` tree. The site serves the package beside the engine (`vendor/exojs-demo-assets/`), not the `examples/assets/` tree. `pnpm --filter @codexo/exojs-examples examples:sync` regenerates the `.js` sources. JSON asset data under `examples/assets/` is committed compact, including Tiled maps/tilesets/templates (`.tmj`, `.tsj`, `.tj`, `.world`) and LDtk documents (`.ldtk`, `.ldtkl`). Run `pnpm assets:compact` after exporting or editing these files. `pnpm assets:compact:check` enforces this in the sync gates. Prettier ignores the asset directory; do not pretty-print large tile arrays. The example catalog `examples/examples.json` remains ordinary repository metadata. @@ -158,7 +162,7 @@ Treat the `.js` as build output that happens to be versioned. It is not linted a One file is not quite that: `examples/tilemap/worker-streamed-terrain.js` is the only example whose generation is not a plain transpile. Its `?worker` import is inlined at generation time, so the emitted file carries code its source does not, and it stays in `tsconfig.examples.json` for that reason. -`examples/shared/` holds one more generated artifact of a different kind. `runtime.d.ts` is emitted from `runtime.ts` during the same sync, because the Playground editor resolves `@examples/runtime` through a declaration file it fetches from the served snapshot rather than through the implementation. Like `assets-global.d.ts` it is Prettier-ignored and pinned by a drift test rather than by `examples:sync:check`. +`examples/shared/` holds one more generated artifact of a different kind. `runtime.d.ts` is emitted from `runtime.ts` during the same sync, because the Playground editor resolves `@examples/runtime` through a declaration file it fetches from the served snapshot rather than through the implementation. It is Prettier-ignored and pinned by a drift test rather than by `examples:sync:check`. ### Guide sources @@ -305,7 +309,8 @@ This policy governs new code and the sites a change already touches. The engine ## Distribution -- npm packages are **modular and self-contained**: `@codexo/exojs` ships Core only; `@codexo/exojs-particles` and `@codexo/exojs-tiled` ship only their own product. There is no Core `/full` export and no aggregator package. +- npm packages are **modular and self-contained**: `@codexo/exojs` ships Core and, as opt-in subpaths, the runtime modules (`@codexo/exojs/tilemap`, `/particles`, `/audio-fx`, `/physics`, `/tilemap-physics`, `/lighting`, `/pathfinding`); the root entry never imports them. Adapters such as `@codexo/exojs-tiled` ship only their own product. There is no Core `/full` export and no aggregator package. +- A runtime module's sources live in `packages/exojs-/` as a private workspace package (`@codexo/exojs-module-`) and are built only by the Core build, which emits them under `dist/esm//`. Every import a module makes of Core or of a sibling module lands on the same file the Core `exports` map names, so a consumer always has exactly one Core instance. The release roles live in `scripts/release/lockstep-packages.ts`. - Packages ship `.js` + `.d.ts` + source maps — **never raw `.ts` runtime entries**. The `exports` map is the public interface; the bundler profile is the supported type-resolution target (browser/bundler-first). - Extensions are side-effect-free at the root; pass their descriptor to `ApplicationOptions.extensions` to equip an app. There is no global registry. - The synchronized offline snapshot (npm tarballs + versioned ESM vendor trees + examples + built site) is the GitHub Full Release archive. diff --git a/README.md b/README.md index 9a3919bf4..cfb0e307d 100644 --- a/README.md +++ b/README.md @@ -120,16 +120,9 @@ For a bundler-based application: npm install --save-exact @codexo/exojs ``` -The package provides ESM and TypeScript declarations. TypeScript projects resolve the declarations with `"moduleResolution": "bundler"`; `node16` and `nodenext` resolution are not supported. The package also includes script-tag bundles: +The package provides ESM and TypeScript declarations. TypeScript projects resolve the declarations with `"moduleResolution": "bundler"` or `node16`/`nodenext`; no consumer `paths` mapping or `skipLibCheck` is needed. Since v0.20 the distribution is ESM-only; the prebuilt IIFE script-tag bundles of earlier lines (`exo.iife.js`, `exo.full.iife.js`) are gone. A script-tag consumer uses a module script with an import map against `dist/esm`; see the [v0.20 migration guide](https://exoridus.github.io/ExoJS/en/guide/getting-started/migration-v020/). -| Bundle | Contents | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | -| `dist/exo.iife.js` | Core runtime on the `Exo` global | -| `dist/exo.full.iife.js` | Core plus the official runtime extensions except React and tilemap physics, on the same `Exo` global | - -Minified variants are provided alongside them. Use one Core instance per application; do not load both bundles into the same page. - -The [Full Release ZIP](https://github.com/Exoridus/ExoJS/releases/latest/download/exojs-full.zip) includes a built runtime, examples, and documentation. See [Deployment](https://exoridus.github.io/ExoJS/en/guide/shipping/deployment/) for static hosting and script-tag setup. +The [Full Release ZIP](https://github.com/Exoridus/ExoJS/releases/latest/download/exojs-full.zip) includes a built runtime, examples, and documentation. See [Deployment](https://exoridus.github.io/ExoJS/en/guide/shipping/deployment/) for static hosting and module-script setup. ## Performance evidence diff --git a/eslint.config.ts b/eslint.config.ts index be7fc805a..c43f2b0b2 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -58,6 +58,9 @@ export default defineConfig([ 'coverage/**', '**/*.min.*', 'examples/**/*.js', + // Generated forwarder packages, pinned to their generator by + // `legacy-shims:check`; no TypeScript program owns them. + 'packages/legacy/**', // Transient fixture directories a suite writes into a scanned tree and // removes again. They exist only while that suite runs, so a lint pass // that overlaps it would otherwise report files nobody authored. @@ -1074,6 +1077,11 @@ export default defineConfig([ 'scripts/**/*.mjs', 'packages/exojs-bench/competitors/*.ts', 'packages/create-exo-app/bin/*.js', + // The demo-assets generators and archive/copy tooling are Node scripts + // that belong to no package TypeScript program (the package tsconfigs + // cover src/** only), so typed rules have nothing to serve for them. + 'packages/exojs-demo-assets/scripts/*.ts', + 'packages/exojs-demo-assets/bin/*.js', ], }), @@ -1226,8 +1234,6 @@ export default defineConfig([ globals: { ...globals.browser, ...globals.es2024, - // Injected typed asset catalog (see examples/shared/assets-global.d.ts). - assets: 'readonly', }, }, rules: { @@ -1243,7 +1249,9 @@ export default defineConfig([ }, ], eqeqeq: ['error', 'always', { null: 'ignore' }], - 'no-console': 'error', + // The playground shows a preview's console in its Output panel, so a + // log line is one of the ways an example explains what just happened. + 'no-console': 'off', // The sync-init() rule (see the engine-source block) is deliberately NOT // enabled here yet: most of the catalog still uses the pre-v0.17 // async `init(loader)` hook and is migrated in the dedicated examples @@ -1289,8 +1297,6 @@ export default defineConfig([ globals: { ...globals.browser, ...globals.es2024, - // Injected typed asset catalog (see examples/shared/assets-global.d.ts). - assets: 'readonly', }, }, rules: { diff --git a/examples/application-scenes/cached-assets-offline.js b/examples/application-scenes/cached-assets-offline.js index a3bbf1b53..cc70e903e 100644 --- a/examples/application-scenes/cached-assets-offline.js +++ b/examples/application-scenes/cached-assets-offline.js @@ -1,7 +1,6 @@ // Auto-generated from cached-assets-offline.ts - edit the .ts source, not this file. import { Application, - Asset, AssetCache, AssetCacheMissError, Color, @@ -12,20 +11,19 @@ import { Scene, Text, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, toggle } 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); +const warmAsset = assets.genericInputPromptsAtlas; +const coldAsset = assets.keyboardMouseInputPromptsAtlas; 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; + result = new Text('Warm the cache, then request sources online and offline.', { fillColor: Color.white, fontSize: 25 }); panel; busy = false; init() { @@ -35,48 +33,29 @@ class CachedAssetsScene extends Scene { 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.warmState.position.set(215, 300); + this.result.position.set(215, 385); + this.panel = demoPanel({ + title: 'Cache actions', + controls: { offline: toggle('Offline policy', false) }, + actions: { + 'Warm generic prompts': () => { + void this.run('Warm', () => this.app.loader.cacheSource(warmAsset)); + }, + 'Request warmed source': () => { + void this.request(warmAsset, 'Generic prompts'); + }, + 'Request unwarmed source': () => { + void this.request(coldAsset, 'Keyboard prompts'); + }, + 'Clear demo cache': () => { + void this.run('Clear', () => cache.clear()); + }, }, - }); - this.panel.addButton({ - label: 'Clear demo cache', - onClick: () => { - void this.run('Clear', () => cache.clear()); + onChange: (key, value) => { + if (key === 'offline') { + this.app.connectivity.mode = value ? 'offline' : 'online'; + } }, }); } @@ -94,7 +73,6 @@ class CachedAssetsScene extends Scene { } catch (error) { this.result.text = `${action} failed: ${error instanceof Error ? error.message : String(error)}`; } finally { - this.hud.setStatus(this.result.text); this.busy = false; } } @@ -115,24 +93,20 @@ class CachedAssetsScene extends Scene { : `${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(); @@ -143,6 +117,6 @@ 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 }, + loader: { cache }, }); await app.start(CachedAssetsScene); diff --git a/examples/application-scenes/cached-assets-offline.ts b/examples/application-scenes/cached-assets-offline.ts index f31d1d707..adcfeebe8 100644 --- a/examples/application-scenes/cached-assets-offline.ts +++ b/examples/application-scenes/cached-assets-offline.ts @@ -1,6 +1,5 @@ import { Application, - Asset, AssetCache, AssetCacheMissError, Color, @@ -12,24 +11,23 @@ import { Scene, Text, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, toggle } 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); +const warmAsset = assets.genericInputPromptsAtlas; +const coldAsset = assets.keyboardMouseInputPromptsAtlas; 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 readonly result = new Text('Warm the cache, then request sources online and offline.', { fillColor: Color.white, fontSize: 25 }); + private panel?: { dispose(): void }; private busy = false; override init(): void { @@ -39,49 +37,30 @@ class CachedAssetsScene extends Scene { 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.warmState.position.set(215, 300); + this.result.position.set(215, 385); - 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 = demoPanel({ + title: 'Cache actions', + controls: { offline: toggle('Offline policy', false) }, + actions: { + 'Warm generic prompts': () => { + void this.run('Warm', () => this.app.loader.cacheSource(warmAsset)); + }, + 'Request warmed source': () => { + void this.request(warmAsset, 'Generic prompts'); + }, + 'Request unwarmed source': () => { + void this.request(coldAsset, 'Keyboard prompts'); + }, + 'Clear demo cache': () => { + void this.run('Clear', () => cache.clear()); + }, }, - }); - this.panel.addButton({ - label: 'Clear demo cache', - onClick: () => { - void this.run('Clear', () => cache.clear()); + onChange: (key, value) => { + if (key === 'offline') { + this.app.connectivity.mode = value ? 'offline' : 'online'; + } }, }); } @@ -102,7 +81,6 @@ class CachedAssetsScene extends Scene { } catch (error) { this.result.text = `${action} failed: ${error instanceof Error ? error.message : String(error)}`; } finally { - this.hud.setStatus(this.result.text); this.busy = false; } } @@ -126,7 +104,6 @@ class CachedAssetsScene extends Scene { : `${name}: ${error instanceof Error ? error.message : String(error)}`; } finally { scope.destroy(); - this.hud.setStatus(this.result.text); this.busy = false; } } @@ -134,18 +111,15 @@ class CachedAssetsScene extends Scene { 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(); @@ -157,7 +131,7 @@ 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 }, + loader: { cache }, }); await app.start(CachedAssetsScene); diff --git a/examples/application-scenes/camera-and-view.js b/examples/application-scenes/camera-and-view.js index 8418d650c..01502f4b9 100644 --- a/examples/application-scenes/camera-and-view.js +++ b/examples/application-scenes/camera-and-view.js @@ -89,8 +89,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CameraViewScene); diff --git a/examples/application-scenes/camera-and-view.ts b/examples/application-scenes/camera-and-view.ts index 0770d4f47..401fb5911 100644 --- a/examples/application-scenes/camera-and-view.ts +++ b/examples/application-scenes/camera-and-view.ts @@ -110,9 +110,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CameraViewScene); diff --git a/examples/application-scenes/camera-basic.js b/examples/application-scenes/camera-basic.js index 2281216e7..a9f5fa906 100644 --- a/examples/application-scenes/camera-basic.js +++ b/examples/application-scenes/camera-basic.js @@ -1,5 +1,6 @@ // Auto-generated from camera-basic.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class CameraBasicScene extends Scene { bunny; grid; @@ -8,7 +9,7 @@ class CameraBasicScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.bunny = new Sprite(this.loader.get('image/ship-a.png')); + this.bunny = new Sprite(this.loader.get(assets.ship)); this.bunny.setAnchor(0.5).setPosition(width / 2, height / 2); this.grid = new Graphics(); this.grid.lineWidth = 1; @@ -57,8 +58,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CameraBasicScene); diff --git a/examples/application-scenes/camera-basic.ts b/examples/application-scenes/camera-basic.ts index 119e36651..e2384a547 100644 --- a/examples/application-scenes/camera-basic.ts +++ b/examples/application-scenes/camera-basic.ts @@ -8,6 +8,7 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class CameraBasicScene extends Scene { private bunny!: Sprite; @@ -19,7 +20,7 @@ class CameraBasicScene extends Scene { const app = this.app; const { width, height } = app; - this.bunny = new Sprite(this.loader.get('image/ship-a.png')); + this.bunny = new Sprite(this.loader.get(assets.ship)); this.bunny.setAnchor(0.5).setPosition(width / 2, height / 2); this.grid = new Graphics(); @@ -79,9 +80,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CameraBasicScene); diff --git a/examples/application-scenes/custom-transition.js b/examples/application-scenes/custom-transition.js index 3a83f28fc..ea10e28c5 100644 --- a/examples/application-scenes/custom-transition.js +++ b/examples/application-scenes/custom-transition.js @@ -147,8 +147,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MenuScene, { transition: wipe }); diff --git a/examples/application-scenes/custom-transition.ts b/examples/application-scenes/custom-transition.ts index 1554afb46..ab07bf93b 100644 --- a/examples/application-scenes/custom-transition.ts +++ b/examples/application-scenes/custom-transition.ts @@ -202,9 +202,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MenuScene, { transition: wipe }); diff --git a/examples/application-scenes/loading-screen.js b/examples/application-scenes/loading-screen.js index 6a8d30dbb..54318ce42 100644 --- a/examples/application-scenes/loading-screen.js +++ b/examples/application-scenes/loading-screen.js @@ -1,11 +1,11 @@ // Auto-generated from loading-screen.ts - edit the .ts source, not this file. -import { Application, Asset, Assets, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { Application, Assets, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const GameAssets = Assets.from({ - ship: 'image/ship-a.png', - grid: 'image/uv-grid-256.png', - ramp: 'image/hue-ramp.png', - click: Asset.type('sound', 'audio/ui-click.ogg'), + ship: assets.ship, + grid: assets.uvGrid256, + ramp: assets.hueRamp, + click: assets.soundUiClick, }); /** * One progress bar for everything the loader is doing, then a hand-over to the @@ -133,7 +133,6 @@ class BootScene extends Scene { class PlayScene extends Scene { ship; label; - hud; init() { const app = this.app; const { width, height } = app; @@ -146,20 +145,11 @@ class PlayScene extends Scene { this.inputs.onTrigger(Keyboard.Space, () => { void app.scenes.change(BootScene); }); - this.hud = mountControls({ - title: 'Loading Progress and Retry', - controls: [{ keys: 'Space', action: 'return to the boot scene' }], - hint: 'The boot scene follows real loader signals, retries failures, and enters the game after a successful batch.', - }); } draw(context) { context.render(this.ship); context.render(this.label); } - destroy() { - this.hud.dispose(); - super.destroy(); - } } const app = new Application({ scenes: { BootScene, PlayScene }, @@ -170,8 +160,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BootScene); diff --git a/examples/application-scenes/loading-screen.ts b/examples/application-scenes/loading-screen.ts index ec080fad2..c87c7e601 100644 --- a/examples/application-scenes/loading-screen.ts +++ b/examples/application-scenes/loading-screen.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Assets, Color, FixedResolutionCanvasSizing, @@ -11,13 +10,13 @@ import { Sprite, Text, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const GameAssets = Assets.from({ - ship: 'image/ship-a.png', - grid: 'image/uv-grid-256.png', - ramp: 'image/hue-ramp.png', - click: Asset.type('sound', 'audio/ui-click.ogg'), + ship: assets.ship, + grid: assets.uvGrid256, + ramp: assets.hueRamp, + click: assets.soundUiClick, }); /** @@ -170,7 +169,6 @@ class BootScene extends Scene { class PlayScene extends Scene { private ship!: Sprite; private label!: Text; - private hud!: ReturnType; override init(): void { const app = this.app; @@ -188,23 +186,12 @@ class PlayScene extends Scene { this.inputs.onTrigger(Keyboard.Space, () => { void app.scenes.change(BootScene); }); - - this.hud = mountControls({ - title: 'Loading Progress and Retry', - controls: [{ keys: 'Space', action: 'return to the boot scene' }], - hint: 'The boot scene follows real loader signals, retries failures, and enters the game after a successful batch.', - }); } override draw(context: RenderingContext): void { context.render(this.ship); context.render(this.label); } - - override destroy(): void { - this.hud.dispose(); - super.destroy(); - } } const app = new Application({ @@ -216,9 +203,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BootScene); diff --git a/examples/application-scenes/multi-view-split-screen.js b/examples/application-scenes/multi-view-split-screen.js index 6992b67e2..ae20c636d 100644 --- a/examples/application-scenes/multi-view-split-screen.js +++ b/examples/application-scenes/multi-view-split-screen.js @@ -1,6 +1,19 @@ // Auto-generated from multi-view-split-screen.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, Graphics, Keyboard, Scene, Sprite, Text, View } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { + ActionMap, + Application, + Color, + FixedResolutionCanvasSizing, + GamepadAxis, + Graphics, + Keyboard, + Scene, + Sprite, + Text, + VectorAction, + View, +} from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SplitScreenScene extends Scene { texture; leftView; @@ -10,24 +23,12 @@ class SplitScreenScene extends Scene { labels; leftPlayer; rightPlayer; - move = { - a: 0, - d: 0, - w: 0, - s: 0, - left: 0, - right: 0, - up: 0, - down: 0, - }; - padMove = [ - { x: 0, y: 0 }, - { x: 0, y: 0 }, - ]; + leftControls; + rightControls; init() { const app = this.app; const { width, height } = app; - this.texture = this.loader.get('image/ship-a.png'); + this.texture = this.loader.get(assets.ship); this.leftView = new View(0, 0, width / 2, height).setViewport(0, 0, 0.5, 1); this.rightView = new View(0, 0, width / 2, height).setViewport(0.5, 0, 0.5, 1); this.divider = new Graphics(); @@ -46,10 +47,6 @@ class SplitScreenScene extends Scene { 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) @@ -58,78 +55,34 @@ class SplitScreenScene extends Scene { .setAnchor(0.5) .setPosition(160, 0) .setTint(new Color(255, 180, 120)); - this.inputs.onActive(Keyboard.A, () => { - this.move.a = 1; - }); - this.inputs.onStop(Keyboard.A, () => { - this.move.a = 0; - }); - this.inputs.onActive(Keyboard.D, () => { - this.move.d = 1; - }); - this.inputs.onStop(Keyboard.D, () => { - this.move.d = 0; - }); - this.inputs.onActive(Keyboard.W, () => { - this.move.w = 1; - }); - this.inputs.onStop(Keyboard.W, () => { - this.move.w = 0; - }); - this.inputs.onActive(Keyboard.S, () => { - this.move.s = 1; - }); - this.inputs.onStop(Keyboard.S, () => { - this.move.s = 0; - }); - this.inputs.onActive(Keyboard.Left, () => { - this.move.left = 1; - }); - this.inputs.onStop(Keyboard.Left, () => { - this.move.left = 0; - }); - this.inputs.onActive(Keyboard.Right, () => { - this.move.right = 1; - }); - this.inputs.onStop(Keyboard.Right, () => { - this.move.right = 0; - }); - this.inputs.onActive(Keyboard.Up, () => { - this.move.up = 1; - }); - this.inputs.onStop(Keyboard.Up, () => { - this.move.up = 0; - }); - this.inputs.onActive(Keyboard.Down, () => { - this.move.down = 1; - }); - 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; - }); - } + // One map per player: each binds its own keys and reads its own pad slot. + this.leftControls = new ActionMap( + { + move: new VectorAction([ + { up: Keyboard.W, down: Keyboard.S, left: Keyboard.A, right: Keyboard.D }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }, + { gamepad: app.input.gamepads[0] }, + ); + this.rightControls = new ActionMap( + { + move: new VectorAction([ + { up: Keyboard.Up, down: Keyboard.Down, left: Keyboard.Left, right: Keyboard.Right }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }, + { gamepad: app.input.gamepads[1] }, + ); + this.inputs.attach(this.leftControls); + this.inputs.attach(this.rightControls); } update(delta) { const speed = 300 * delta; - 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, - ); + const left = this.leftControls.move.value; + const right = this.rightControls.move.value; + this.leftPlayer.move(left.x * speed, left.y * speed); + this.rightPlayer.move(right.x * speed, right.y * speed); this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y); this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y); } @@ -155,8 +108,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SplitScreenScene); diff --git a/examples/application-scenes/multi-view-split-screen.ts b/examples/application-scenes/multi-view-split-screen.ts index 26cfcaf5d..e091803bb 100644 --- a/examples/application-scenes/multi-view-split-screen.ts +++ b/examples/application-scenes/multi-view-split-screen.ts @@ -1,4 +1,5 @@ import { + ActionMap, Application, Color, FixedResolutionCanvasSizing, @@ -11,9 +12,10 @@ import { Sprite, Text, type Texture, + VectorAction, View, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SplitScreenScene extends Scene { private texture!: Texture; @@ -24,26 +26,14 @@ class SplitScreenScene extends Scene { private labels!: Text[]; private leftPlayer!: Sprite; private rightPlayer!: Sprite; - private move = { - a: 0, - d: 0, - w: 0, - s: 0, - left: 0, - right: 0, - up: 0, - down: 0, - }; - private padMove = [ - { x: 0, y: 0 }, - { x: 0, y: 0 }, - ]; + private leftControls!: ActionMap<{ move: VectorAction }>; + private rightControls!: ActionMap<{ move: VectorAction }>; override init(): void { const app = this.app; const { width, height } = app; - this.texture = this.loader.get('image/ship-a.png'); + this.texture = this.loader.get(assets.ship); this.leftView = new View(0, 0, width / 2, height).setViewport(0, 0, 0.5, 1); this.rightView = new View(0, 0, width / 2, height).setViewport(0.5, 0, 0.5, 1); @@ -67,11 +57,6 @@ class SplitScreenScene extends Scene { 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) @@ -81,81 +66,37 @@ class SplitScreenScene extends Scene { .setPosition(160, 0) .setTint(new Color(255, 180, 120)); - this.inputs.onActive(Keyboard.A, () => { - this.move.a = 1; - }); - this.inputs.onStop(Keyboard.A, () => { - this.move.a = 0; - }); - this.inputs.onActive(Keyboard.D, () => { - this.move.d = 1; - }); - this.inputs.onStop(Keyboard.D, () => { - this.move.d = 0; - }); - this.inputs.onActive(Keyboard.W, () => { - this.move.w = 1; - }); - this.inputs.onStop(Keyboard.W, () => { - this.move.w = 0; - }); - this.inputs.onActive(Keyboard.S, () => { - this.move.s = 1; - }); - this.inputs.onStop(Keyboard.S, () => { - this.move.s = 0; - }); - this.inputs.onActive(Keyboard.Left, () => { - this.move.left = 1; - }); - this.inputs.onStop(Keyboard.Left, () => { - this.move.left = 0; - }); - this.inputs.onActive(Keyboard.Right, () => { - this.move.right = 1; - }); - this.inputs.onStop(Keyboard.Right, () => { - this.move.right = 0; - }); - this.inputs.onActive(Keyboard.Up, () => { - this.move.up = 1; - }); - this.inputs.onStop(Keyboard.Up, () => { - this.move.up = 0; - }); - this.inputs.onActive(Keyboard.Down, () => { - this.move.down = 1; - }); - 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; - }); - } + // One map per player: each binds its own keys and reads its own pad slot. + this.leftControls = new ActionMap( + { + move: new VectorAction([ + { up: Keyboard.W, down: Keyboard.S, left: Keyboard.A, right: Keyboard.D }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }, + { gamepad: app.input.gamepads[0] }, + ); + this.rightControls = new ActionMap( + { + move: new VectorAction([ + { up: Keyboard.Up, down: Keyboard.Down, left: Keyboard.Left, right: Keyboard.Right }, + { x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }, + ]), + }, + { gamepad: app.input.gamepads[1] }, + ); + this.inputs.attach(this.leftControls); + this.inputs.attach(this.rightControls); } override update(delta: Seconds): void { const speed = 300 * delta; - 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, - ); + const left = this.leftControls.move.value; + const right = this.rightControls.move.value; + + this.leftPlayer.move(left.x * speed, left.y * speed); + this.rightPlayer.move(right.x * speed, right.y * speed); this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y); this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y); } @@ -184,9 +125,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SplitScreenScene); diff --git a/examples/application-scenes/multiple-scenes.js b/examples/application-scenes/multiple-scenes.js index 0fbd49033..eb277d083 100644 --- a/examples/application-scenes/multiple-scenes.js +++ b/examples/application-scenes/multiple-scenes.js @@ -90,6 +90,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(MenuScene); diff --git a/examples/application-scenes/multiple-scenes.ts b/examples/application-scenes/multiple-scenes.ts index edce85f3a..f1a16fdd8 100644 --- a/examples/application-scenes/multiple-scenes.ts +++ b/examples/application-scenes/multiple-scenes.ts @@ -108,7 +108,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(MenuScene); diff --git a/examples/application-scenes/pause-and-resume.js b/examples/application-scenes/pause-and-resume.js index da16a564d..354f93825 100644 --- a/examples/application-scenes/pause-and-resume.js +++ b/examples/application-scenes/pause-and-resume.js @@ -1,12 +1,13 @@ // Auto-generated from pause-and-resume.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, SceneAvailability, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class PauseResumeScene extends Scene { sprite; label; init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); this.label = new Text('Space or click: pause update', { fillColor: Color.white, fontSize: 16 }); @@ -44,8 +45,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PauseResumeScene); diff --git a/examples/application-scenes/pause-and-resume.ts b/examples/application-scenes/pause-and-resume.ts index c888d7e96..c9560eb70 100644 --- a/examples/application-scenes/pause-and-resume.ts +++ b/examples/application-scenes/pause-and-resume.ts @@ -9,6 +9,7 @@ import { Sprite, Text, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class PauseResumeScene extends Scene { private sprite!: Sprite; @@ -18,7 +19,7 @@ class PauseResumeScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); @@ -64,9 +65,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PauseResumeScene); diff --git a/examples/application-scenes/picture-in-picture.js b/examples/application-scenes/picture-in-picture.js index 2f1fe9563..3ef2cf13e 100644 --- a/examples/application-scenes/picture-in-picture.js +++ b/examples/application-scenes/picture-in-picture.js @@ -1,5 +1,6 @@ // Auto-generated from picture-in-picture.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite, View } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class PictureInPictureScene extends Scene { mainView; pipView; @@ -9,7 +10,7 @@ class PictureInPictureScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.mainView = new View(0, 0, width, height); this.pipView = new View(0, 0, width * 0.3, height * 0.3).setViewport(0.68, 0.04, 0.28, 0.28); // Zoom < 1 zooms OUT (a larger visible world area maps into the same @@ -44,8 +45,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PictureInPictureScene); diff --git a/examples/application-scenes/picture-in-picture.ts b/examples/application-scenes/picture-in-picture.ts index 8a83846c7..772728fa6 100644 --- a/examples/application-scenes/picture-in-picture.ts +++ b/examples/application-scenes/picture-in-picture.ts @@ -9,6 +9,7 @@ import { Sprite, View, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class PictureInPictureScene extends Scene { private mainView!: View; @@ -21,7 +22,7 @@ class PictureInPictureScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.mainView = new View(0, 0, width, height); this.pipView = new View(0, 0, width * 0.3, height * 0.3).setViewport(0.68, 0.04, 0.28, 0.28); @@ -64,9 +65,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PictureInPictureScene); diff --git a/examples/application-scenes/scene-less-app.js b/examples/application-scenes/scene-less-app.js index 0fed4af29..71cb41173 100644 --- a/examples/application-scenes/scene-less-app.js +++ b/examples/application-scenes/scene-less-app.js @@ -1,5 +1,6 @@ // Auto-generated from scene-less-app.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A scene-less Application: no Scene is ever registered or started. All // frame work happens through a single app-level system - useful for utility // apps, splash/loading screens, or anything that doesn't need scene @@ -14,7 +15,7 @@ const app = new Application({ }, clearColor: new Color(18, 22, 33), }); -const square = new Sprite(app.loader.get(assets.demo.textures.pixelWhite)).setAnchor(0.5); +const square = new Sprite(app.loader.get(assets.pixelWhite)).setAnchor(0.5); square.width = 80; square.height = 80; square.tint = new Color(90, 160, 255); diff --git a/examples/application-scenes/scene-less-app.ts b/examples/application-scenes/scene-less-app.ts index d375e4cea..7bcc4f6e6 100644 --- a/examples/application-scenes/scene-less-app.ts +++ b/examples/application-scenes/scene-less-app.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A scene-less Application: no Scene is ever registered or started. All // frame work happens through a single app-level system - useful for utility @@ -16,7 +17,7 @@ const app = new Application({ clearColor: new Color(18, 22, 33), }); -const square = new Sprite(app.loader.get(assets.demo.textures.pixelWhite)).setAnchor(0.5); +const square = new Sprite(app.loader.get(assets.pixelWhite)).setAnchor(0.5); square.width = 80; square.height = 80; square.tint = new Color(90, 160, 255); diff --git a/examples/application-scenes/scene-lifecycle.js b/examples/application-scenes/scene-lifecycle.js index 8c7d4d8f6..1bdce4887 100644 --- a/examples/application-scenes/scene-lifecycle.js +++ b/examples/application-scenes/scene-lifecycle.js @@ -69,8 +69,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(LifecycleScene); diff --git a/examples/application-scenes/scene-lifecycle.ts b/examples/application-scenes/scene-lifecycle.ts index 19e4f3b68..50586d239 100644 --- a/examples/application-scenes/scene-lifecycle.ts +++ b/examples/application-scenes/scene-lifecycle.ts @@ -80,9 +80,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(LifecycleScene); diff --git a/examples/application-scenes/world-vs-screen-coords.js b/examples/application-scenes/world-vs-screen-coords.js index 72c03d1ce..ad4570373 100644 --- a/examples/application-scenes/world-vs-screen-coords.js +++ b/examples/application-scenes/world-vs-screen-coords.js @@ -1,12 +1,10 @@ // Auto-generated from world-vs-screen-coords.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text, View } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; class CameraNavigationScene extends Scene { view; grid; markers; readout; - hud; up; down; left; @@ -48,15 +46,6 @@ class CameraNavigationScene extends Scene { 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); @@ -74,7 +63,6 @@ class CameraNavigationScene extends Scene { 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(); } } @@ -87,6 +75,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { basePath: 'assets/' }, }); 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 b07503c9c..245755293 100644 --- a/examples/application-scenes/world-vs-screen-coords.ts +++ b/examples/application-scenes/world-vs-screen-coords.ts @@ -11,14 +11,12 @@ import { Text, View, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; class CameraNavigationScene extends Scene { private view!: View; private grid!: Graphics; private markers!: Graphics; private readout!: Text; - private hud!: ReturnType; private up!: InputBinding; private down!: InputBinding; private left!: InputBinding; @@ -65,15 +63,6 @@ class CameraNavigationScene extends Scene { 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.', - }); } override update(delta: Seconds): void { @@ -94,7 +83,6 @@ class CameraNavigationScene extends Scene { 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(); } } @@ -108,7 +96,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { basePath: 'assets/' }, }); await app.start(CameraNavigationScene); diff --git a/examples/assets/assets.js b/examples/assets/assets.js deleted file mode 100644 index 134a4b0c4..000000000 --- a/examples/assets/assets.js +++ /dev/null @@ -1,215 +0,0 @@ -// Auto-generated from assets.ts - edit the .ts source, not this file. -/** - * Canonical hierarchical asset catalog for ExoJS examples and playground. - * - * Example authors use the injected `assets` global directly (no import): - * const path = assets.demo.textures.particleFlame; - * - * This module is the source of truth: the controlled example runtimes install a - * resolved copy of it on `globalThis.assets`, and examples:sync generates the - * `assets-global.d.ts` typing + the runtime `catalog.js`. - */ -export const assets = { - demo: { - isometricLandscape: 'json/maps/isometric-landscape.tmj', - textures: { - particleFlame: 'demo/textures/particle-flame.png', - particleSmoke: 'demo/textures/particle-smoke.png', - particleStar: 'demo/textures/particle-star.png', - particleSpark: 'demo/textures/particle-spark.png', - particleLight: 'demo/textures/particle-light.png', - shipA: 'demo/textures/ship-a.png', - pixelWhite: 'demo/textures/pixel-white.png', - pixelBlack: 'demo/textures/pixel-black.png', - pixelTransparent: 'demo/textures/pixel-transparent.png', - checkerboardTransparent: 'demo/textures/checkerboard-transparent.png', - kenneyUv: 'demo/textures/kenney-uv.png', - prototypeDark01: 'demo/textures/prototype-dark-01.png', - prototypeLight01: 'demo/textures/prototype-light-01.png', - prototypeGrid: 'demo/textures/prototype-grid.png', - }, - sprites: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - }, - spritesheets: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - platformerBackgrounds: { - image: 'demo/spritesheets/kenney/platformer-backgrounds.png', - data: 'demo/spritesheets/kenney/platformer-backgrounds.json', - }, - platformerCharacters: { - image: 'demo/spritesheets/kenney/platformer-characters.png', - data: 'demo/spritesheets/kenney/platformer-characters.json', - }, - platformerEnemies: { - image: 'demo/spritesheets/kenney/platformer-enemies.png', - data: 'demo/spritesheets/kenney/platformer-enemies.json', - }, - platformerTiles: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - }, - backgroundElements: { - image: 'demo/spritesheets/kenney/background-elements.png', - data: 'demo/spritesheets/kenney/background-elements.json', - }, - mapPack: { - image: 'demo/spritesheets/kenney/map-pack.png', - data: 'demo/spritesheets/kenney/map-pack.json', - }, - }, - tilesets: { - platformer: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - tileWidth: 64, - tileHeight: 64, - }, - map: { - image: 'demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png', - tileWidth: 64, - tileHeight: 64, - }, - }, - audio: { - uiClick: 'demo/audio/ui-click.ogg', - uiConfirm: 'demo/audio/ui-confirm.ogg', - uiBong: 'demo/audio/ui-bong.ogg', - impactLight: 'demo/audio/impact-light.ogg', - impactHeavy: 'demo/audio/impact-heavy.ogg', - musicA: 'demo/audio/demo-loop-a.ogg', - musicB: 'demo/audio/demo-loop-b.ogg', - musicLoop: 'demo/audio/demo-loop-main.ogg', - }, - voice: { - // Kenney "Voiceover Pack" (CC0) - spoken phrases, ideal as a - // vocoder modulator or for voiced UI feedback. - congratulations: 'demo/voice/congratulations.ogg', - missionComplete: 'demo/voice/mission-complete.ogg', - objectiveAchieved: 'demo/voice/objective-achieved.ogg', - }, - sound: { - uiClick: 'demo/sound/ui-click.ogg', - uiConfirm: 'demo/sound/ui-confirm.ogg', - uiBong: 'demo/sound/ui-bong.ogg', - back: 'demo/sound/back.ogg', - clickAlt: 'demo/sound/click-alt.ogg', - switch: 'demo/sound/switch.ogg', - impactLight: 'demo/sound/impact-light.ogg', - impactHeavy: 'demo/sound/impact-heavy.ogg', - impactWood: 'demo/sound/impact-wood.ogg', - jump: 'demo/sound/jump.ogg', - coin: 'demo/sound/coin.ogg', - hurt: 'demo/sound/hurt.ogg', - powerUp: 'demo/sound/power-up.ogg', - laser: 'demo/sound/laser.ogg', - }, - music: { - loopA: 'demo/music/demo-loop-a.ogg', - loopB: 'demo/music/demo-loop-b.ogg', - loopMain: 'demo/music/demo-loop-main.ogg', - jingleSuccess: 'demo/music/jingle-success.ogg', - jingleFailure: 'demo/music/jingle-failure.ogg', - jingleRetroA: 'demo/music/jingle-retro-a.ogg', - jingleRetroB: 'demo/music/jingle-retro-b.ogg', - }, - soundSprites: { - ui: { - audio: 'demo/sound-sprites/ui/ui.ogg', - data: 'demo/sound-sprites/ui/ui.json', - }, - impacts: { - audio: 'demo/sound-sprites/impacts/impacts.ogg', - data: 'demo/sound-sprites/impacts/impacts.json', - }, - digital: { - audio: 'demo/sound-sprites/digital/digital.ogg', - data: 'demo/sound-sprites/digital/digital.json', - }, - }, - fonts: { - kenneyFuture: 'demo/fonts/Kenney Future.ttf', - kenneyPixel: 'demo/fonts/Kenney Pixel.ttf', - kenneyMini: 'demo/fonts/Kenney Mini.ttf', - kenneyMiniSquareMono: 'demo/fonts/Kenney Mini Square Mono.ttf', - kenneyBlocksFnt: 'demo/fonts/kenney-blocks-32.fnt', - kenneyBlocksPng: 'demo/fonts/kenney-blocks-32.png', - }, - backgrounds: { - platformerClouds: 'demo/backgrounds/platformer-clouds.png', - platformerHills: 'demo/backgrounds/platformer-hills.png', - castles: 'demo/backgrounds/castles.png', - forest: 'demo/backgrounds/forest.png', - }, - cursors: { - arrow: 'demo/cursors/arrow.svg', - pointer: 'demo/cursors/pointer.svg', - hand: 'demo/cursors/hand.svg', - busy: 'demo/cursors/busy.svg', - cross: 'demo/cursors/cross.svg', - }, - svg: { - play: 'demo/svg/play.svg', - pause: 'demo/svg/pause.svg', - reset: 'demo/svg/reset.svg', - arrowRight: 'demo/svg/arrow-right.svg', - sparkle: 'demo/svg/sparkle.svg', - audioWave: 'demo/svg/audio-wave.svg', - imagePlaceholder: 'demo/svg/image-placeholder.svg', - runeMark: 'demo/svg/rune-mark.svg', - }, - video: { - demoLoop: 'demo/video/demo-loop.webm', - highRes: 'demo/video/high-res.mp4', - highFps: 'demo/video/high-fps.webm', - hdr10: 'demo/video/hdr10.webm', - }, - inputPrompts: { - generic: { - image: 'demo/input-prompts/generic.png', - data: 'demo/input-prompts/generic.json', - }, - xboxSeries: { - image: 'demo/input-prompts/xbox-series.png', - data: 'demo/input-prompts/xbox-series.json', - }, - playstationSeries: { - image: 'demo/input-prompts/playstation-series.png', - data: 'demo/input-prompts/playstation-series.json', - }, - nintendoSwitch: { - image: 'demo/input-prompts/nintendo-switch.png', - data: 'demo/input-prompts/nintendo-switch.json', - }, - keyboardMouse: { - image: 'demo/input-prompts/keyboard-mouse.png', - data: 'demo/input-prompts/keyboard-mouse.json', - }, - }, - vendor: { - kenneyManifest: 'demo/vendor/kenney/manifest.json', - }, - }, - technical: { - alpha: { - alphaEdgeStraight: 'technical/alpha/alpha-edge-straight.png', - alphaGradientRings: 'technical/alpha/alpha-gradient-rings.png', - }, - filtering: { - checker256: 'technical/filtering/checker-256.png', - pixelGrid128: 'technical/filtering/pixel-grid-128.png', - uvGrid256: 'technical/filtering/uv-grid-256.png', - }, - color: { - srgbRamp: 'technical/color/srgb-ramp.png', - hueRamp: 'technical/color/hue-ramp.png', - primaryRamp: 'technical/color/primary-ramp.png', - }, - }, -}; diff --git a/examples/assets/assets.json b/examples/assets/assets.json deleted file mode 100644 index 465159e8d..000000000 --- a/examples/assets/assets.json +++ /dev/null @@ -1 +0,0 @@ -{"audio":["demo-loop-main.ogg","impact-light.ogg","ui-click.ogg"],"font":["Kenney Future.ttf"],"image":["buttons.png","hue-ramp.png","input-prompts-generic.png","input-prompts-xbox-series.png","input-prompts-playstation-series.png","input-prompts-nintendo-switch.png","particle-light.png","platformer-characters.png","ship-a.png","uv-grid-256.png"],"json":["buttons.json","input-prompts-generic.json","input-prompts-xbox-series.json","input-prompts-playstation-series.json","input-prompts-nintendo-switch.json","platformer-characters.json"],"svg":["rune-mark.svg"],"video":["demo-loop.webm"]} diff --git a/examples/assets/assets.ts b/examples/assets/assets.ts deleted file mode 100644 index d92035b87..000000000 --- a/examples/assets/assets.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Canonical hierarchical asset catalog for ExoJS examples and playground. - * - * Example authors use the injected `assets` global directly (no import): - * const path = assets.demo.textures.particleFlame; - * - * This module is the source of truth: the controlled example runtimes install a - * resolved copy of it on `globalThis.assets`, and examples:sync generates the - * `assets-global.d.ts` typing + the runtime `catalog.js`. - */ -export const assets = { - demo: { - isometricLandscape: 'json/maps/isometric-landscape.tmj', - textures: { - particleFlame: 'demo/textures/particle-flame.png', - particleSmoke: 'demo/textures/particle-smoke.png', - particleStar: 'demo/textures/particle-star.png', - particleSpark: 'demo/textures/particle-spark.png', - particleLight: 'demo/textures/particle-light.png', - shipA: 'demo/textures/ship-a.png', - pixelWhite: 'demo/textures/pixel-white.png', - pixelBlack: 'demo/textures/pixel-black.png', - pixelTransparent: 'demo/textures/pixel-transparent.png', - checkerboardTransparent: 'demo/textures/checkerboard-transparent.png', - kenneyUv: 'demo/textures/kenney-uv.png', - prototypeDark01: 'demo/textures/prototype-dark-01.png', - prototypeLight01: 'demo/textures/prototype-light-01.png', - prototypeGrid: 'demo/textures/prototype-grid.png', - }, - sprites: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - }, - spritesheets: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - platformerBackgrounds: { - image: 'demo/spritesheets/kenney/platformer-backgrounds.png', - data: 'demo/spritesheets/kenney/platformer-backgrounds.json', - }, - platformerCharacters: { - image: 'demo/spritesheets/kenney/platformer-characters.png', - data: 'demo/spritesheets/kenney/platformer-characters.json', - }, - platformerEnemies: { - image: 'demo/spritesheets/kenney/platformer-enemies.png', - data: 'demo/spritesheets/kenney/platformer-enemies.json', - }, - platformerTiles: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - }, - backgroundElements: { - image: 'demo/spritesheets/kenney/background-elements.png', - data: 'demo/spritesheets/kenney/background-elements.json', - }, - mapPack: { - image: 'demo/spritesheets/kenney/map-pack.png', - data: 'demo/spritesheets/kenney/map-pack.json', - }, - }, - tilesets: { - platformer: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - tileWidth: 64, - tileHeight: 64, - }, - map: { - image: 'demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png', - tileWidth: 64, - tileHeight: 64, - }, - }, - audio: { - uiClick: 'demo/audio/ui-click.ogg', - uiConfirm: 'demo/audio/ui-confirm.ogg', - uiBong: 'demo/audio/ui-bong.ogg', - impactLight: 'demo/audio/impact-light.ogg', - impactHeavy: 'demo/audio/impact-heavy.ogg', - musicA: 'demo/audio/demo-loop-a.ogg', - musicB: 'demo/audio/demo-loop-b.ogg', - musicLoop: 'demo/audio/demo-loop-main.ogg', - }, - voice: { - // Kenney "Voiceover Pack" (CC0) - spoken phrases, ideal as a - // vocoder modulator or for voiced UI feedback. - congratulations: 'demo/voice/congratulations.ogg', - missionComplete: 'demo/voice/mission-complete.ogg', - objectiveAchieved: 'demo/voice/objective-achieved.ogg', - }, - sound: { - uiClick: 'demo/sound/ui-click.ogg', - uiConfirm: 'demo/sound/ui-confirm.ogg', - uiBong: 'demo/sound/ui-bong.ogg', - back: 'demo/sound/back.ogg', - clickAlt: 'demo/sound/click-alt.ogg', - switch: 'demo/sound/switch.ogg', - impactLight: 'demo/sound/impact-light.ogg', - impactHeavy: 'demo/sound/impact-heavy.ogg', - impactWood: 'demo/sound/impact-wood.ogg', - jump: 'demo/sound/jump.ogg', - coin: 'demo/sound/coin.ogg', - hurt: 'demo/sound/hurt.ogg', - powerUp: 'demo/sound/power-up.ogg', - laser: 'demo/sound/laser.ogg', - }, - music: { - loopA: 'demo/music/demo-loop-a.ogg', - loopB: 'demo/music/demo-loop-b.ogg', - loopMain: 'demo/music/demo-loop-main.ogg', - jingleSuccess: 'demo/music/jingle-success.ogg', - jingleFailure: 'demo/music/jingle-failure.ogg', - jingleRetroA: 'demo/music/jingle-retro-a.ogg', - jingleRetroB: 'demo/music/jingle-retro-b.ogg', - }, - soundSprites: { - ui: { - audio: 'demo/sound-sprites/ui/ui.ogg', - data: 'demo/sound-sprites/ui/ui.json', - }, - impacts: { - audio: 'demo/sound-sprites/impacts/impacts.ogg', - data: 'demo/sound-sprites/impacts/impacts.json', - }, - digital: { - audio: 'demo/sound-sprites/digital/digital.ogg', - data: 'demo/sound-sprites/digital/digital.json', - }, - }, - fonts: { - kenneyFuture: 'demo/fonts/Kenney Future.ttf', - kenneyPixel: 'demo/fonts/Kenney Pixel.ttf', - kenneyMini: 'demo/fonts/Kenney Mini.ttf', - kenneyMiniSquareMono: 'demo/fonts/Kenney Mini Square Mono.ttf', - kenneyBlocksFnt: 'demo/fonts/kenney-blocks-32.fnt', - kenneyBlocksPng: 'demo/fonts/kenney-blocks-32.png', - }, - backgrounds: { - platformerClouds: 'demo/backgrounds/platformer-clouds.png', - platformerHills: 'demo/backgrounds/platformer-hills.png', - castles: 'demo/backgrounds/castles.png', - forest: 'demo/backgrounds/forest.png', - }, - cursors: { - arrow: 'demo/cursors/arrow.svg', - pointer: 'demo/cursors/pointer.svg', - hand: 'demo/cursors/hand.svg', - busy: 'demo/cursors/busy.svg', - cross: 'demo/cursors/cross.svg', - }, - svg: { - play: 'demo/svg/play.svg', - pause: 'demo/svg/pause.svg', - reset: 'demo/svg/reset.svg', - arrowRight: 'demo/svg/arrow-right.svg', - sparkle: 'demo/svg/sparkle.svg', - audioWave: 'demo/svg/audio-wave.svg', - imagePlaceholder: 'demo/svg/image-placeholder.svg', - runeMark: 'demo/svg/rune-mark.svg', - }, - video: { - demoLoop: 'demo/video/demo-loop.webm', - highRes: 'demo/video/high-res.mp4', - highFps: 'demo/video/high-fps.webm', - hdr10: 'demo/video/hdr10.webm', - }, - inputPrompts: { - generic: { - image: 'demo/input-prompts/generic.png', - data: 'demo/input-prompts/generic.json', - }, - xboxSeries: { - image: 'demo/input-prompts/xbox-series.png', - data: 'demo/input-prompts/xbox-series.json', - }, - playstationSeries: { - image: 'demo/input-prompts/playstation-series.png', - data: 'demo/input-prompts/playstation-series.json', - }, - nintendoSwitch: { - image: 'demo/input-prompts/nintendo-switch.png', - data: 'demo/input-prompts/nintendo-switch.json', - }, - keyboardMouse: { - image: 'demo/input-prompts/keyboard-mouse.png', - data: 'demo/input-prompts/keyboard-mouse.json', - }, - }, - vendor: { - kenneyManifest: 'demo/vendor/kenney/manifest.json', - }, - }, - technical: { - alpha: { - alphaEdgeStraight: 'technical/alpha/alpha-edge-straight.png', - alphaGradientRings: 'technical/alpha/alpha-gradient-rings.png', - }, - filtering: { - checker256: 'technical/filtering/checker-256.png', - pixelGrid128: 'technical/filtering/pixel-grid-128.png', - uvGrid256: 'technical/filtering/uv-grid-256.png', - }, - color: { - srgbRamp: 'technical/color/srgb-ramp.png', - hueRamp: 'technical/color/hue-ramp.png', - primaryRamp: 'technical/color/primary-ramp.png', - }, - }, -} as const; - -export type Assets = typeof assets; diff --git a/examples/assets/catalog.js b/examples/assets/catalog.js deleted file mode 100644 index 87f2e5fb0..000000000 --- a/examples/assets/catalog.js +++ /dev/null @@ -1,195 +0,0 @@ -// Auto-generated from catalog.ts - edit the .ts source, not this file. -export const rawAssets = { - textures: { - particleFlame: 'demo/textures/particle-flame.png', - particleSmoke: 'demo/textures/particle-smoke.png', - particleStar: 'demo/textures/particle-star.png', - particleSpark: 'demo/textures/particle-spark.png', - particleLight: 'demo/textures/particle-light.png', - shipA: 'demo/textures/ship-a.png', - pixelWhite: 'demo/textures/pixel-white.png', - pixelBlack: 'demo/textures/pixel-black.png', - pixelTransparent: 'demo/textures/pixel-transparent.png', - checkerboardTransparent: 'demo/textures/checkerboard-transparent.png', - kenneyUv: 'demo/textures/kenney-uv.png', - prototypeDark01: 'demo/textures/prototype-dark-01.png', - prototypeLight01: 'demo/textures/prototype-light-01.png', - prototypeGrid: 'demo/textures/prototype-grid.png', - }, - sprites: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - }, - spritesheets: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - platformerBackgrounds: { - image: 'demo/spritesheets/kenney/platformer-backgrounds.png', - data: 'demo/spritesheets/kenney/platformer-backgrounds.json', - }, - platformerCharacters: { - image: 'demo/spritesheets/kenney/platformer-characters.png', - data: 'demo/spritesheets/kenney/platformer-characters.json', - }, - platformerEnemies: { - image: 'demo/spritesheets/kenney/platformer-enemies.png', - data: 'demo/spritesheets/kenney/platformer-enemies.json', - }, - platformerTiles: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - }, - backgroundElements: { - image: 'demo/spritesheets/kenney/background-elements.png', - data: 'demo/spritesheets/kenney/background-elements.json', - }, - mapPack: { - image: 'demo/spritesheets/kenney/map-pack.png', - data: 'demo/spritesheets/kenney/map-pack.json', - }, - }, - tilesets: { - platformer: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - tileWidth: 64, - tileHeight: 64, - }, - map: { - image: 'demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png', - tileWidth: 64, - tileHeight: 64, - }, - }, - audio: { - uiClick: 'demo/audio/ui-click.ogg', - uiConfirm: 'demo/audio/ui-confirm.ogg', - uiBong: 'demo/audio/ui-bong.ogg', - impactLight: 'demo/audio/impact-light.ogg', - impactHeavy: 'demo/audio/impact-heavy.ogg', - musicA: 'demo/audio/demo-loop-a.ogg', - musicB: 'demo/audio/demo-loop-b.ogg', - musicLoop: 'demo/audio/demo-loop-main.ogg', - }, - sound: { - uiClick: 'demo/sound/ui-click.ogg', - uiConfirm: 'demo/sound/ui-confirm.ogg', - uiBong: 'demo/sound/ui-bong.ogg', - back: 'demo/sound/back.ogg', - clickAlt: 'demo/sound/click-alt.ogg', - switch: 'demo/sound/switch.ogg', - impactLight: 'demo/sound/impact-light.ogg', - impactHeavy: 'demo/sound/impact-heavy.ogg', - impactWood: 'demo/sound/impact-wood.ogg', - jump: 'demo/sound/jump.ogg', - coin: 'demo/sound/coin.ogg', - hurt: 'demo/sound/hurt.ogg', - powerUp: 'demo/sound/power-up.ogg', - laser: 'demo/sound/laser.ogg', - }, - music: { - loopA: 'demo/music/demo-loop-a.ogg', - loopB: 'demo/music/demo-loop-b.ogg', - loopMain: 'demo/music/demo-loop-main.ogg', - jingleSuccess: 'demo/music/jingle-success.ogg', - jingleFailure: 'demo/music/jingle-failure.ogg', - jingleRetroA: 'demo/music/jingle-retro-a.ogg', - jingleRetroB: 'demo/music/jingle-retro-b.ogg', - }, - soundSprites: { - ui: { - audio: 'demo/sound-sprites/ui/ui.ogg', - data: 'demo/sound-sprites/ui/ui.json', - }, - impacts: { - audio: 'demo/sound-sprites/impacts/impacts.ogg', - data: 'demo/sound-sprites/impacts/impacts.json', - }, - digital: { - audio: 'demo/sound-sprites/digital/digital.ogg', - data: 'demo/sound-sprites/digital/digital.json', - }, - }, - fonts: { - kenneyFuture: 'demo/fonts/Kenney Future.ttf', - kenneyPixel: 'demo/fonts/Kenney Pixel.ttf', - kenneyMini: 'demo/fonts/Kenney Mini.ttf', - kenneyMiniSquareMono: 'demo/fonts/Kenney Mini Square Mono.ttf', - kenneyBlocksFnt: 'demo/fonts/kenney-blocks-32.fnt', - kenneyBlocksPng: 'demo/fonts/kenney-blocks-32.png', - }, - backgrounds: { - platformerClouds: 'demo/backgrounds/platformer-clouds.png', - platformerHills: 'demo/backgrounds/platformer-hills.png', - castles: 'demo/backgrounds/castles.png', - forest: 'demo/backgrounds/forest.png', - }, - cursors: { - arrow: 'demo/cursors/arrow.svg', - pointer: 'demo/cursors/pointer.svg', - hand: 'demo/cursors/hand.svg', - busy: 'demo/cursors/busy.svg', - cross: 'demo/cursors/cross.svg', - }, - svg: { - play: 'demo/svg/play.svg', - pause: 'demo/svg/pause.svg', - reset: 'demo/svg/reset.svg', - arrowRight: 'demo/svg/arrow-right.svg', - sparkle: 'demo/svg/sparkle.svg', - audioWave: 'demo/svg/audio-wave.svg', - imagePlaceholder: 'demo/svg/image-placeholder.svg', - runeMark: 'demo/svg/rune-mark.svg', - }, - video: { - demoLoop: 'demo/video/demo-loop.webm', - highRes: 'demo/video/high-res.mp4', - highFps: 'demo/video/high-fps.webm', - hdr10: 'demo/video/hdr10.webm', - }, - inputPrompts: { - generic: { - image: 'demo/input-prompts/generic.png', - data: 'demo/input-prompts/generic.json', - }, - xboxSeries: { - image: 'demo/input-prompts/xbox-series.png', - data: 'demo/input-prompts/xbox-series.json', - }, - playstationSeries: { - image: 'demo/input-prompts/playstation-series.png', - data: 'demo/input-prompts/playstation-series.json', - }, - nintendoSwitch: { - image: 'demo/input-prompts/nintendo-switch.png', - data: 'demo/input-prompts/nintendo-switch.json', - }, - keyboardMouse: { - image: 'demo/input-prompts/keyboard-mouse.png', - data: 'demo/input-prompts/keyboard-mouse.json', - }, - }, - technical: { - alpha: { - alphaEdgeStraight: 'technical/alpha/alpha-edge-straight.png', - alphaGradientRings: 'technical/alpha/alpha-gradient-rings.png', - }, - filtering: { - checker256: 'technical/filtering/checker-256.png', - pixelGrid128: 'technical/filtering/pixel-grid-128.png', - uvGrid256: 'technical/filtering/uv-grid-256.png', - }, - color: { - srgbRamp: 'technical/color/srgb-ramp.png', - hueRamp: 'technical/color/hue-ramp.png', - primaryRamp: 'technical/color/primary-ramp.png', - }, - }, - vendor: { - kenneyManifest: 'demo/vendor/kenney/manifest.json', - }, -}; diff --git a/examples/assets/catalog.ts b/examples/assets/catalog.ts deleted file mode 100644 index 583ac22af..000000000 --- a/examples/assets/catalog.ts +++ /dev/null @@ -1,194 +0,0 @@ -export const rawAssets = { - textures: { - particleFlame: 'demo/textures/particle-flame.png', - particleSmoke: 'demo/textures/particle-smoke.png', - particleStar: 'demo/textures/particle-star.png', - particleSpark: 'demo/textures/particle-spark.png', - particleLight: 'demo/textures/particle-light.png', - shipA: 'demo/textures/ship-a.png', - pixelWhite: 'demo/textures/pixel-white.png', - pixelBlack: 'demo/textures/pixel-black.png', - pixelTransparent: 'demo/textures/pixel-transparent.png', - checkerboardTransparent: 'demo/textures/checkerboard-transparent.png', - kenneyUv: 'demo/textures/kenney-uv.png', - prototypeDark01: 'demo/textures/prototype-dark-01.png', - prototypeLight01: 'demo/textures/prototype-light-01.png', - prototypeGrid: 'demo/textures/prototype-grid.png', - }, - sprites: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - }, - spritesheets: { - buttons: { - image: 'demo/sprites/buttons.png', - data: 'demo/sprites/buttons.json', - }, - platformerBackgrounds: { - image: 'demo/spritesheets/kenney/platformer-backgrounds.png', - data: 'demo/spritesheets/kenney/platformer-backgrounds.json', - }, - platformerCharacters: { - image: 'demo/spritesheets/kenney/platformer-characters.png', - data: 'demo/spritesheets/kenney/platformer-characters.json', - }, - platformerEnemies: { - image: 'demo/spritesheets/kenney/platformer-enemies.png', - data: 'demo/spritesheets/kenney/platformer-enemies.json', - }, - platformerTiles: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - }, - backgroundElements: { - image: 'demo/spritesheets/kenney/background-elements.png', - data: 'demo/spritesheets/kenney/background-elements.json', - }, - mapPack: { - image: 'demo/spritesheets/kenney/map-pack.png', - data: 'demo/spritesheets/kenney/map-pack.json', - }, - }, - tilesets: { - platformer: { - image: 'demo/spritesheets/kenney/platformer-tiles.png', - data: 'demo/spritesheets/kenney/platformer-tiles.json', - tileWidth: 64, - tileHeight: 64, - }, - map: { - image: 'demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png', - tileWidth: 64, - tileHeight: 64, - }, - }, - audio: { - uiClick: 'demo/audio/ui-click.ogg', - uiConfirm: 'demo/audio/ui-confirm.ogg', - uiBong: 'demo/audio/ui-bong.ogg', - impactLight: 'demo/audio/impact-light.ogg', - impactHeavy: 'demo/audio/impact-heavy.ogg', - musicA: 'demo/audio/demo-loop-a.ogg', - musicB: 'demo/audio/demo-loop-b.ogg', - musicLoop: 'demo/audio/demo-loop-main.ogg', - }, - sound: { - uiClick: 'demo/sound/ui-click.ogg', - uiConfirm: 'demo/sound/ui-confirm.ogg', - uiBong: 'demo/sound/ui-bong.ogg', - back: 'demo/sound/back.ogg', - clickAlt: 'demo/sound/click-alt.ogg', - switch: 'demo/sound/switch.ogg', - impactLight: 'demo/sound/impact-light.ogg', - impactHeavy: 'demo/sound/impact-heavy.ogg', - impactWood: 'demo/sound/impact-wood.ogg', - jump: 'demo/sound/jump.ogg', - coin: 'demo/sound/coin.ogg', - hurt: 'demo/sound/hurt.ogg', - powerUp: 'demo/sound/power-up.ogg', - laser: 'demo/sound/laser.ogg', - }, - music: { - loopA: 'demo/music/demo-loop-a.ogg', - loopB: 'demo/music/demo-loop-b.ogg', - loopMain: 'demo/music/demo-loop-main.ogg', - jingleSuccess: 'demo/music/jingle-success.ogg', - jingleFailure: 'demo/music/jingle-failure.ogg', - jingleRetroA: 'demo/music/jingle-retro-a.ogg', - jingleRetroB: 'demo/music/jingle-retro-b.ogg', - }, - soundSprites: { - ui: { - audio: 'demo/sound-sprites/ui/ui.ogg', - data: 'demo/sound-sprites/ui/ui.json', - }, - impacts: { - audio: 'demo/sound-sprites/impacts/impacts.ogg', - data: 'demo/sound-sprites/impacts/impacts.json', - }, - digital: { - audio: 'demo/sound-sprites/digital/digital.ogg', - data: 'demo/sound-sprites/digital/digital.json', - }, - }, - fonts: { - kenneyFuture: 'demo/fonts/Kenney Future.ttf', - kenneyPixel: 'demo/fonts/Kenney Pixel.ttf', - kenneyMini: 'demo/fonts/Kenney Mini.ttf', - kenneyMiniSquareMono: 'demo/fonts/Kenney Mini Square Mono.ttf', - kenneyBlocksFnt: 'demo/fonts/kenney-blocks-32.fnt', - kenneyBlocksPng: 'demo/fonts/kenney-blocks-32.png', - }, - backgrounds: { - platformerClouds: 'demo/backgrounds/platformer-clouds.png', - platformerHills: 'demo/backgrounds/platformer-hills.png', - castles: 'demo/backgrounds/castles.png', - forest: 'demo/backgrounds/forest.png', - }, - cursors: { - arrow: 'demo/cursors/arrow.svg', - pointer: 'demo/cursors/pointer.svg', - hand: 'demo/cursors/hand.svg', - busy: 'demo/cursors/busy.svg', - cross: 'demo/cursors/cross.svg', - }, - svg: { - play: 'demo/svg/play.svg', - pause: 'demo/svg/pause.svg', - reset: 'demo/svg/reset.svg', - arrowRight: 'demo/svg/arrow-right.svg', - sparkle: 'demo/svg/sparkle.svg', - audioWave: 'demo/svg/audio-wave.svg', - imagePlaceholder: 'demo/svg/image-placeholder.svg', - runeMark: 'demo/svg/rune-mark.svg', - }, - video: { - demoLoop: 'demo/video/demo-loop.webm', - highRes: 'demo/video/high-res.mp4', - highFps: 'demo/video/high-fps.webm', - hdr10: 'demo/video/hdr10.webm', - }, - inputPrompts: { - generic: { - image: 'demo/input-prompts/generic.png', - data: 'demo/input-prompts/generic.json', - }, - xboxSeries: { - image: 'demo/input-prompts/xbox-series.png', - data: 'demo/input-prompts/xbox-series.json', - }, - playstationSeries: { - image: 'demo/input-prompts/playstation-series.png', - data: 'demo/input-prompts/playstation-series.json', - }, - nintendoSwitch: { - image: 'demo/input-prompts/nintendo-switch.png', - data: 'demo/input-prompts/nintendo-switch.json', - }, - keyboardMouse: { - image: 'demo/input-prompts/keyboard-mouse.png', - data: 'demo/input-prompts/keyboard-mouse.json', - }, - }, - technical: { - alpha: { - alphaEdgeStraight: 'technical/alpha/alpha-edge-straight.png', - alphaGradientRings: 'technical/alpha/alpha-gradient-rings.png', - }, - filtering: { - checker256: 'technical/filtering/checker-256.png', - pixelGrid128: 'technical/filtering/pixel-grid-128.png', - uvGrid256: 'technical/filtering/uv-grid-256.png', - }, - color: { - srgbRamp: 'technical/color/srgb-ramp.png', - hueRamp: 'technical/color/hue-ramp.png', - primaryRamp: 'technical/color/primary-ramp.png', - }, - }, - vendor: { - kenneyManifest: 'demo/vendor/kenney/manifest.json', - }, -} as const; diff --git a/examples/assets/demo/sound-sprites/digital/digital.json b/examples/assets/demo/sound-sprites/digital/digital.json index cade8e993..c63ebce21 100644 --- a/examples/assets/demo/sound-sprites/digital/digital.json +++ b/examples/assets/demo/sound-sprites/digital/digital.json @@ -1 +1 @@ -{"sprites":{"powerUp":{"start":0,"duration":1.2016},"laser":{"start":1.3016,"duration":1.1004}}} +{"powerUp":{"start":0,"end":1.2016},"laser":{"start":1.3016,"end":2.402}} diff --git a/examples/assets/demo/sound-sprites/impacts/impacts.json b/examples/assets/demo/sound-sprites/impacts/impacts.json index ec616afc7..563b5f819 100644 --- a/examples/assets/demo/sound-sprites/impacts/impacts.json +++ b/examples/assets/demo/sound-sprites/impacts/impacts.json @@ -1 +1 @@ -{"sprites":{"impactLight":{"start":0,"duration":0.1387},"impactHeavy":{"start":0.2387,"duration":0.1677},"impactWood":{"start":0.5063,"duration":0.3327}}} +{"impactLight":{"start":0,"end":0.1387},"impactHeavy":{"start":0.2387,"end":0.4064},"impactWood":{"start":0.5063,"end":0.839}} diff --git a/examples/assets/demo/sound-sprites/ui/ui.json b/examples/assets/demo/sound-sprites/ui/ui.json index d6549df6a..bb20cadd0 100644 --- a/examples/assets/demo/sound-sprites/ui/ui.json +++ b/examples/assets/demo/sound-sprites/ui/ui.json @@ -1 +1 @@ -{"sprites":{"uiClick":{"start":0,"duration":0.1},"uiConfirm":{"start":0.2,"duration":0.2898},"uiBong":{"start":0.5899,"duration":0.1228},"back":{"start":0.8127,"duration":0.0639},"clickAlt":{"start":0.9766,"duration":0.3671},"switch":{"start":1.4437,"duration":0.2273}}} +{"uiClick":{"start":0,"end":0.1},"uiConfirm":{"start":0.2,"end":0.4898},"uiBong":{"start":0.5899,"end":0.7127},"back":{"start":0.8127,"end":0.8766},"clickAlt":{"start":0.9766,"end":1.3437},"switch":{"start":1.4437,"end":1.671}} diff --git a/examples/assets/demo/video/clips/hdr10.webm b/examples/assets/demo/video/clips/hdr10.webm new file mode 100644 index 000000000..c9ea0cf1d Binary files /dev/null and b/examples/assets/demo/video/clips/hdr10.webm differ diff --git a/examples/assets/demo/video/clips/high-fps.webm b/examples/assets/demo/video/clips/high-fps.webm new file mode 100644 index 000000000..8821e33cb Binary files /dev/null and b/examples/assets/demo/video/clips/high-fps.webm differ diff --git a/examples/assets/demo/video/clips/high-res.mp4 b/examples/assets/demo/video/clips/high-res.mp4 new file mode 100644 index 000000000..c63c8a160 Binary files /dev/null and b/examples/assets/demo/video/clips/high-res.mp4 differ diff --git a/examples/assets/index.js b/examples/assets/index.js deleted file mode 100644 index 08fd335bb..000000000 --- a/examples/assets/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// Auto-generated from index.ts - edit the .ts source, not this file. -export { assets } from './assets.js'; diff --git a/examples/assets/index.ts b/examples/assets/index.ts deleted file mode 100644 index 0352eb349..000000000 --- a/examples/assets/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { Assets } from './assets.js'; -export { assets } from './assets.js'; diff --git a/examples/assets/resolver.js b/examples/assets/resolver.js deleted file mode 100644 index 5f1ea314f..000000000 --- a/examples/assets/resolver.js +++ /dev/null @@ -1,19 +0,0 @@ -// Auto-generated from resolver.ts - edit the .ts source, not this file. -export const createAssetUrl = (path, baseUrl = '/assets/') => { - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - const normalizedPath = path.startsWith('/') ? path.slice(1) : path; - return `${normalizedBase}${normalizedPath}`; -}; -export const resolveAssetCatalog = (catalog, baseUrl = '/assets/') => { - const resolved = {}; - for (const [key, value] of Object.entries(catalog)) { - if (typeof value === 'string') { - resolved[key] = createAssetUrl(value, baseUrl); - } else if (value && typeof value === 'object') { - resolved[key] = resolveAssetCatalog(value, baseUrl); - } else { - resolved[key] = value; - } - } - return resolved; -}; diff --git a/examples/assets/resolver.ts b/examples/assets/resolver.ts deleted file mode 100644 index 4886738a0..000000000 --- a/examples/assets/resolver.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const createAssetUrl = (path: string, baseUrl = '/assets/'): string => { - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - const normalizedPath = path.startsWith('/') ? path.slice(1) : path; - - return `${normalizedBase}${normalizedPath}`; -}; - -type ResolvedCatalog> = { - [K in keyof T]: T[K] extends string ? string : T[K] extends Record ? ResolvedCatalog : T[K]; -}; - -export const resolveAssetCatalog = >(catalog: T, baseUrl = '/assets/'): ResolvedCatalog => { - const resolved = {} as Record; - - for (const [key, value] of Object.entries(catalog)) { - if (typeof value === 'string') { - resolved[key] = createAssetUrl(value, baseUrl); - } else if (value && typeof value === 'object') { - resolved[key] = resolveAssetCatalog(value as Record, baseUrl); - } else { - resolved[key] = value; - } - } - - return resolved as ResolvedCatalog; -}; diff --git a/examples/audio-basics/audio-buses.js b/examples/audio-basics/audio-buses.js index 008e5df64..ad48ceb6c 100644 --- a/examples/audio-basics/audio-buses.js +++ b/examples/audio-basics/audio-buses.js @@ -1,72 +1,47 @@ // Auto-generated from audio-buses.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider } from '@examples/runtime'; class AudioBusesScene extends Scene { music; sfx; musicVoice; diagram; panel; - hud; + prompt; async load() { - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); - this.sfx = this.loader.get(assets.demo.audio.uiClick); + this.music = await this.loader.load(assets.musicLoopMain); + this.sfx = this.loader.get(assets.soundUiClick); } 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 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.', - }); - 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; - }, - }); - this.panel.addSlider({ - label: 'Music', - min: 0, - max: 1, - step: 0.01, - value: audio.music.volume, - onChange: value => { - audio.music.volume = value; + this.panel = demoPanel({ + title: 'Mixer', + controls: { + master: slider('Master', { min: 0, max: 1, step: 0.01, value: audio.master.volume }), + music: slider('Music', { min: 0, max: 1, step: 0.01, value: audio.music.volume }), + sound: slider('Sound', { min: 0, max: 1, step: 0.01, value: audio.sound.volume }), }, - }); - this.panel.addSlider({ - label: 'Sound', - min: 0, - max: 1, - step: 0.01, - value: audio.sound.volume, - onChange: value => { - audio.sound.volume = value; - }, - }); - this.panel.addButton({ - label: 'Play sound effect', - onClick: () => { - audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }); + actions: { 'Play sound effect': () => audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }) }, + onChange: (key, value) => { + audio[key].volume = value; }, }); + // The track waits for the first gesture to unlock audio, then starts. + this.prompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22 }).setPosition(420, 420); this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.6 }); } draw(context) { context.render(this.diagram); + if (this.app.audio.locked) { + context.render(this.prompt); + } } destroy() { this.musicVoice?.stop(); this.panel?.dispose(); - this.hud?.dispose(); this.diagram?.destroy(); super.destroy(); } diff --git a/examples/audio-basics/audio-buses.ts b/examples/audio-basics/audio-buses.ts index 658e0d05f..04adf6228 100644 --- a/examples/audio-basics/audio-buses.ts +++ b/examples/audio-basics/audio-buses.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -10,19 +9,20 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; class AudioBusesScene extends Scene { private music!: AudioStream; private sfx!: Sound; private musicVoice!: Voice; private diagram!: Text; - private panel!: ReturnType; - private hud!: ReturnType; + private panel!: DemoPanel<{ master: SliderSpec; music: SliderSpec; sound: SliderSpec }>; + private prompt!: Text; override async load(): Promise { - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); - this.sfx = this.loader.get(assets.demo.audio.uiClick); + this.music = await this.loader.load(assets.musicLoopMain); + this.sfx = this.loader.get(assets.soundUiClick); } override init(): void { @@ -30,60 +30,35 @@ class AudioBusesScene extends Scene { 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 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.', - }); - 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; - }, - }); - this.panel.addSlider({ - label: 'Music', - min: 0, - max: 1, - step: 0.01, - value: audio.music.volume, - onChange: value => { - audio.music.volume = value; + this.panel = demoPanel({ + title: 'Mixer', + controls: { + master: slider('Master', { min: 0, max: 1, step: 0.01, value: audio.master.volume }), + music: slider('Music', { min: 0, max: 1, step: 0.01, value: audio.music.volume }), + sound: slider('Sound', { min: 0, max: 1, step: 0.01, value: audio.sound.volume }), }, - }); - this.panel.addSlider({ - label: 'Sound', - min: 0, - max: 1, - step: 0.01, - value: audio.sound.volume, - onChange: value => { - audio.sound.volume = value; - }, - }); - this.panel.addButton({ - label: 'Play sound effect', - onClick: () => { - audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }); + actions: { 'Play sound effect': () => audio.play(this.sfx, { bus: audio.sound, volume: 0.5 }) }, + onChange: (key, value) => { + audio[key].volume = value; }, }); + // The track waits for the first gesture to unlock audio, then starts. + this.prompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22 }).setPosition(420, 420); this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: 0.6 }); } override draw(context: RenderingContext): void { context.render(this.diagram); + + if (this.app.audio.locked) { + context.render(this.prompt); + } } override destroy(): void { this.musicVoice?.stop(); this.panel?.dispose(); - this.hud?.dispose(); this.diagram?.destroy(); super.destroy(); } diff --git a/examples/audio-basics/crossfade-tracks.js b/examples/audio-basics/crossfade-tracks.js index 24c176c9a..971c59635 100644 --- a/examples/audio-basics/crossfade-tracks.js +++ b/examples/audio-basics/crossfade-tracks.js @@ -1,6 +1,7 @@ // 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 { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, crossFade, FixedResolutionCanvasSizing, Graphics, Scene, Text, Time } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel } from '@examples/runtime'; const PEAK = 0.7; const COLOR_A = new Color(120, 200, 255); const COLOR_B = new Color(255, 160, 120); @@ -25,7 +26,6 @@ class CrossfadeTracksScene extends Scene { meterAX = 0; meterBX = 0; meterBaseY = 0; - hud; panel; async load() { const app = this.app; @@ -38,10 +38,7 @@ class CrossfadeTracksScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so each // track is loaded directly through `Asset.type('music', ...)` and awaited. Both // tracks loop; the crossfade only swaps which one is audible. - [this.trackA, this.trackB] = await Promise.all([ - this.loader.load(Asset.type('music', assets.demo.audio.musicA)), - this.loader.load(Asset.type('music', assets.demo.audio.musicB)), - ]); + [this.trackA, this.trackB] = await Promise.all([this.loader.load(assets.musicLoopA), this.loader.load(assets.musicLoopB)]); this.graphics = new Graphics(); this.labelA = new Text('Track A', { fillColor: Color.white, fontSize: 22, align: 'center' }) .setAnchor(0.5, 0.5) @@ -57,26 +54,28 @@ class CrossfadeTracksScene extends Scene { 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.hud = mountControls({ - title: 'Crossfade Tracks', - controls: [{ keys: 'Choose A or B', action: 'crossfade between looping tracks (2s)' }], - status: 'Click or press any key to start…', - 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 - choose a track to crossfade.'); + this.panel = demoPanel({ + title: 'Crossfade', + corner: 'bottom-left', + controls: { track: choice('Track', ['Track A', 'Track B']) }, + onChange: (_key, value) => this.selectTrack(value === 'Track B' ? 1 : 0), + }); } selectTrack(target) { - if (this.app.audio.locked || this.fadeElapsed < this.fadeDuration || target === this.mix) { + if (target === this.fadeTo) { return; } + if (this.app.audio.locked) { + this.panel.set('track', this.fadeTo === 1 ? 'Track B' : 'Track A'); + return; + } + // A new choice mid-fade restarts from the current mix; crossFade ramps the + // voice gains from where they are, so the meter and the audio stay together. this.fadeFrom = this.mix; this.fadeTo = target; this.fadeElapsed = 0; @@ -85,7 +84,6 @@ class CrossfadeTracksScene extends Scene { } 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) { @@ -93,9 +91,6 @@ class CrossfadeTracksScene extends Scene { } 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; @@ -139,7 +134,6 @@ class CrossfadeTracksScene extends Scene { this.trackAVoice?.stop(); this.trackBVoice?.stop(); this.panel?.dispose(); - this.hud?.dispose(); this.graphics?.destroy(); this.labelA?.destroy(); this.labelB?.destroy(); diff --git a/examples/audio-basics/crossfade-tracks.ts b/examples/audio-basics/crossfade-tracks.ts index 9fa98a5e3..b1dbfd8b9 100644 --- a/examples/audio-basics/crossfade-tracks.ts +++ b/examples/audio-basics/crossfade-tracks.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, crossFade, @@ -13,7 +12,8 @@ import { Time, type Voice, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel } from '@examples/runtime'; const PEAK = 0.7; const COLOR_A = new Color(120, 200, 255); @@ -41,8 +41,7 @@ class CrossfadeTracksScene extends Scene { private meterAX = 0; private meterBX = 0; private meterBaseY = 0; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: DemoPanel<{ track: ChoiceSpec<'Track A' | 'Track B'> }>; override async load(): Promise { const app = this.app; @@ -57,10 +56,7 @@ class CrossfadeTracksScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so each // track is loaded directly through `Asset.type('music', ...)` and awaited. Both // tracks loop; the crossfade only swaps which one is audible. - [this.trackA, this.trackB] = await Promise.all([ - this.loader.load(Asset.type('music', assets.demo.audio.musicA)), - this.loader.load(Asset.type('music', assets.demo.audio.musicB)), - ]); + [this.trackA, this.trackB] = await Promise.all([this.loader.load(assets.musicLoopA), this.loader.load(assets.musicLoopB)]); this.graphics = new Graphics(); this.labelA = new Text('Track A', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -79,29 +75,33 @@ class CrossfadeTracksScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Crossfade Tracks', - controls: [{ keys: 'Choose A or B', action: 'crossfade between looping tracks (2s)' }], - status: 'Click or press any key to start…', - 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 - choose a track to crossfade.'); + + this.panel = demoPanel({ + title: 'Crossfade', + corner: 'bottom-left', + controls: { track: choice('Track', ['Track A', 'Track B']) }, + onChange: (_key, value) => this.selectTrack(value === 'Track B' ? 1 : 0), + }); } private selectTrack(target: 0 | 1): void { - if (this.app.audio.locked || this.fadeElapsed < this.fadeDuration || target === this.mix) { + if (target === this.fadeTo) { return; } + if (this.app.audio.locked) { + this.panel.set('track', this.fadeTo === 1 ? 'Track B' : 'Track A'); + + return; + } + + // A new choice mid-fade restarts from the current mix; crossFade ramps the + // voice gains from where they are, so the meter and the audio stay together. this.fadeFrom = this.mix; this.fadeTo = target; this.fadeElapsed = 0; @@ -111,8 +111,6 @@ class CrossfadeTracksScene extends Scene { } 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 { @@ -122,10 +120,6 @@ class CrossfadeTracksScene extends Scene { 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 { @@ -180,7 +174,6 @@ class CrossfadeTracksScene extends Scene { this.trackAVoice?.stop(); this.trackBVoice?.stop(); this.panel?.dispose(); - this.hud?.dispose(); this.graphics?.destroy(); this.labelA?.destroy(); this.labelB?.destroy(); diff --git a/examples/audio-basics/music-loop.js b/examples/audio-basics/music-loop.js index 068816a04..dbee8866d 100644 --- a/examples/audio-basics/music-loop.js +++ b/examples/audio-basics/music-loop.js @@ -1,6 +1,7 @@ // Auto-generated from music-loop.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider, toggle } from '@examples/runtime'; class MusicLoopScene extends Scene { music; musicVoice; @@ -9,23 +10,19 @@ class MusicLoopScene extends Scene { tapPrompt; // Progress bar geometry, canvas-relative, computed in init(). bar = { x: 0, y: 0, w: 0, h: 28 }; - hud; - panel; async load() { const app = this.app; const { width, height } = app; // Wide progress bar centred horizontally on the 16:9 canvas. this.bar = { x: width * 0.15, y: height * 0.5, w: width * 0.7, h: 28 }; // A single streaming track - the browser's media pipeline loops it - // seamlessly when `loop` is on, so no duplicate/silent track is needed. - // AudioStream is a non-leaf resource kind (no seamless placeholder), so it - // is loaded directly through `Asset.type('music', ...)` and awaited rather - // than adopted from an `Assets.from` catalog leaf. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + // seamlessly when `loop` is on. Music has no placeholder handle, so it is + // awaited with `load()` rather than claimed with `get()`. + this.music = await this.loader.load(assets.musicLoopMain); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now, // and all live control (volume, rate, loop, seek) lives on it. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.7, playbackRate: 1 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.5, playbackRate: 1 }); this.graphics = new Graphics(); this.status = new Text('', { fillColor: Color.white, fontSize: 18 }).setPosition(this.bar.x, this.bar.y - 36); // Shown while the browser still blocks audio (`app.audio.locked`); the @@ -33,51 +30,41 @@ class MusicLoopScene extends Scene { 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.hud = mountControls({ - title: 'Music Loop', - controls: [{ keys: 'Panel', action: 'adjust volume, playback rate, and looping' }], - status: 'Click or press any key to start the track…', - hint: 'The bar shows playback position. With Loop off, the track plays once and stops; turn Loop on to repeat seamlessly.', - }); // Real, engine-backed controls over the live music Voice. - this.panel = mountControlPanel({ title: 'Music', corner: 'bottom-left' }); - this.panel.addSlider({ - label: 'Volume', - min: 0, - max: 1, - step: 0.01, - value: this.musicVoice.volume, - onChange: value => (this.musicVoice.volume = value), - }); - this.panel.addSlider({ - label: 'Playback rate', - min: 0.5, - max: 2, - step: 0.05, - value: this.musicVoice.playbackRate, - onChange: value => (this.musicVoice.playbackRate = value), - }); - this.panel.addToggle({ - label: 'Loop', - value: this.musicVoice.loop, - onChange: on => { - this.musicVoice.loop = on; - // Re-running off the end: if the track already finished while - // loop was off, re-enabling and playing restarts it. - if (on && this.musicVoice.paused) { - this.musicVoice.seek(0); - this.musicVoice.resume(); - } + const voice = this.musicVoice; + const togglePause = () => { + if (voice.paused || voice.ended) { + voice.resume(); + } else { + voice.pause(); + } + }; + demoPanel({ + title: 'Music', + controls: { + volume: slider('Volume', { min: 0, max: 1, step: 0.01, value: voice.volume }), + rate: slider('Playback rate', { min: 0.5, max: 2, step: 0.05, value: voice.playbackRate, format: v => `${Math.round(v * 100)}%` }), + loop: toggle('Loop', voice.loop), }, - }); - this.panel.addButton({ - label: 'Restart', - onClick: () => { - this.musicVoice.seek(0); - this.musicVoice.resume(); + actions: { + 'Pause / resume': togglePause, + Restart: () => { + voice.seek(0); + voice.resume(); + }, + }, + onChange: (key, _value, values) => { + voice.volume = values.volume; + voice.playbackRate = values.rate; + voice.loop = values.loop; + // Re-enabling the loop after the track ran off its end restarts it. + if (key === 'loop' && values.loop && voice.ended) { + voice.seek(0); + voice.resume(); + } }, }); - this.hud.setStatus('Playing — use the panel to mix.'); + this.inputs.onTrigger(Keyboard.Space, togglePause); } draw(context) { const app = this.app; diff --git a/examples/audio-basics/music-loop.ts b/examples/audio-basics/music-loop.ts index e94e560d5..7fb63dcab 100644 --- a/examples/audio-basics/music-loop.ts +++ b/examples/audio-basics/music-loop.ts @@ -1,10 +1,10 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, Graphics, + Keyboard, type Loopable, type Pausable, type RatePitched, @@ -14,7 +14,8 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider, toggle } from '@examples/runtime'; class MusicLoopScene extends Scene { private music!: AudioStream; @@ -24,8 +25,6 @@ class MusicLoopScene extends Scene { private tapPrompt!: Text; // Progress bar geometry, canvas-relative, computed in init(). private bar = { x: 0, y: 0, w: 0, h: 28 }; - private hud!: ReturnType; - private panel!: ReturnType; override async load(): Promise { const app = this.app; @@ -35,16 +34,14 @@ class MusicLoopScene extends Scene { this.bar = { x: width * 0.15, y: height * 0.5, w: width * 0.7, h: 28 }; // A single streaming track - the browser's media pipeline loops it - // seamlessly when `loop` is on, so no duplicate/silent track is needed. - // AudioStream is a non-leaf resource kind (no seamless placeholder), so it - // is loaded directly through `Asset.type('music', ...)` and awaited rather - // than adopted from an `Assets.from` catalog leaf. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + // seamlessly when `loop` is on. Music has no placeholder handle, so it is + // awaited with `load()` rather than claimed with `get()`. + this.music = await this.loader.load(assets.musicLoopMain); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now, // and all live control (volume, rate, loop, seek) lives on it. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.7, playbackRate: 1 }) as Voice & + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.5, playbackRate: 1 }) as Voice & Seekable & Pausable & Loopable & @@ -59,54 +56,45 @@ class MusicLoopScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Music Loop', - controls: [{ keys: 'Panel', action: 'adjust volume, playback rate, and looping' }], - status: 'Click or press any key to start the track…', - hint: 'The bar shows playback position. With Loop off, the track plays once and stops; turn Loop on to repeat seamlessly.', - }); - // Real, engine-backed controls over the live music Voice. - this.panel = mountControlPanel({ title: 'Music', corner: 'bottom-left' }); - this.panel.addSlider({ - label: 'Volume', - min: 0, - max: 1, - step: 0.01, - value: this.musicVoice.volume, - onChange: value => (this.musicVoice.volume = value), - }); - this.panel.addSlider({ - label: 'Playback rate', - min: 0.5, - max: 2, - step: 0.05, - value: this.musicVoice.playbackRate, - onChange: value => (this.musicVoice.playbackRate = value), - }); - this.panel.addToggle({ - label: 'Loop', - value: this.musicVoice.loop, - onChange: on => { - this.musicVoice.loop = on; - - // Re-running off the end: if the track already finished while - // loop was off, re-enabling and playing restarts it. - if (on && this.musicVoice.paused) { - this.musicVoice.seek(0); - this.musicVoice.resume(); - } + const voice = this.musicVoice; + + const togglePause = (): void => { + if (voice.paused || voice.ended) { + voice.resume(); + } else { + voice.pause(); + } + }; + + demoPanel({ + title: 'Music', + controls: { + volume: slider('Volume', { min: 0, max: 1, step: 0.01, value: voice.volume }), + rate: slider('Playback rate', { min: 0.5, max: 2, step: 0.05, value: voice.playbackRate, format: v => `${Math.round(v * 100)}%` }), + loop: toggle('Loop', voice.loop), }, - }); - this.panel.addButton({ - label: 'Restart', - onClick: () => { - this.musicVoice.seek(0); - this.musicVoice.resume(); + actions: { + 'Pause / resume': togglePause, + Restart: () => { + voice.seek(0); + voice.resume(); + }, + }, + onChange: (key, _value, values) => { + voice.volume = values.volume; + voice.playbackRate = values.rate; + voice.loop = values.loop; + + // Re-enabling the loop after the track ran off its end restarts it. + if (key === 'loop' && values.loop && voice.ended) { + voice.seek(0); + voice.resume(); + } }, }); - this.hud.setStatus('Playing — use the panel to mix.'); + this.inputs.onTrigger(Keyboard.Space, togglePause); } override draw(context: RenderingContext): void { diff --git a/examples/audio-basics/play-sound.js b/examples/audio-basics/play-sound.js index ab1d8d272..c53e584b7 100644 --- a/examples/audio-basics/play-sound.js +++ b/examples/audio-basics/play-sound.js @@ -1,7 +1,14 @@ // Auto-generated from play-sound.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A small pool of different UI sounds so repeated taps stay interesting. -const SOUND_KEYS = ['uiClick', 'uiConfirm', 'uiBong', 'impactLight', 'impactHeavy']; +const SOUNDS = [ + { name: 'UI click', asset: assets.soundUiClick }, + { name: 'UI confirm', asset: assets.soundUiConfirm }, + { name: 'UI bong', asset: assets.soundUiBong }, + { name: 'Light impact', asset: assets.soundImpactLight }, + { name: 'Heavy impact', asset: assets.soundImpactHeavy }, +]; class PlaySoundScene extends Scene { sounds; text; @@ -11,10 +18,7 @@ class PlaySoundScene extends Scene { const { width, height } = app; // Keep example SFX comfortable - full volume is jarring in the docs. app.audio.sound.volume = 0.5; - // Path-only get() infers Sound from the .ogg extension - sidesteps a - // compile-time overload ambiguity between Sound and the Json token form - // when passing the Sound token explicitly. - this.sounds = SOUND_KEYS.map(key => this.loader.get(assets.demo.audio[key])); + this.sounds = SOUNDS.map(sound => this.loader.get(sound.asset)); this.text = new Text('Click anywhere to play SFX', { fillColor: Color.white, fontSize: 24, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height / 2); @@ -23,7 +27,7 @@ class PlaySoundScene extends Scene { const sound = this.sounds[this.index]; this.index = (this.index + 1) % this.sounds.length; app.audio.play(sound); - this.text.text = `Playing: ${SOUND_KEYS[(this.index + this.sounds.length - 1) % this.sounds.length]}`; + this.text.text = `Playing: ${SOUNDS[(this.index + this.sounds.length - 1) % this.sounds.length].name}`; }); } draw(context) { diff --git a/examples/audio-basics/play-sound.ts b/examples/audio-basics/play-sound.ts index 74336530d..49d11cd3b 100644 --- a/examples/audio-basics/play-sound.ts +++ b/examples/audio-basics/play-sound.ts @@ -1,7 +1,14 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A small pool of different UI sounds so repeated taps stay interesting. -const SOUND_KEYS = ['uiClick', 'uiConfirm', 'uiBong', 'impactLight', 'impactHeavy'] as const; +const SOUNDS = [ + { name: 'UI click', asset: assets.soundUiClick }, + { name: 'UI confirm', asset: assets.soundUiConfirm }, + { name: 'UI bong', asset: assets.soundUiBong }, + { name: 'Light impact', asset: assets.soundImpactLight }, + { name: 'Heavy impact', asset: assets.soundImpactHeavy }, +]; class PlaySoundScene extends Scene { private sounds!: Sound[]; @@ -15,10 +22,7 @@ class PlaySoundScene extends Scene { // Keep example SFX comfortable - full volume is jarring in the docs. app.audio.sound.volume = 0.5; - // Path-only get() infers Sound from the .ogg extension - sidesteps a - // compile-time overload ambiguity between Sound and the Json token form - // when passing the Sound token explicitly. - this.sounds = SOUND_KEYS.map(key => this.loader.get(assets.demo.audio[key])); + this.sounds = SOUNDS.map(sound => this.loader.get(sound.asset)); this.text = new Text('Click anywhere to play SFX', { fillColor: Color.white, fontSize: 24, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height / 2); @@ -27,7 +31,7 @@ class PlaySoundScene extends Scene { const sound = this.sounds[this.index]; this.index = (this.index + 1) % this.sounds.length; app.audio.play(sound); - this.text.text = `Playing: ${SOUND_KEYS[(this.index + this.sounds.length - 1) % this.sounds.length]}`; + this.text.text = `Playing: ${SOUNDS[(this.index + this.sounds.length - 1) % this.sounds.length]!.name}`; }); } diff --git a/examples/audio-basics/random-pitch-pool.js b/examples/audio-basics/random-pitch-pool.js index 88a79ade6..b577cd1e7 100644 --- a/examples/audio-basics/random-pitch-pool.js +++ b/examples/audio-basics/random-pitch-pool.js @@ -1,5 +1,6 @@ // Auto-generated from random-pitch-pool.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const DETUNE_RANGE = 200; // ± cents const FIRE_INTERVAL = 0.08; @@ -27,7 +28,7 @@ class RandomPitchPoolScene extends Scene { // Path-only get() infers Sound from the .ogg extension - sidesteps a // compile-time overload ambiguity between Sound and the Json token form // when passing the Sound token explicitly. - this.sound = this.loader.get(assets.demo.audio.impactLight); + this.sound = this.loader.get(assets.soundImpactLight); this.sound.poolSize = 20; this.graphics = new Graphics(); this.label = new Text('Hold Space to retrigger with a random pitch', { fillColor: Color.white, fontSize: 22, align: 'center' }) diff --git a/examples/audio-basics/random-pitch-pool.ts b/examples/audio-basics/random-pitch-pool.ts index 4e556caa8..2f41371b9 100644 --- a/examples/audio-basics/random-pitch-pool.ts +++ b/examples/audio-basics/random-pitch-pool.ts @@ -10,6 +10,7 @@ import { type Sound, Text, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const DETUNE_RANGE = 200; // ± cents @@ -42,7 +43,7 @@ class RandomPitchPoolScene extends Scene { // Path-only get() infers Sound from the .ogg extension - sidesteps a // compile-time overload ambiguity between Sound and the Json token form // when passing the Sound token explicitly. - this.sound = this.loader.get(assets.demo.audio.impactLight); + this.sound = this.loader.get(assets.soundImpactLight); this.sound.poolSize = 20; this.graphics = new Graphics(); diff --git a/examples/audio-basics/sound-pool.js b/examples/audio-basics/sound-pool.js index f932bc458..029b5f446 100644 --- a/examples/audio-basics/sound-pool.js +++ b/examples/audio-basics/sound-pool.js @@ -1,6 +1,6 @@ // Auto-generated from sound-pool.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const POOL_SIZE = 12; const FIRE_INTERVAL = 0.04; class SoundPoolScene extends Scene { @@ -18,19 +18,19 @@ class SoundPoolScene extends Scene { clock = 0; evictions = 0; lastDetune = 0; - hud; init() { const app = this.app; const { width, height } = app; // impactHeavy is long enough (~0.5 s) that rapid fire actually overlaps - // a short click ends before the next shot and the pool never fills. - // Path-only get() infers Sound from the .ogg extension - sidesteps a - // compile-time overload ambiguity between Sound and the Json token form - // when passing the Sound token explicitly. - this.sound = this.loader.get(assets.demo.audio.impactHeavy); + this.sound = this.loader.get(assets.soundImpactHeavy); this.sound.poolSize = POOL_SIZE; this.graphics = new Graphics(); - this.label = new Text('Hold Space to fire faster than voices finish', { fillColor: Color.white, fontSize: 22, align: 'center' }) + this.label = new Text('Hold Space, a mouse button or a finger to fire faster than voices finish', { + fillColor: Color.white, + fontSize: 22, + align: 'center', + }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height * 0.22); this.readout = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' }) @@ -42,21 +42,15 @@ class SoundPoolScene extends Scene { 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.hud = mountControls({ - 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} voices. Each shot varies pitch; at capacity, the oldest voice is evicted.`, - }); this.inputs.onActive(Keyboard.Space, () => { this.firing = true; }); this.inputs.onStop(Keyboard.Space, () => { this.firing = false; }); + // Holding a pointer or a finger fires as well, so touch devices can fill the pool. + app.input.onPointerDown.add(() => (this.firing = true)); + app.input.onPointerUp.add(() => (this.firing = false)); } spawnVoice() { const app = this.app; @@ -71,7 +65,8 @@ class SoundPoolScene extends Scene { 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 }); + // Up to POOL_SIZE impacts overlap, so each one is quiet on its own. + app.audio.play(this.sound, { playbackRate, volume: 0.3 }); } update(delta) { const app = this.app; @@ -120,10 +115,6 @@ 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 6ccd0fdd8..3b81e4c44 100644 --- a/examples/audio-basics/sound-pool.ts +++ b/examples/audio-basics/sound-pool.ts @@ -10,7 +10,7 @@ import { type Sound, Text, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const POOL_SIZE = 12; const FIRE_INTERVAL = 0.04; @@ -30,7 +30,6 @@ class SoundPoolScene extends Scene { private clock = 0; private evictions = 0; private lastDetune = 0; - private hud!: ReturnType; override init(): void { const app = this.app; @@ -38,14 +37,15 @@ class SoundPoolScene extends Scene { // impactHeavy is long enough (~0.5 s) that rapid fire actually overlaps - // a short click ends before the next shot and the pool never fills. - // Path-only get() infers Sound from the .ogg extension - sidesteps a - // compile-time overload ambiguity between Sound and the Json token form - // when passing the Sound token explicitly. - this.sound = this.loader.get(assets.demo.audio.impactHeavy); + this.sound = this.loader.get(assets.soundImpactHeavy); this.sound.poolSize = POOL_SIZE; this.graphics = new Graphics(); - this.label = new Text('Hold Space to fire faster than voices finish', { fillColor: Color.white, fontSize: 22, align: 'center' }) + this.label = new Text('Hold Space, a mouse button or a finger to fire faster than voices finish', { + fillColor: Color.white, + fontSize: 22, + align: 'center', + }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height * 0.22); this.readout = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' }) @@ -59,22 +59,15 @@ class SoundPoolScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - 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} voices. Each shot varies pitch; at capacity, the oldest voice is evicted.`, - }); - this.inputs.onActive(Keyboard.Space, () => { this.firing = true; }); this.inputs.onStop(Keyboard.Space, () => { this.firing = false; }); + // Holding a pointer or a finger fires as well, so touch devices can fill the pool. + app.input.onPointerDown.add(() => (this.firing = true)); + app.input.onPointerUp.add(() => (this.firing = false)); } private spawnVoice(): void { @@ -92,7 +85,8 @@ class SoundPoolScene extends Scene { 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 }); + // Up to POOL_SIZE impacts overlap, so each one is quiet on its own. + app.audio.play(this.sound, { playbackRate, volume: 0.3 }); } override update(delta: Seconds): void { @@ -154,11 +148,6 @@ 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 bead0425f..00090dbda 100644 --- a/examples/audio-fx/compressor.js +++ b/examples/audio-fx/compressor.js @@ -1,18 +1,17 @@ // 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 { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { CompressorEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, slider, toggle } from '@examples/runtime'; class CompressorScene extends Scene { music; voice; compressor; meter = new Graphics(); label; - panel; - hud; bypass = false; async load() { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.music = await this.loader.load(assets.musicLoopMain); } init() { const audio = this.app.audio; @@ -21,55 +20,36 @@ class CompressorScene extends Scene { 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({ + // The loud input is hot enough to push the peaks well over the threshold + // without being louder than the rest of the catalog, and it ramps rather + // than jumps so switching never spikes. + demoPanel({ 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.', - }); - 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.'); - }, - }); - this.panel.addSlider({ - label: 'Threshold (dB)', - min: -50, - max: -5, - step: 1, - value: -30, - onChange: value => { - this.compressor.threshold = value; + controls: { + input: choice('Input', ['Quiet', 'Loud']), + bypass: toggle('Bypass'), + threshold: slider('Threshold (dB)', { min: -50, max: -5, step: 1, value: -30, format: v => v.toFixed(0) }), + ratio: slider('Ratio', { min: 1, max: 12, step: 0.5, value: 6, format: v => `${v.toFixed(1)}:1` }), }, - }); - this.panel.addSlider({ - label: 'Ratio', - min: 1, - max: 12, - step: 0.5, - value: 6, - onChange: value => { - this.compressor.ratio = value; + onChange: (key, _value, values) => { + if (key === 'input') { + this.tweens + .create(this.voice) + .to({ volume: values.input === 'Loud' ? 0.6 : 0.15 }, 0.4) + .start(); + } else if (key === 'bypass' && values.bypass !== this.bypass) { + this.bypass = values.bypass; + if (this.bypass) { + audio.music.removeEffect(this.compressor); + } else { + audio.music.addEffect(this.compressor); + } + } + this.compressor.threshold = values.threshold; + this.compressor.ratio = values.ratio; }, }); } - setInput(volume) { - this.voice.volume = volume; - this.hud.setStatus(`${volume === 1 ? 'Loud' : 'Quiet'} input; compressor ${this.bypass ? 'bypassed' : 'active'}.`); - } draw(context) { const reduction = this.bypass ? 0 : this.compressor.reduction; this.meter.clear(); @@ -87,8 +67,6 @@ class CompressorScene extends Scene { this.app.audio.music.removeEffect(this.compressor); } this.compressor?.destroy(); - this.panel?.dispose(); - this.hud?.dispose(); this.meter.destroy(); this.label?.destroy(); super.destroy(); @@ -98,6 +76,5 @@ const app = new Application({ scenes: { CompressorScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(CompressorScene); diff --git a/examples/audio-fx/compressor.ts b/examples/audio-fx/compressor.ts index ce1fd4d38..9c3dbbb0b 100644 --- a/examples/audio-fx/compressor.ts +++ b/examples/audio-fx/compressor.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -10,8 +9,9 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { CompressorEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { CompressorEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, slider, toggle } from '@examples/runtime'; class CompressorScene extends Scene { private music!: AudioStream; @@ -19,12 +19,10 @@ class CompressorScene extends Scene { private compressor!: CompressorEffect; private meter = new Graphics(); private label!: Text; - private panel!: ReturnType; - private hud!: ReturnType; private bypass = false; override async load(): Promise { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.music = await this.loader.load(assets.musicLoopMain); } override init(): void { @@ -35,60 +33,39 @@ class CompressorScene extends Scene { this.label = new Text('', { fillColor: Color.white, fontSize: 24 }); this.label.setPosition(380, 280); - this.hud = mountControls({ + // The loud input is hot enough to push the peaks well over the threshold + // without being louder than the rest of the catalog, and it ramps rather + // than jumps so switching never spikes. + demoPanel({ 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.', - }); - 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; + controls: { + input: choice('Input', ['Quiet', 'Loud']), + bypass: toggle('Bypass'), + threshold: slider('Threshold (dB)', { min: -50, max: -5, step: 1, value: -30, format: v => v.toFixed(0) }), + ratio: slider('Ratio', { min: 1, max: 12, step: 0.5, value: 6, format: v => `${v.toFixed(1)}:1` }), + }, + onChange: (key, _value, values) => { + if (key === 'input') { + this.tweens + .create(this.voice) + .to({ volume: values.input === 'Loud' ? 0.6 : 0.15 }, 0.4) + .start(); + } else if (key === 'bypass' && values.bypass !== this.bypass) { + this.bypass = values.bypass; - if (value) { - audio.music.removeEffect(this.compressor); - } else { - audio.music.addEffect(this.compressor); + if (this.bypass) { + audio.music.removeEffect(this.compressor); + } else { + audio.music.addEffect(this.compressor); + } } - this.hud.setStatus(value ? 'Compressor bypassed.' : 'Compressor active. Compare Quiet and Loud input.'); - }, - }); - this.panel.addSlider({ - label: 'Threshold (dB)', - min: -50, - max: -5, - step: 1, - value: -30, - onChange: value => { - this.compressor.threshold = value; - }, - }); - this.panel.addSlider({ - label: 'Ratio', - min: 1, - max: 12, - step: 0.5, - value: 6, - onChange: value => { - this.compressor.ratio = value; + this.compressor.threshold = values.threshold; + this.compressor.ratio = values.ratio; }, }); } - 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 reduction = this.bypass ? 0 : this.compressor.reduction; this.meter.clear(); @@ -109,8 +86,6 @@ class CompressorScene extends Scene { } this.compressor?.destroy(); - this.panel?.dispose(); - this.hud?.dispose(); this.meter.destroy(); this.label?.destroy(); super.destroy(); @@ -121,7 +96,6 @@ const app = new Application({ scenes: { CompressorScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(CompressorScene); diff --git a/examples/audio-fx/convolution-rooms.js b/examples/audio-fx/convolution-rooms.js index adccf42b9..00b84567a 100644 --- a/examples/audio-fx/convolution-rooms.js +++ b/examples/audio-fx/convolution-rooms.js @@ -1,43 +1,45 @@ // Auto-generated from convolution-rooms.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls, trackPressButtons } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Text } from '@codexo/exojs'; +import { ConvolutionEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, trackPressButtons } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only // colours the sound, ~30-150 ms reads as a cupboard or a shaft, and past // ~250 ms you start hearing a room you could walk around in. const ROOMS = [ - { file: 'AK-SROOMS_014', ms: 1 }, - { file: 'AK-SROOMS_013', ms: 2 }, - { file: 'AK-SROOMS_023', ms: 3 }, - { file: 'AK-SROOMS_012', ms: 6 }, - { file: 'AK-SROOMS_011', ms: 11 }, - { file: 'AK-SROOMS_022', ms: 15 }, - { file: 'AK-SROOMS_002', ms: 20 }, - { file: 'AK-SROOMS_003', ms: 20 }, - { file: 'AK-SROOMS_010', ms: 28 }, - { file: 'AK-SROOMS_021', ms: 33 }, - { file: 'AK-SROOMS_009', ms: 48 }, - { file: 'AK-SROOMS_019', ms: 51 }, - { file: 'AK-SROOMS_020', ms: 95 }, - { file: 'AK-SROOMS_006', ms: 133 }, - { file: 'AK-SROOMS_018', ms: 156 }, - { file: 'AK-SROOMS_015', ms: 235 }, - { file: 'AK-SROOMS_001', ms: 236 }, - { file: 'AK-SROOMS_024', ms: 226 }, - { file: 'AK-SROOMS_026', ms: 226 }, - { file: 'AK-SROOMS_027', ms: 226 }, - { file: 'AK-SROOMS_025', ms: 229 }, - { file: 'AK-SROOMS_030', ms: 267 }, - { file: 'AK-SROOMS_004', ms: 307 }, - { file: 'AK-SROOMS_005', ms: 307 }, - { file: 'AK-SROOMS_029', ms: 308 }, - { file: 'AK-SROOMS_007', ms: 350 }, - { file: 'AK-SROOMS_008', ms: 350 }, - { file: 'AK-SROOMS_028', ms: 353 }, - { file: 'AK-SROOMS_017', ms: 550 }, - { file: 'AK-SROOMS_016', ms: 593 }, + { file: 'AK-SROOMS_014', ir: assets.impulseRoom014, ms: 1 }, + { file: 'AK-SROOMS_013', ir: assets.impulseRoom013, ms: 2 }, + { file: 'AK-SROOMS_023', ir: assets.impulseRoom023, ms: 3 }, + { file: 'AK-SROOMS_012', ir: assets.impulseRoom012, ms: 6 }, + { file: 'AK-SROOMS_011', ir: assets.impulseRoom011, ms: 11 }, + { file: 'AK-SROOMS_022', ir: assets.impulseRoom022, ms: 15 }, + { file: 'AK-SROOMS_002', ir: assets.impulseRoom002, ms: 20 }, + { file: 'AK-SROOMS_003', ir: assets.impulseRoom003, ms: 20 }, + { file: 'AK-SROOMS_010', ir: assets.impulseRoom010, ms: 28 }, + { file: 'AK-SROOMS_021', ir: assets.impulseRoom021, ms: 33 }, + { file: 'AK-SROOMS_009', ir: assets.impulseRoom009, ms: 48 }, + { file: 'AK-SROOMS_019', ir: assets.impulseRoom019, ms: 51 }, + { file: 'AK-SROOMS_020', ir: assets.impulseRoom020, ms: 95 }, + { file: 'AK-SROOMS_006', ir: assets.impulseRoom006, ms: 133 }, + { file: 'AK-SROOMS_018', ir: assets.impulseRoom018, ms: 156 }, + { file: 'AK-SROOMS_015', ir: assets.impulseRoom015, ms: 235 }, + { file: 'AK-SROOMS_001', ir: assets.impulseRoom001, ms: 236 }, + { file: 'AK-SROOMS_024', ir: assets.impulseRoom024, ms: 226 }, + { file: 'AK-SROOMS_026', ir: assets.impulseRoom026, ms: 226 }, + { file: 'AK-SROOMS_027', ir: assets.impulseRoom027, ms: 226 }, + { file: 'AK-SROOMS_025', ir: assets.impulseRoom025, ms: 229 }, + { file: 'AK-SROOMS_030', ir: assets.impulseRoom030, ms: 267 }, + { file: 'AK-SROOMS_004', ir: assets.impulseRoom004, ms: 307 }, + { file: 'AK-SROOMS_005', ir: assets.impulseRoom005, ms: 307 }, + { file: 'AK-SROOMS_029', ir: assets.impulseRoom029, ms: 308 }, + { file: 'AK-SROOMS_007', ir: assets.impulseRoom007, ms: 350 }, + { file: 'AK-SROOMS_008', ir: assets.impulseRoom008, ms: 350 }, + { file: 'AK-SROOMS_028', ir: assets.impulseRoom028, ms: 353 }, + { file: 'AK-SROOMS_017', ir: assets.impulseRoom017, ms: 550 }, + { file: 'AK-SROOMS_016', ir: assets.impulseRoom016, ms: 593 }, ]; +const ROOM_LABELS = ROOMS.map(room => `${room.ms} ms (IR ${room.file.slice(-3)})`); /** Rough label for a tail length - the same intuition a level designer works with. */ const character = ms => { if (ms < 10) { @@ -61,10 +63,10 @@ const character = ms => { // stereo position matches where it visually sits - the same left/right // spread the ear hears back through the room. const MATERIALS = [ - { file: 'impactBell_heavy_000', label: 'Bell', color: new Color(210, 180, 100) }, - { file: 'impactWood_heavy_000', label: 'Wood', color: new Color(160, 120, 80) }, - { file: 'impactGlass_heavy_000', label: 'Glass', color: new Color(130, 210, 230) }, - { file: 'impactMetal_heavy_000', label: 'Metal', color: new Color(160, 180, 200) }, + { sound: assets.soundImpactBellHeavy, label: 'Bell', color: new Color(210, 180, 100) }, + { sound: assets.soundImpactWoodHeavy, label: 'Wood', color: new Color(160, 120, 80) }, + { sound: assets.soundImpactGlassHeavy, label: 'Glass', color: new Color(130, 210, 230) }, + { sound: assets.soundImpactMetalHeavy, label: 'Metal', color: new Color(160, 180, 200) }, ]; const REF_DISTANCE = 60; const MAX_DISTANCE = 700; @@ -77,10 +79,8 @@ class ConvolutionRoomsScene extends Scene { label; detail; tapPrompt; - hud; panel; pressButtons = null; - roomControl; init() { const { width, height } = this.app; this.listener = { x: width / 2, y: height / 2 }; @@ -96,7 +96,7 @@ class ConvolutionRoomsScene extends Scene { const startX = (width - totalW) / 2; const padY = height / 2 - padH / 2 + 20; this.pads = MATERIALS.map((material, i) => ({ - sound: this.loader.get(`audio/${material.file}.ogg`), + sound: this.loader.get(material.sound), label: material.label, color: material.color, x: startX + i * (padW + gap), @@ -119,30 +119,19 @@ class ConvolutionRoomsScene extends Scene { this.pressButtons = trackPressButtons(this.app.canvas); } this.app.input.onPointerDown.add(this.onPointerDown); - this.app.input.onContextMenu.add(this.onContextMenu); - this.hud = mountControls({ - title: 'Convolution rooms', - controls: [ - { keys: 'Click', action: 'strike a pad, panned to its position' }, - { keys: 'Right-click', action: 'next room (or use the panel)' }, - ], - status: 'Click or press any key to start…', - hint: 'Same four materials every time — only the room around them changes.', + this.panel = demoPanel({ + controls: { room: choice('Room', ROOM_LABELS) }, + onChange: (_key, value) => this.select(ROOM_LABELS.indexOf(value)), }); - this.panel = mountControlPanel({ title: 'Room' }); - this.roomControl = this.panel.addCycle({ - label: 'Impulse', - options: ROOMS.map((room, i) => `${i + 1} · ${room.ms} ms`), - onChange: index => this.select(index), - }); - this.select(0); + this.inputs.onTrigger(Keyboard.Right, () => this.panel.step('room', 1)); + this.inputs.onTrigger(Keyboard.Left, () => this.panel.step('room', -1)); } - // Application input outlives the scene, so both handlers are removed in destroy(). + // Application input outlives the scene, so the handler is removed in destroy(). onPointerDown = (pointer, x, y) => { // Presses are delivered at the next frame, when `pointer.buttons` already // reads the live state, so a right click released before then looks like a - // main click. The button recorded at press time decides: right-click - // switches rooms instead of striking. + // main click. The button recorded at press time decides: only the main + // button strikes. if ((this.pressButtons?.take(pointer.id) ?? 0) !== 0) { return; } @@ -153,15 +142,13 @@ class ConvolutionRoomsScene extends Scene { } } }; - onContextMenu = () => this.select(this.index + 1); /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ select(next) { - this.index = (next + ROOMS.length) % ROOMS.length; - this.roomControl.set(this.index); + this.index = next; const room = ROOMS[this.index]; - const ir = this.loader.get(`audio/ir/${room.file}.wav`); - this.label.text = `${this.index + 1} / ${ROOMS.length} — ${character(room.ms)}`; - this.detail.text = `${room.file} · ${room.ms} ms tail`; + const ir = this.loader.get(room.ir); + this.label.text = `${this.index + 1} / ${ROOMS.length}: ${character(room.ms)}`; + this.detail.text = `${room.file}, ${room.ms} ms tail`; // `.loaded` resolves immediately for an already-decoded handle; the // effect stays on the previous IR until the new one is ready. void ir.loaded.then(() => { @@ -188,7 +175,6 @@ class ConvolutionRoomsScene extends Scene { pad.flash = Math.max(0, pad.flash - time * 3); } this.tapPrompt.visible = this.app.audio.locked; - this.hud.setStatus(this.app.audio.locked ? 'Click or press any key to start…' : `Room ${this.index + 1} of ${ROOMS.length}`); } draw(context) { this.gfx.clear(); @@ -202,12 +188,10 @@ class ConvolutionRoomsScene extends Scene { } destroy() { this.app.input.onPointerDown.remove(this.onPointerDown); - this.app.input.onContextMenu.remove(this.onContextMenu); this.pressButtons?.dispose(); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); - this.hud.dispose(); super.destroy(); } } @@ -219,8 +203,5 @@ const application = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); void application.start(ConvolutionRoomsScene); diff --git a/examples/audio-fx/convolution-rooms.ts b/examples/audio-fx/convolution-rooms.ts index 2638a89e7..ef938aa40 100644 --- a/examples/audio-fx/convolution-rooms.ts +++ b/examples/audio-fx/convolution-rooms.ts @@ -3,6 +3,7 @@ import { Color, FixedResolutionCanvasSizing, Graphics, + Keyboard, type Pointer, type RenderingContext, Scene, @@ -10,46 +11,49 @@ import { type Sound, Text, } from '@codexo/exojs'; -import { ConvolutionEffect } from '@codexo/exojs-audio-fx'; -import { type ControlBinding, mountControlPanel, mountControls, type PressButtonTracker, trackPressButtons } from '@examples/runtime'; +import { ConvolutionEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, type PressButtonTracker, trackPressButtons } from '@examples/runtime'; // Every impulse response in the AK-SROOMS set, shortest tail first. The // duration is what actually decides the character: a few milliseconds only // colours the sound, ~30-150 ms reads as a cupboard or a shaft, and past // ~250 ms you start hearing a room you could walk around in. const ROOMS = [ - { file: 'AK-SROOMS_014', ms: 1 }, - { file: 'AK-SROOMS_013', ms: 2 }, - { file: 'AK-SROOMS_023', ms: 3 }, - { file: 'AK-SROOMS_012', ms: 6 }, - { file: 'AK-SROOMS_011', ms: 11 }, - { file: 'AK-SROOMS_022', ms: 15 }, - { file: 'AK-SROOMS_002', ms: 20 }, - { file: 'AK-SROOMS_003', ms: 20 }, - { file: 'AK-SROOMS_010', ms: 28 }, - { file: 'AK-SROOMS_021', ms: 33 }, - { file: 'AK-SROOMS_009', ms: 48 }, - { file: 'AK-SROOMS_019', ms: 51 }, - { file: 'AK-SROOMS_020', ms: 95 }, - { file: 'AK-SROOMS_006', ms: 133 }, - { file: 'AK-SROOMS_018', ms: 156 }, - { file: 'AK-SROOMS_015', ms: 235 }, - { file: 'AK-SROOMS_001', ms: 236 }, - { file: 'AK-SROOMS_024', ms: 226 }, - { file: 'AK-SROOMS_026', ms: 226 }, - { file: 'AK-SROOMS_027', ms: 226 }, - { file: 'AK-SROOMS_025', ms: 229 }, - { file: 'AK-SROOMS_030', ms: 267 }, - { file: 'AK-SROOMS_004', ms: 307 }, - { file: 'AK-SROOMS_005', ms: 307 }, - { file: 'AK-SROOMS_029', ms: 308 }, - { file: 'AK-SROOMS_007', ms: 350 }, - { file: 'AK-SROOMS_008', ms: 350 }, - { file: 'AK-SROOMS_028', ms: 353 }, - { file: 'AK-SROOMS_017', ms: 550 }, - { file: 'AK-SROOMS_016', ms: 593 }, + { file: 'AK-SROOMS_014', ir: assets.impulseRoom014, ms: 1 }, + { file: 'AK-SROOMS_013', ir: assets.impulseRoom013, ms: 2 }, + { file: 'AK-SROOMS_023', ir: assets.impulseRoom023, ms: 3 }, + { file: 'AK-SROOMS_012', ir: assets.impulseRoom012, ms: 6 }, + { file: 'AK-SROOMS_011', ir: assets.impulseRoom011, ms: 11 }, + { file: 'AK-SROOMS_022', ir: assets.impulseRoom022, ms: 15 }, + { file: 'AK-SROOMS_002', ir: assets.impulseRoom002, ms: 20 }, + { file: 'AK-SROOMS_003', ir: assets.impulseRoom003, ms: 20 }, + { file: 'AK-SROOMS_010', ir: assets.impulseRoom010, ms: 28 }, + { file: 'AK-SROOMS_021', ir: assets.impulseRoom021, ms: 33 }, + { file: 'AK-SROOMS_009', ir: assets.impulseRoom009, ms: 48 }, + { file: 'AK-SROOMS_019', ir: assets.impulseRoom019, ms: 51 }, + { file: 'AK-SROOMS_020', ir: assets.impulseRoom020, ms: 95 }, + { file: 'AK-SROOMS_006', ir: assets.impulseRoom006, ms: 133 }, + { file: 'AK-SROOMS_018', ir: assets.impulseRoom018, ms: 156 }, + { file: 'AK-SROOMS_015', ir: assets.impulseRoom015, ms: 235 }, + { file: 'AK-SROOMS_001', ir: assets.impulseRoom001, ms: 236 }, + { file: 'AK-SROOMS_024', ir: assets.impulseRoom024, ms: 226 }, + { file: 'AK-SROOMS_026', ir: assets.impulseRoom026, ms: 226 }, + { file: 'AK-SROOMS_027', ir: assets.impulseRoom027, ms: 226 }, + { file: 'AK-SROOMS_025', ir: assets.impulseRoom025, ms: 229 }, + { file: 'AK-SROOMS_030', ir: assets.impulseRoom030, ms: 267 }, + { file: 'AK-SROOMS_004', ir: assets.impulseRoom004, ms: 307 }, + { file: 'AK-SROOMS_005', ir: assets.impulseRoom005, ms: 307 }, + { file: 'AK-SROOMS_029', ir: assets.impulseRoom029, ms: 308 }, + { file: 'AK-SROOMS_007', ir: assets.impulseRoom007, ms: 350 }, + { file: 'AK-SROOMS_008', ir: assets.impulseRoom008, ms: 350 }, + { file: 'AK-SROOMS_028', ir: assets.impulseRoom028, ms: 353 }, + { file: 'AK-SROOMS_017', ir: assets.impulseRoom017, ms: 550 }, + { file: 'AK-SROOMS_016', ir: assets.impulseRoom016, ms: 593 }, ] as const; +const ROOM_LABELS = ROOMS.map(room => `${room.ms} ms (IR ${room.file.slice(-3)})`); + /** Rough label for a tail length - the same intuition a level designer works with. */ const character = (ms: number): string => { if (ms < 10) { @@ -79,10 +83,10 @@ const character = (ms: number): string => { // stereo position matches where it visually sits - the same left/right // spread the ear hears back through the room. const MATERIALS = [ - { file: 'impactBell_heavy_000', label: 'Bell', color: new Color(210, 180, 100) }, - { file: 'impactWood_heavy_000', label: 'Wood', color: new Color(160, 120, 80) }, - { file: 'impactGlass_heavy_000', label: 'Glass', color: new Color(130, 210, 230) }, - { file: 'impactMetal_heavy_000', label: 'Metal', color: new Color(160, 180, 200) }, + { sound: assets.soundImpactBellHeavy, label: 'Bell', color: new Color(210, 180, 100) }, + { sound: assets.soundImpactWoodHeavy, label: 'Wood', color: new Color(160, 120, 80) }, + { sound: assets.soundImpactGlassHeavy, label: 'Glass', color: new Color(130, 210, 230) }, + { sound: assets.soundImpactMetalHeavy, label: 'Metal', color: new Color(160, 180, 200) }, ] as const; const REF_DISTANCE = 60; @@ -108,10 +112,8 @@ class ConvolutionRoomsScene extends Scene { private label!: Text; private detail!: Text; private tapPrompt!: Text; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: DemoPanel<{ room: ChoiceSpec }>; private pressButtons: PressButtonTracker | null = null; - private roomControl!: ControlBinding; override init(): void { const { width, height } = this.app; @@ -132,7 +134,7 @@ class ConvolutionRoomsScene extends Scene { const padY = height / 2 - padH / 2 + 20; this.pads = MATERIALS.map((material, i) => ({ - sound: this.loader.get(`audio/${material.file}.ogg`), + sound: this.loader.get(material.sound), label: material.label, color: material.color, x: startX + i * (padW + gap), @@ -160,34 +162,21 @@ class ConvolutionRoomsScene extends Scene { } this.app.input.onPointerDown.add(this.onPointerDown); - this.app.input.onContextMenu.add(this.onContextMenu); - - this.hud = mountControls({ - title: 'Convolution rooms', - controls: [ - { keys: 'Click', action: 'strike a pad, panned to its position' }, - { keys: 'Right-click', action: 'next room (or use the panel)' }, - ], - status: 'Click or press any key to start…', - hint: 'Same four materials every time — only the room around them changes.', - }); - this.panel = mountControlPanel({ title: 'Room' }); - this.roomControl = this.panel.addCycle({ - label: 'Impulse', - options: ROOMS.map((room, i) => `${i + 1} · ${room.ms} ms`), - onChange: index => this.select(index), + this.panel = demoPanel({ + controls: { room: choice('Room', ROOM_LABELS) }, + onChange: (_key, value) => this.select(ROOM_LABELS.indexOf(value)), }); - - this.select(0); + this.inputs.onTrigger(Keyboard.Right, () => this.panel.step('room', 1)); + this.inputs.onTrigger(Keyboard.Left, () => this.panel.step('room', -1)); } - // Application input outlives the scene, so both handlers are removed in destroy(). + // Application input outlives the scene, so the handler is removed in destroy(). private readonly onPointerDown = (pointer: Pointer, x: number, y: number): void => { // Presses are delivered at the next frame, when `pointer.buttons` already // reads the live state, so a right click released before then looks like a - // main click. The button recorded at press time decides: right-click - // switches rooms instead of striking. + // main click. The button recorded at press time decides: only the main + // button strikes. if ((this.pressButtons?.take(pointer.id) ?? 0) !== 0) { return; } @@ -201,18 +190,15 @@ class ConvolutionRoomsScene extends Scene { } }; - private readonly onContextMenu = (): void => this.select(this.index + 1); - /** Swap the impulse response. The handle heals in place, so the effect picks it up once decoded. */ private select(next: number): void { - this.index = (next + ROOMS.length) % ROOMS.length; - this.roomControl.set(this.index); + this.index = next; const room = ROOMS[this.index]!; - const ir = this.loader.get(`audio/ir/${room.file}.wav`); + const ir = this.loader.get(room.ir); - this.label.text = `${this.index + 1} / ${ROOMS.length} — ${character(room.ms)}`; - this.detail.text = `${room.file} · ${room.ms} ms tail`; + this.label.text = `${this.index + 1} / ${ROOMS.length}: ${character(room.ms)}`; + this.detail.text = `${room.file}, ${room.ms} ms tail`; // `.loaded` resolves immediately for an already-decoded handle; the // effect stays on the previous IR until the new one is ready. @@ -244,7 +230,6 @@ class ConvolutionRoomsScene extends Scene { } this.tapPrompt.visible = this.app.audio.locked; - this.hud.setStatus(this.app.audio.locked ? 'Click or press any key to start…' : `Room ${this.index + 1} of ${ROOMS.length}`); } override draw(context: RenderingContext): void { @@ -263,12 +248,10 @@ class ConvolutionRoomsScene extends Scene { override destroy(): void { this.app.input.onPointerDown.remove(this.onPointerDown); - this.app.input.onContextMenu.remove(this.onContextMenu); this.pressButtons?.dispose(); this.app.audio.sound.removeEffect(this.convolution); this.convolution.destroy(); this.panel.dispose(); - this.hud.dispose(); super.destroy(); } } @@ -281,9 +264,6 @@ const application = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); void application.start(ConvolutionRoomsScene); diff --git a/examples/audio-fx/ducking.js b/examples/audio-fx/ducking.js index 8f6023423..1947dbc12 100644 --- a/examples/audio-fx/ducking.js +++ b/examples/audio-fx/ducking.js @@ -1,7 +1,7 @@ // Auto-generated from ducking.ts - edit the .ts source, not this file. -import { Application, Asset, AudioBus, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { AudioAnalyser, DuckingEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { Application, AudioBus, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, DuckingEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class DuckingScene extends Scene { music; voice; @@ -21,7 +21,14 @@ class DuckingScene extends Scene { barW = 0; musicBarY = 0; voiceBarY = 0; - hud; + clickPrompt; + playVoice = () => { + // 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) { + this.app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); + } + }; async load() { const app = this.app; const { width, height } = app; @@ -31,12 +38,12 @@ class DuckingScene extends Scene { this.musicBarY = height * 0.42; this.voiceBarY = height * 0.55; // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + const music = await this.loader.load(assets.musicLoopMain); this.music = music; // Path-only get() infers Sound from the .ogg extension - sidesteps a // compile-time overload ambiguity between Sound and the Json token form // when passing the Sound token explicitly. - this.voice = this.loader.get(assets.demo.voice.congratulations); + this.voice = this.loader.get(assets.voiceCongratulations); // Route the voice-over onto its own bus so it can drive the sidechain. this.voiceBus = new AudioBus('voice-over', { parent: app.audio.master }); app.audio.registerBus(this.voiceBus); @@ -56,28 +63,16 @@ class DuckingScene extends Scene { this.voiceLabel.setPosition(this.barX, this.voiceBarY - 26); // 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' }) + this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(width / 2, height - 48); + this.clickPrompt = new Text('Click to play the voice-over', { fillColor: Color.white, fontSize: 22, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Ducking', - controls: [{ keys: 'Click', action: 'play the voice-over' }], - status: 'Click or press any key to start…', - hint: 'When the voice plays, the music meter ducks down while the voice meter spikes.', - }); - app.input.onPointerTap.add(() => { - // The pointer gesture also unlocks the AudioContext; firing while - // still locked would be silent, so wait until audio is ready. - if (app.audio.locked) { - return; - } - app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); - this.hud.setStatus('Voice playing — music ducked'); - }); + app.input.onPointerTap.add(this.playVoice); // 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.hud.setStatus('Music playing — click to duck it'); + app.audio.play(this.music, { loop: true, volume: 0.5 }); } bar(label, name, y, level, color) { this.gfx.fillColor = new Color(70, 70, 70); @@ -100,9 +95,16 @@ class DuckingScene extends Scene { context.render(this.gfx); context.render(this.musicLabel); context.render(this.voiceLabel); - if (app.audio.locked) { - context.render(this.tapPrompt); - } + context.render(app.audio.locked ? this.tapPrompt : this.clickPrompt); + } + destroy() { + this.app.input.onPointerTap.remove(this.playVoice); + this.gfx?.destroy(); + this.musicLabel?.destroy(); + this.voiceLabel?.destroy(); + this.tapPrompt?.destroy(); + this.clickPrompt?.destroy(); + super.destroy(); } } const app = new Application({ diff --git a/examples/audio-fx/ducking.ts b/examples/audio-fx/ducking.ts index 73561808c..f9f1fcbd8 100644 --- a/examples/audio-fx/ducking.ts +++ b/examples/audio-fx/ducking.ts @@ -1,6 +1,5 @@ import { Application, - Asset, AudioBus, type AudioStream, Color, @@ -11,8 +10,8 @@ import { type Sound, Text, } from '@codexo/exojs'; -import { AudioAnalyser, DuckingEffect } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { AudioAnalyser, DuckingEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class DuckingScene extends Scene { private music!: AudioStream; @@ -33,7 +32,14 @@ class DuckingScene extends Scene { private barW = 0; private musicBarY = 0; private voiceBarY = 0; - private hud!: ReturnType; + private clickPrompt!: Text; + private readonly playVoice = (): 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) { + this.app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); + } + }; override async load(): Promise { const app = this.app; @@ -46,12 +52,12 @@ class DuckingScene extends Scene { this.voiceBarY = height * 0.55; // AudioStream has no seamless adapter - await it explicitly. - const music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + const music = await this.loader.load(assets.musicLoopMain); this.music = music; // Path-only get() infers Sound from the .ogg extension - sidesteps a // compile-time overload ambiguity between Sound and the Json token form // when passing the Sound token explicitly. - this.voice = this.loader.get(assets.demo.voice.congratulations); + this.voice = this.loader.get(assets.voiceCongratulations); // Route the voice-over onto its own bus so it can drive the sidechain. this.voiceBus = new AudioBus('voice-over', { parent: app.audio.master }); @@ -76,32 +82,19 @@ class DuckingScene extends Scene { // 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' }) + this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Ducking', - controls: [{ keys: 'Click', action: 'play the voice-over' }], - status: 'Click or press any key to start…', - hint: 'When the voice plays, the music meter ducks down while the voice meter spikes.', - }); - - app.input.onPointerTap.add(() => { - // The pointer gesture also unlocks the AudioContext; firing while - // still locked would be silent, so wait until audio is ready. - if (app.audio.locked) { - return; - } + this.clickPrompt = new Text('Click to play the voice-over', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0.5) + .setPosition(width / 2, height - 48); - app.audio.play(this.voice, { bus: this.voiceBus, volume: 0.4 }); - this.hud.setStatus('Voice playing — music ducked'); - }); + app.input.onPointerTap.add(this.playVoice); // 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.hud.setStatus('Music playing — click to duck it'); + app.audio.play(this.music, { loop: true, volume: 0.5 }); } private bar(label: Text, name: string, y: number, level: number, color: Color): void { @@ -128,9 +121,17 @@ class DuckingScene extends Scene { context.render(this.musicLabel); context.render(this.voiceLabel); - if (app.audio.locked) { - context.render(this.tapPrompt); - } + context.render(app.audio.locked ? this.tapPrompt : this.clickPrompt); + } + + override destroy(): void { + this.app.input.onPointerTap.remove(this.playVoice); + this.gfx?.destroy(); + this.musicLabel?.destroy(); + this.voiceLabel?.destroy(); + this.tapPrompt?.destroy(); + this.clickPrompt?.destroy(); + super.destroy(); } } diff --git a/examples/audio-fx/effect-chains.js b/examples/audio-fx/effect-chains.js index 17408f9ad..b6239067b 100644 --- a/examples/audio-fx/effect-chains.js +++ b/examples/audio-fx/effect-chains.js @@ -1,17 +1,8 @@ // Auto-generated from effect-chains.ts - edit the .ts source, not this file. -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'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, HighpassFilter, LowpassFilter, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, BitCrusherEffect, ChorusEffect, DistortionEffect, PingPongDelayEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; const CHAINS = [ { name: 'Telephone', @@ -59,17 +50,16 @@ class EffectChainsScene extends Scene { analyser; spectrum = new Uint8Array(16); active = []; - selected = 0; - bypass = false; generation = 0; rack = new Graphics(); title; + note; + prompt; stageLabels = []; panel; - hud; async load() { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impact = this.loader.get('audio/impact-light.ogg'); + this.music = await this.loader.load(assets.musicLoopMain); + this.impact = this.loader.get(assets.soundImpactLight); } init() { const audio = this.app.audio; @@ -78,37 +68,34 @@ class EffectChainsScene extends Scene { this.stageLabels = Array.from({ length: 3 }, () => new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }).setAnchor(0.5), ); - this.hud = mountControls({ - 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.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.note = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 20, align: 'center' }).setAnchor(0.5).setPosition(640, 215); + this.prompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(640, 670); + this.panel = demoPanel({ + title: 'Effect rack', + controls: { + chain: choice( + 'Chain', + CHAINS.map(chain => chain.name), + ), + bypass: toggle('Bypass', false), }, - }); - this.panel.addButton({ - label: 'Play impact', - onClick: () => { - if (!audio.locked) { - audio.play(this.impact, { volume: 0.4 }); + actions: { + 'Play impact': () => { + if (!audio.locked) { + audio.play(this.impact, { volume: 0.4 }); + } + }, + }, + onChange: () => { + // The mount-time calls arrive before the panel handle exists; init() builds the first chain once. + if (this.panel) { + this.rebuild(); } }, }); - this.musicVoice = audio.play(this.music, { loop: true, volume: 0.7 }); - this.rebuild(); - } - select(index) { - if (index === this.selected) { - return; - } - this.selected = index; + this.musicVoice = audio.play(this.music, { loop: true, volume: 0.5 }); this.rebuild(); } rebuild() { @@ -119,8 +106,9 @@ class EffectChainsScene extends Scene { effect.destroy(); } this.active = []; - const chain = CHAINS[this.selected]; - this.title.text = `${chain.name}: ${chain.stages.map(stage => stage.name).join(' -> ')}`; + const { chain: chainName, bypass } = this.panel.values; + const chain = CHAINS.find(candidate => candidate.name === chainName); + this.title.text = bypass ? `${chain.name} (bypassed)` : `${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; @@ -129,10 +117,10 @@ class EffectChainsScene extends Scene { label.setPosition(225 + (index + 0.5) * slotWidth - 6, 305); } }); - this.hud.setHint(chain.note); - master.volume = this.bypass ? 1 : chain.trim; - if (this.bypass) { - this.hud.setStatus(`${chain.name} bypassed; dry signal.`); + this.note.text = chain.note; + master.volume = bypass ? 1 : chain.trim; + if (bypass) { + this.panel.status('Dry signal'); return; } for (const stage of chain.stages) { @@ -140,16 +128,16 @@ class EffectChainsScene extends Scene { master.addEffect(effect); this.active.push(effect); } - this.hud.setStatus(`${chain.name}: preparing ${this.active.length} effects...`); + this.panel.status('Preparing 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.`); + this.panel.status(`${this.active.length} effects ready`); } }, error => { if (generation === this.generation) { - this.hud.setStatus(`${chain.name}: ${error instanceof Error ? error.message : String(error)}`); + this.panel.status(error instanceof Error ? error.message : String(error)); } }, ); @@ -157,10 +145,11 @@ class EffectChainsScene extends Scene { 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 { chain: chainName, bypass } = this.panel.values; + const stages = CHAINS.find(candidate => candidate.name === chainName).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.fillColor = 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++) { @@ -169,11 +158,15 @@ class EffectChainsScene extends Scene { } context.render(this.rack); context.render(this.title); + context.render(this.note); for (const label of this.stageLabels) { if (label.visible) { context.render(label); } } + if (this.app.audio.locked) { + context.render(this.prompt); + } } destroy() { ++this.generation; @@ -186,9 +179,10 @@ class EffectChainsScene extends Scene { this.app.audio.master.volume = 1; this.analyser?.destroy(); this.panel?.dispose(); - this.hud?.dispose(); this.rack.destroy(); this.title?.destroy(); + this.note?.destroy(); + this.prompt?.destroy(); for (const label of this.stageLabels) { label.destroy(); } @@ -199,6 +193,5 @@ const app = new Application({ scenes: { EffectChainsScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(EffectChainsScene); diff --git a/examples/audio-fx/effect-chains.ts b/examples/audio-fx/effect-chains.ts index 7fa4aba7d..1a8aed76d 100644 --- a/examples/audio-fx/effect-chains.ts +++ b/examples/audio-fx/effect-chains.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioEffect, type AudioStream, Color, @@ -14,8 +13,9 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser, BitCrusherEffect, ChorusEffect, DistortionEffect, PingPongDelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AudioAnalyser, BitCrusherEffect, ChorusEffect, DistortionEffect, PingPongDelayEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; interface Chain { name: string; @@ -75,18 +75,17 @@ class EffectChainsScene extends Scene { private analyser!: AudioAnalyser; private readonly spectrum = new Uint8Array(16); private active: AudioEffect[] = []; - private selected = 0; - private bypass = false; private generation = 0; private rack = new Graphics(); private title!: Text; + private note!: Text; + private prompt!: Text; private stageLabels: Text[] = []; - private panel!: ReturnType; - private hud!: ReturnType; + private panel!: DemoPanel<{ chain: ChoiceSpec; bypass: ToggleSpec }>; override async load(): Promise { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impact = this.loader.get('audio/impact-light.ogg'); + this.music = await this.loader.load(assets.musicLoopMain); + this.impact = this.loader.get(assets.soundImpactLight); } override init(): void { @@ -96,39 +95,34 @@ class EffectChainsScene extends Scene { this.stageLabels = Array.from({ length: 3 }, () => new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' }).setAnchor(0.5), ); - this.hud = mountControls({ - 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.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.note = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 20, align: 'center' }).setAnchor(0.5).setPosition(640, 215); + this.prompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(640, 670); + this.panel = demoPanel({ + title: 'Effect rack', + controls: { + chain: choice( + 'Chain', + CHAINS.map(chain => chain.name), + ), + bypass: toggle('Bypass', false), }, - }); - this.panel.addButton({ - label: 'Play impact', - onClick: () => { - if (!audio.locked) { - audio.play(this.impact, { volume: 0.4 }); + actions: { + 'Play impact': () => { + if (!audio.locked) { + audio.play(this.impact, { volume: 0.4 }); + } + }, + }, + onChange: () => { + // The mount-time calls arrive before the panel handle exists; init() builds the first chain once. + if (this.panel) { + this.rebuild(); } }, }); - this.musicVoice = audio.play(this.music, { loop: true, volume: 0.7 }); - this.rebuild(); - } - - private select(index: number): void { - if (index === this.selected) { - return; - } - - this.selected = index; + this.musicVoice = audio.play(this.music, { loop: true, volume: 0.5 }); this.rebuild(); } @@ -142,8 +136,9 @@ class EffectChainsScene extends Scene { } this.active = []; - const chain = CHAINS[this.selected]!; - this.title.text = `${chain.name}: ${chain.stages.map(stage => stage.name).join(' -> ')}`; + const { chain: chainName, bypass } = this.panel.values; + const chain = CHAINS.find(candidate => candidate.name === chainName)!; + this.title.text = bypass ? `${chain.name} (bypassed)` : `${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; @@ -153,12 +148,12 @@ class EffectChainsScene extends Scene { label.setPosition(225 + (index + 0.5) * slotWidth - 6, 305); } }); - this.hud.setHint(chain.note); + this.note.text = chain.note; - master.volume = this.bypass ? 1 : chain.trim; + master.volume = bypass ? 1 : chain.trim; - if (this.bypass) { - this.hud.setStatus(`${chain.name} bypassed; dry signal.`); + if (bypass) { + this.panel.status('Dry signal'); return; } @@ -169,16 +164,16 @@ class EffectChainsScene extends Scene { this.active.push(effect); } - this.hud.setStatus(`${chain.name}: preparing ${this.active.length} effects...`); + this.panel.status('Preparing 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.`); + this.panel.status(`${this.active.length} effects ready`); } }, error => { if (generation === this.generation) { - this.hud.setStatus(`${chain.name}: ${error instanceof Error ? error.message : String(error)}`); + this.panel.status(error instanceof Error ? error.message : String(error)); } }, ); @@ -187,10 +182,11 @@ class EffectChainsScene extends Scene { 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 { chain: chainName, bypass } = this.panel.values; + const stages = CHAINS.find(candidate => candidate.name === chainName)!.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.fillColor = bypass ? new Color(46, 49, 55) : new Color(53, 93, 121); this.rack.drawRoundedRectangle(225 + index * width, 260, width - 12, 90, 12); }); @@ -201,12 +197,17 @@ class EffectChainsScene extends Scene { context.render(this.rack); context.render(this.title); + context.render(this.note); for (const label of this.stageLabels) { if (label.visible) { context.render(label); } } + + if (this.app.audio.locked) { + context.render(this.prompt); + } } override destroy(): void { @@ -222,9 +223,10 @@ class EffectChainsScene extends Scene { this.app.audio.master.volume = 1; this.analyser?.destroy(); this.panel?.dispose(); - this.hud?.dispose(); this.rack.destroy(); this.title?.destroy(); + this.note?.destroy(); + this.prompt?.destroy(); for (const label of this.stageLabels) { label.destroy(); @@ -238,7 +240,6 @@ const app = new Application({ scenes: { EffectChainsScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(EffectChainsScene); diff --git a/examples/audio-fx/gpu-pcm-stream.js b/examples/audio-fx/gpu-pcm-stream.js index a1778f090..54331e109 100644 --- a/examples/audio-fx/gpu-pcm-stream.js +++ b/examples/audio-fx/gpu-pcm-stream.js @@ -12,11 +12,16 @@ import { TextureFormat, UniformType, } from '@codexo/exojs'; -import { PcmStreamSource } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { PcmStreamSource } from '@codexo/exojs/audio-fx'; +import { choice, demoPanel, toggle } from '@examples/runtime'; const blockFrames = 1024; const readbackSlots = 2; const capacityFrames = 8192; +const FORMATS = { + 'rgba8 (packed 16-bit)': TextureFormat.Rgba8, + rgba32f: TextureFormat.Rgba32F, + rgba16f: TextureFormat.Rgba16F, +}; const soundShader = createFilterShader({ uniforms: { sampleOffset: UniformType.Int, sampleRate: UniformType.Float, packed: UniformType.Int }, glsl: { @@ -62,70 +67,56 @@ class GpuPcmStreamScene extends Scene { right = new Float32Array(blockFrames); channels = [this.left, this.right]; pending = []; - format = TextureFormat.Rgba8; generatedFrames = 0; readbackMs = 0; - producing = true; starting = false; active = false; draining = false; label; + prompt; panel; - hud; + get format() { + return FORMATS[this.panel.values.format]; + } init() { this.label = new Text('', { fillColor: Color.white, fontSize: 23 }).setPosition(145, 190); - this.hud = mountControls({ - title: 'GPU stereo PCM', - status: 'Choose a format, then press Start to unlock audio.', - hint: '220 Hz left / 330 Hz right. Pause production to hear starvation. Format changes create a fresh stream.', - }); - this.panel = mountControlPanel({ title: 'GPU producer' }); - this.panel.addCycle({ - label: 'Readback format', - options: ['rgba8 (packed 16-bit)', 'rgba32f', 'rgba16f'], - onChange: index => { - this.format = [TextureFormat.Rgba8, TextureFormat.Rgba32F, TextureFormat.Rgba16F][index]; - this.createStream(); - }, - }); - this.panel.addButton({ - label: 'Start', - onClick: () => { - const stream = this.stream; - if (!stream || stream.state !== 'ready') { - return; - } - void getAudioContext() - .resume() - .then(() => { - if (this.stream !== stream || stream.state !== 'ready') { - return; - } - this.active = true; - this.starting = true; - this.hud.setStatus('Prebuffering GPU samples...'); - }) - .catch(error => this.fail(error)); + this.prompt = new Text('Click or press any key to start the stream', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); + this.panel = demoPanel({ + title: 'GPU producer', + controls: { + format: choice('Readback format', Object.keys(FORMATS)), + producing: toggle('Produce PCM', true), }, - }); - this.panel.addToggle({ - label: 'Produce PCM', - value: true, - onChange: value => { - this.producing = value; + actions: { + Drain: () => { + if (this.active) { + this.draining = true; + } + }, + 'New stream': () => this.createStream(), }, - }); - this.panel.addButton({ - label: 'Drain', - onClick: () => { - if (this.active) { - this.draining = true; + onChange: key => { + // The mount-time calls arrive before the panel handle exists; init() creates the first stream once. + if (key === 'format' && this.panel) { + this.createStream(); } }, }); - this.panel.addButton({ label: 'New stream', onClick: () => this.createStream() }); + // onUnlock replays when audio is already unlocked, so a warm context starts immediately. + this.app.audio.onUnlock.add(this.start); this.createStream(); } + start = () => { + const stream = this.stream; + if (!stream || stream.state !== 'ready' || this.active || this.app.audio.locked) { + return; + } + this.active = true; + this.starting = true; + this.panel.status('Prebuffering GPU samples...'); + }; releaseStream() { this.active = false; this.starting = false; @@ -142,11 +133,12 @@ class GpuPcmStreamScene extends Scene { } createStream() { this.releaseStream(); + this.panel.status(''); this.generatedFrames = 0; this.readbackMs = 0; const context = this.app.rendering; if (!context.supportsReadbackFormat(this.format)) { - this.hud.setStatus(`${this.format} readback is unsupported. Choose another format.`); + this.panel.status(`${this.format} readback is unsupported. Choose another format.`); return; } try { @@ -160,12 +152,12 @@ class GpuPcmStreamScene extends Scene { this.stream = stream; this.filter.uniforms.sampleRate.set(stream.sampleRate); this.filter.uniforms.packed.set(this.format === TextureFormat.Rgba8 ? 1 : 0); - stream.onEnd.add(() => this.hud.setStatus('Drained. Create a new stream to play again.')); + stream.onEnd.add(() => this.panel.status('Drained. Create a new stream to play again.')); stream.onError.add(error => this.fail(error)); void stream.ready .then(() => { if (this.stream === stream) { - this.hud.setStatus('Ready. Press Start to unlock audio and prebuffer.'); + this.start(); } }) .catch(error => { @@ -179,7 +171,7 @@ class GpuPcmStreamScene extends Scene { } fail(error) { this.releaseStream(); - this.hud.setStatus(String(error)); + this.panel.status(String(error)); } pump(context) { const stream = this.stream; @@ -218,7 +210,7 @@ class GpuPcmStreamScene extends Scene { if (this.starting && (stream.bufferedFrames >= targetFrames || this.draining)) { stream.start(getAudioContext().currentTime + 0.02); this.starting = false; - this.hud.setStatus('Playing GPU stereo through the music bus.'); + this.panel.status(''); } if (this.draining) { if (this.pending.length === 0) { @@ -227,7 +219,7 @@ class GpuPcmStreamScene extends Scene { } return; } - if (!this.producing) { + if (!this.panel.values.producing) { return; } // Reserve queue space for every pending read before rendering another block. @@ -255,16 +247,20 @@ class GpuPcmStreamScene extends Scene { `PCM queue: ${stream?.bufferedFrames ?? 0} / ${capacityFrames} | Peak: ${stream?.highWaterFrames ?? 0} frames (${((stream?.bufferedSeconds ?? 0) * 1000).toFixed(1)} ms)`, `Readback: ${this.pending.length} / ${readbackSlots} slots | last observed wait: ${this.readbackMs.toFixed(1)} ms`, `Starvation: ${stream?.underruns ?? 0} episodes / ${stream?.underrunFrames ?? 0} silent frames`, - `Overflow: ${stream?.overflowCount ?? 0} blocks | ${this.producing ? 'Producer enabled' : 'Producer paused'}`, + `Overflow: ${stream?.overflowCount ?? 0} blocks | ${this.panel.values.producing ? 'Producer enabled' : 'Producer paused'}`, ].join('\n'); context.render(this.label); + if (this.app.audio.locked) { + context.render(this.prompt); + } } destroy() { this.releaseStream(); this.input.destroy(); + this.app.audio.onUnlock.remove(this.start); this.panel?.dispose(); - this.hud?.dispose(); this.label?.destroy(); + this.prompt?.destroy(); super.destroy(); } } diff --git a/examples/audio-fx/gpu-pcm-stream.ts b/examples/audio-fx/gpu-pcm-stream.ts index d6c725144..b1bb7938f 100644 --- a/examples/audio-fx/gpu-pcm-stream.ts +++ b/examples/audio-fx/gpu-pcm-stream.ts @@ -15,12 +15,20 @@ import { TextureFormat, UniformType, } from '@codexo/exojs'; -import { PcmStreamSource } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { PcmStreamSource } from '@codexo/exojs/audio-fx'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; const blockFrames = 1024; const readbackSlots = 2; const capacityFrames = 8192; +const FORMATS = { + 'rgba8 (packed 16-bit)': TextureFormat.Rgba8, + rgba32f: TextureFormat.Rgba32F, + rgba16f: TextureFormat.Rgba16F, +} as const satisfies Record; + +type FormatName = keyof typeof FORMATS; + const soundShader = createFilterShader({ uniforms: { sampleOffset: UniformType.Int, sampleRate: UniformType.Float, packed: UniformType.Int }, glsl: { @@ -67,75 +75,62 @@ class GpuPcmStreamScene extends Scene { private readonly right = new Float32Array(blockFrames); private readonly channels = [this.left, this.right]; private readonly pending: Array<{ read: PixelRead | PixelRead; submittedAt: number }> = []; - private format: ColorTextureFormat = TextureFormat.Rgba8; private generatedFrames = 0; private readbackMs = 0; - private producing = true; private starting = false; private active = false; private draining = false; private label!: Text; - private panel!: ReturnType; - private hud!: ReturnType; + private prompt!: Text; + private panel!: DemoPanel<{ format: ChoiceSpec; producing: ToggleSpec }>; + + private get format(): ColorTextureFormat { + return FORMATS[this.panel.values.format]; + } override init(): void { this.label = new Text('', { fillColor: Color.white, fontSize: 23 }).setPosition(145, 190); - this.hud = mountControls({ - title: 'GPU stereo PCM', - status: 'Choose a format, then press Start to unlock audio.', - hint: '220 Hz left / 330 Hz right. Pause production to hear starvation. Format changes create a fresh stream.', - }); - this.panel = mountControlPanel({ title: 'GPU producer' }); - this.panel.addCycle({ - label: 'Readback format', - options: ['rgba8 (packed 16-bit)', 'rgba32f', 'rgba16f'], - onChange: index => { - this.format = ([TextureFormat.Rgba8, TextureFormat.Rgba32F, TextureFormat.Rgba16F] as const)[index]!; - this.createStream(); + this.prompt = new Text('Click or press any key to start the stream', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); + this.panel = demoPanel({ + title: 'GPU producer', + controls: { + format: choice('Readback format', Object.keys(FORMATS) as FormatName[]), + producing: toggle('Produce PCM', true), }, - }); - this.panel.addButton({ - label: 'Start', - onClick: () => { - const stream = this.stream; - - if (!stream || stream.state !== 'ready') { - return; - } - - void getAudioContext() - .resume() - .then(() => { - if (this.stream !== stream || stream.state !== 'ready') { - return; - } - - this.active = true; - this.starting = true; - this.hud.setStatus('Prebuffering GPU samples...'); - }) - .catch(error => this.fail(error)); - }, - }); - this.panel.addToggle({ - label: 'Produce PCM', - value: true, - onChange: value => { - this.producing = value; + actions: { + Drain: () => { + if (this.active) { + this.draining = true; + } + }, + 'New stream': () => this.createStream(), }, - }); - this.panel.addButton({ - label: 'Drain', - onClick: () => { - if (this.active) { - this.draining = true; + onChange: key => { + // The mount-time calls arrive before the panel handle exists; init() creates the first stream once. + if (key === 'format' && this.panel) { + this.createStream(); } }, }); - this.panel.addButton({ label: 'New stream', onClick: () => this.createStream() }); + // onUnlock replays when audio is already unlocked, so a warm context starts immediately. + this.app.audio.onUnlock.add(this.start); this.createStream(); } + private readonly start = (): void => { + const stream = this.stream; + + if (!stream || stream.state !== 'ready' || this.active || this.app.audio.locked) { + return; + } + + this.active = true; + this.starting = true; + this.panel.status('Prebuffering GPU samples...'); + }; + private releaseStream(): void { this.active = false; this.starting = false; @@ -153,12 +148,13 @@ class GpuPcmStreamScene extends Scene { private createStream(): void { this.releaseStream(); + this.panel.status(''); this.generatedFrames = 0; this.readbackMs = 0; const context = this.app.rendering; if (!context.supportsReadbackFormat(this.format)) { - this.hud.setStatus(`${this.format} readback is unsupported. Choose another format.`); + this.panel.status(`${this.format} readback is unsupported. Choose another format.`); return; } @@ -174,12 +170,12 @@ class GpuPcmStreamScene extends Scene { this.stream = stream; this.filter.uniforms.sampleRate.set(stream.sampleRate); this.filter.uniforms.packed.set(this.format === TextureFormat.Rgba8 ? 1 : 0); - stream.onEnd.add(() => this.hud.setStatus('Drained. Create a new stream to play again.')); + stream.onEnd.add(() => this.panel.status('Drained. Create a new stream to play again.')); stream.onError.add(error => this.fail(error)); void stream.ready .then(() => { if (this.stream === stream) { - this.hud.setStatus('Ready. Press Start to unlock audio and prebuffer.'); + this.start(); } }) .catch(error => { @@ -194,7 +190,7 @@ class GpuPcmStreamScene extends Scene { private fail(error: unknown): void { this.releaseStream(); - this.hud.setStatus(String(error)); + this.panel.status(String(error)); } private pump(context: RenderingContext): void { @@ -246,7 +242,7 @@ class GpuPcmStreamScene extends Scene { if (this.starting && (stream.bufferedFrames >= targetFrames || this.draining)) { stream.start(getAudioContext().currentTime + 0.02); this.starting = false; - this.hud.setStatus('Playing GPU stereo through the music bus.'); + this.panel.status(''); } if (this.draining) { @@ -258,7 +254,7 @@ class GpuPcmStreamScene extends Scene { return; } - if (!this.producing) { + if (!this.panel.values.producing) { return; } @@ -291,17 +287,22 @@ class GpuPcmStreamScene extends Scene { `PCM queue: ${stream?.bufferedFrames ?? 0} / ${capacityFrames} | Peak: ${stream?.highWaterFrames ?? 0} frames (${((stream?.bufferedSeconds ?? 0) * 1000).toFixed(1)} ms)`, `Readback: ${this.pending.length} / ${readbackSlots} slots | last observed wait: ${this.readbackMs.toFixed(1)} ms`, `Starvation: ${stream?.underruns ?? 0} episodes / ${stream?.underrunFrames ?? 0} silent frames`, - `Overflow: ${stream?.overflowCount ?? 0} blocks | ${this.producing ? 'Producer enabled' : 'Producer paused'}`, + `Overflow: ${stream?.overflowCount ?? 0} blocks | ${this.panel.values.producing ? 'Producer enabled' : 'Producer paused'}`, ].join('\n'); context.render(this.label); + + if (this.app.audio.locked) { + context.render(this.prompt); + } } override destroy(): void { this.releaseStream(); this.input.destroy(); + this.app.audio.onUnlock.remove(this.start); this.panel?.dispose(); - this.hud?.dispose(); this.label?.destroy(); + this.prompt?.destroy(); super.destroy(); } } diff --git a/examples/audio-fx/pcm-stream.js b/examples/audio-fx/pcm-stream.js index 5f52e741a..801362343 100644 --- a/examples/audio-fx/pcm-stream.js +++ b/examples/audio-fx/pcm-stream.js @@ -1,79 +1,65 @@ // Auto-generated from pcm-stream.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, getAudioContext, Scene, Text } from '@codexo/exojs'; -import { PcmStreamSource } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { PcmStreamSource } from '@codexo/exojs/audio-fx'; +import { demoPanel, toggle } from '@examples/runtime'; class PcmStreamScene extends Scene { stream = null; left = new Float32Array(512); right = new Float32Array(512); channels = [this.left, this.right]; cursor = 0; - producing = true; label; + prompt; panel; - hud; init() { this.label = new Text('', { fillColor: Color.white, fontSize: 26 }).setPosition(170, 190); - this.hud = mountControls({ - title: 'Streaming stereo PCM', - status: 'Loading audio. Press Start when ready.', - hint: 'Pause the producer to hear starvation. Clear empties the queue; Drain finishes it.', - }); - this.panel = mountControlPanel({ title: 'PCM producer' }); - this.panel.addButton({ - label: 'Start', - onClick: () => { - const stream = this.stream; - if (!stream || stream.state !== 'ready') { - return; - } - void getAudioContext() - .resume() - .then(() => { - if (this.stream !== stream || stream.state !== 'ready') { - return; - } - this.fillQueue(); - stream.start(getAudioContext().currentTime + 0.02); - this.hud.setStatus('Playing 220 Hz left / 330 Hz right through the music bus.'); - }) - .catch(error => this.hud.setStatus(String(error))); - }, - }); - this.panel.addToggle({ - label: 'Produce PCM', - value: true, - onChange: value => { - this.producing = value; + this.prompt = new Text('Click or press any key to start the stream', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); + this.panel = demoPanel({ + title: 'PCM producer', + controls: { producing: toggle('Produce PCM', true) }, + actions: { + 'Clear queue': () => this.stream?.clear(), + Drain: () => this.stream?.close(), + 'New stream': () => this.createStream(), }, }); - this.panel.addButton({ label: 'Clear queue', onClick: () => this.stream?.clear() }); - this.panel.addButton({ label: 'Drain', onClick: () => this.stream?.close() }); - this.panel.addButton({ label: 'New stream', onClick: () => this.createStream() }); + // onUnlock replays when audio is already unlocked, so a warm context starts immediately. + this.app.audio.onUnlock.add(this.start); this.createStream(); } + start = () => { + const stream = this.stream; + if (!stream || stream.state !== 'ready' || this.app.audio.locked) { + return; + } + this.fillQueue(); + stream.start(getAudioContext().currentTime + 0.02); + }; createStream() { + this.panel.status(''); this.stream?.destroy(); this.cursor = 0; const stream = new PcmStreamSource({ channels: 2, capacityFrames: 8192, bus: this.app.audio.music }); this.stream = stream; - stream.onEnd.add(() => this.hud.setStatus('Drained. Create a new stream to play again.')); - stream.onError.add(error => this.hud.setStatus(error.message)); + stream.onEnd.add(() => this.panel.status('Drained. Create a new stream to play again.')); + stream.onError.add(error => this.panel.status(error.message)); void stream.ready .then(() => { if (this.stream === stream) { - this.hud.setStatus('Ready. Press Start to unlock audio and begin.'); + this.start(); } }) .catch(error => { if (this.stream === stream && stream.state !== 'destroyed') { - this.hud.setStatus(String(error)); + this.panel.status(String(error)); } }); } fillQueue() { const stream = this.stream; - if (!stream || !this.producing || stream.clearing || (stream.state !== 'ready' && stream.state !== 'running')) { + if (!stream || !this.panel.values.producing || stream.clearing || (stream.state !== 'ready' && stream.state !== 'running')) { return; } const targetFrames = Math.ceil(stream.sampleRate * 0.06); @@ -98,7 +84,7 @@ class PcmStreamScene extends Scene { return; } this.label.text = [ - `State: ${stream.state} | ${stream.sampleRate} Hz stereo`, + `State: ${stream.state} | 220 Hz left / 330 Hz right | ${stream.sampleRate} Hz stereo`, `Queued: ${stream.bufferedFrames} / ${stream.capacityFrames} frames (${(stream.bufferedSeconds * 1000).toFixed(1)} ms)`, `Played: ${stream.playedFrames} frames`, `Accepted: ${stream.enqueuedFrames} frames | Queue peak: ${stream.highWaterFrames} frames`, @@ -108,13 +94,17 @@ class PcmStreamScene extends Scene { } draw(context) { context.render(this.label); + if (this.app.audio.locked) { + context.render(this.prompt); + } } destroy() { this.stream?.destroy(); this.stream = null; + this.app.audio.onUnlock.remove(this.start); this.panel?.dispose(); - this.hud?.dispose(); this.label?.destroy(); + this.prompt?.destroy(); super.destroy(); } } diff --git a/examples/audio-fx/pcm-stream.ts b/examples/audio-fx/pcm-stream.ts index d6fca8e6e..94d590bfe 100644 --- a/examples/audio-fx/pcm-stream.ts +++ b/examples/audio-fx/pcm-stream.ts @@ -1,6 +1,6 @@ import { Application, Color, FixedResolutionCanvasSizing, getAudioContext, type RenderingContext, Scene, Text } from '@codexo/exojs'; -import { PcmStreamSource } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { PcmStreamSource } from '@codexo/exojs/audio-fx'; +import { type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; class PcmStreamScene extends Scene { private stream: PcmStreamSource | null = null; @@ -8,71 +8,57 @@ class PcmStreamScene extends Scene { private readonly right = new Float32Array(512); private readonly channels = [this.left, this.right]; private cursor = 0; - private producing = true; private label!: Text; - private panel!: ReturnType; - private hud!: ReturnType; + private prompt!: Text; + private panel!: DemoPanel<{ producing: ToggleSpec }>; override init(): void { this.label = new Text('', { fillColor: Color.white, fontSize: 26 }).setPosition(170, 190); - this.hud = mountControls({ - title: 'Streaming stereo PCM', - status: 'Loading audio. Press Start when ready.', - hint: 'Pause the producer to hear starvation. Clear empties the queue; Drain finishes it.', - }); - this.panel = mountControlPanel({ title: 'PCM producer' }); - this.panel.addButton({ - label: 'Start', - onClick: () => { - const stream = this.stream; - - if (!stream || stream.state !== 'ready') { - return; - } - - void getAudioContext() - .resume() - .then(() => { - if (this.stream !== stream || stream.state !== 'ready') { - return; - } - - this.fillQueue(); - stream.start(getAudioContext().currentTime + 0.02); - this.hud.setStatus('Playing 220 Hz left / 330 Hz right through the music bus.'); - }) - .catch(error => this.hud.setStatus(String(error))); + this.prompt = new Text('Click or press any key to start the stream', { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); + this.panel = demoPanel({ + title: 'PCM producer', + controls: { producing: toggle('Produce PCM', true) }, + actions: { + 'Clear queue': () => this.stream?.clear(), + Drain: () => this.stream?.close(), + 'New stream': () => this.createStream(), }, }); - this.panel.addToggle({ - label: 'Produce PCM', - value: true, - onChange: value => { - this.producing = value; - }, - }); - this.panel.addButton({ label: 'Clear queue', onClick: () => this.stream?.clear() }); - this.panel.addButton({ label: 'Drain', onClick: () => this.stream?.close() }); - this.panel.addButton({ label: 'New stream', onClick: () => this.createStream() }); + // onUnlock replays when audio is already unlocked, so a warm context starts immediately. + this.app.audio.onUnlock.add(this.start); this.createStream(); } + private readonly start = (): void => { + const stream = this.stream; + + if (!stream || stream.state !== 'ready' || this.app.audio.locked) { + return; + } + + this.fillQueue(); + stream.start(getAudioContext().currentTime + 0.02); + }; + private createStream(): void { + this.panel.status(''); this.stream?.destroy(); this.cursor = 0; const stream = new PcmStreamSource({ channels: 2, capacityFrames: 8192, bus: this.app.audio.music }); this.stream = stream; - stream.onEnd.add(() => this.hud.setStatus('Drained. Create a new stream to play again.')); - stream.onError.add(error => this.hud.setStatus(error.message)); + stream.onEnd.add(() => this.panel.status('Drained. Create a new stream to play again.')); + stream.onError.add(error => this.panel.status(error.message)); void stream.ready .then(() => { if (this.stream === stream) { - this.hud.setStatus('Ready. Press Start to unlock audio and begin.'); + this.start(); } }) .catch(error => { if (this.stream === stream && stream.state !== 'destroyed') { - this.hud.setStatus(String(error)); + this.panel.status(String(error)); } }); } @@ -80,7 +66,7 @@ class PcmStreamScene extends Scene { private fillQueue(): void { const stream = this.stream; - if (!stream || !this.producing || stream.clearing || (stream.state !== 'ready' && stream.state !== 'running')) { + if (!stream || !this.panel.values.producing || stream.clearing || (stream.state !== 'ready' && stream.state !== 'running')) { return; } @@ -113,7 +99,7 @@ class PcmStreamScene extends Scene { } this.label.text = [ - `State: ${stream.state} | ${stream.sampleRate} Hz stereo`, + `State: ${stream.state} | 220 Hz left / 330 Hz right | ${stream.sampleRate} Hz stereo`, `Queued: ${stream.bufferedFrames} / ${stream.capacityFrames} frames (${(stream.bufferedSeconds * 1000).toFixed(1)} ms)`, `Played: ${stream.playedFrames} frames`, `Accepted: ${stream.enqueuedFrames} frames | Queue peak: ${stream.highWaterFrames} frames`, @@ -124,14 +110,19 @@ class PcmStreamScene extends Scene { override draw(context: RenderingContext): void { context.render(this.label); + + if (this.app.audio.locked) { + context.render(this.prompt); + } } override destroy(): void { this.stream?.destroy(); this.stream = null; + this.app.audio.onUnlock.remove(this.start); this.panel?.dispose(); - this.hud?.dispose(); this.label?.destroy(); + this.prompt?.destroy(); super.destroy(); } } diff --git a/examples/audio-fx/reverb-and-delay.js b/examples/audio-fx/reverb-and-delay.js index 19f99c83f..cc93ddbff 100644 --- a/examples/audio-fx/reverb-and-delay.js +++ b/examples/audio-fx/reverb-and-delay.js @@ -1,79 +1,68 @@ // Auto-generated from reverb-and-delay.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; -import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { DelayEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; +const PRESETS = { + 'Small room': { reverbWet: 0.3, decay: 1.3, delayWet: 0.12, delaySeconds: 0.18, feedback: 0.2 }, + 'Large hall': { reverbWet: 0.55, decay: 5, delayWet: 0.18, delaySeconds: 0.32, feedback: 0.3 }, + Echo: { reverbWet: 0.12, decay: 1.5, delayWet: 0.55, delaySeconds: 0.48, feedback: 0.55 }, +}; class ReverbAndDelayScene extends Scene { sound; reverb; delay; label; + prompt; panel; - hud; - preset = 'Small room'; - bypass = false; - reverbWet = 0.3; - delayWet = 0.12; init() { const audio = this.app.audio; - this.sound = this.loader.get('audio/impact-light.ogg'); + this.sound = this.loader.get(assets.soundImpactLight); 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', - 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.panel = mountControlPanel({ title: 'Space and echoes' }); - this.panel.addButton({ - label: 'Play impact', - onClick: () => { - if (!audio.locked) { - audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); - } + this.prompt = new Text('Click or press any key to enable audio', { fillColor: Color.white, fontSize: 22 }); + this.prompt.setPosition(415, 330); + this.panel = demoPanel({ + title: 'Space and echoes', + controls: { + preset: choice('Preset', Object.keys(PRESETS)), + bypass: toggle('Bypass', false), }, - }); - 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(); + actions: { + 'Play impact': () => { + if (!audio.locked) { + audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); + } + }, }, + onChange: (_key, _value, values) => this.applyPreset(PRESETS[values.preset], values.bypass), }); } - 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(); - } - 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)}.`, - ); + applyPreset(preset, bypass) { + this.reverb.decay = preset.decay; + this.delay.delaySeconds = preset.delaySeconds; + this.delay.feedback = preset.feedback; + this.reverb.wet = bypass ? 0 : preset.reverbWet; + this.delay.wet = bypass ? 0 : preset.delayWet; + this.panel?.status(bypass ? 'Bypassed: dry impact' : `Reverb ${this.reverb.wet.toFixed(2)}, delay ${this.delay.wet.toFixed(2)}`); } draw(context) { context.render(this.label); + if (this.app.audio.locked) { + context.render(this.prompt); + } } 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(); + this.prompt?.destroy(); super.destroy(); } } @@ -81,6 +70,5 @@ const app = new Application({ scenes: { ReverbAndDelayScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - 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 3875cf63c..fa01a640f 100644 --- a/examples/audio-fx/reverb-and-delay.ts +++ b/examples/audio-fx/reverb-and-delay.ts @@ -1,22 +1,35 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Sound, Text } from '@codexo/exojs'; -import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { DelayEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; + +interface Preset { + readonly reverbWet: number; + readonly decay: number; + readonly delayWet: number; + readonly delaySeconds: number; + readonly feedback: number; +} + +const PRESETS = { + 'Small room': { reverbWet: 0.3, decay: 1.3, delayWet: 0.12, delaySeconds: 0.18, feedback: 0.2 }, + 'Large hall': { reverbWet: 0.55, decay: 5, delayWet: 0.18, delaySeconds: 0.32, feedback: 0.3 }, + Echo: { reverbWet: 0.12, decay: 1.5, delayWet: 0.55, delaySeconds: 0.48, feedback: 0.55 }, +} as const satisfies Record; + +type PresetName = keyof typeof PRESETS; class ReverbAndDelayScene extends Scene { private sound!: Sound; private reverb!: ReverbEffect; private delay!: DelayEffect; private label!: Text; - private panel!: ReturnType; - private hud!: ReturnType; - private preset = 'Small room'; - private bypass = false; - private reverbWet = 0.3; - private delayWet = 0.12; + private prompt!: Text; + private panel!: DemoPanel<{ preset: ChoiceSpec; bypass: ToggleSpec }>; override init(): void { const audio = this.app.audio; - this.sound = this.loader.get('audio/impact-light.ogg'); + this.sound = this.loader.get(assets.soundImpactLight); 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); @@ -24,53 +37,41 @@ class ReverbAndDelayScene extends Scene { 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', - 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.panel = mountControlPanel({ title: 'Space and echoes' }); - this.panel.addButton({ - label: 'Play impact', - onClick: () => { - if (!audio.locked) { - audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); - } + this.prompt = new Text('Click or press any key to enable audio', { fillColor: Color.white, fontSize: 22 }); + this.prompt.setPosition(415, 330); + + this.panel = demoPanel({ + title: 'Space and echoes', + controls: { + preset: choice('Preset', Object.keys(PRESETS) as PresetName[]), + bypass: toggle('Bypass', false), }, - }); - 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(); + actions: { + 'Play impact': () => { + if (!audio.locked) { + audio.play(this.sound, { bus: audio.sound, volume: 0.4 }); + } + }, }, + onChange: (_key, _value, values) => this.applyPreset(PRESETS[values.preset], values.bypass), }); } - 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(); - } - - 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)}.`, - ); + private applyPreset(preset: Preset, bypass: boolean): void { + this.reverb.decay = preset.decay; + this.delay.delaySeconds = preset.delaySeconds; + this.delay.feedback = preset.feedback; + this.reverb.wet = bypass ? 0 : preset.reverbWet; + this.delay.wet = bypass ? 0 : preset.delayWet; + this.panel?.status(bypass ? 'Bypassed: dry impact' : `Reverb ${this.reverb.wet.toFixed(2)}, delay ${this.delay.wet.toFixed(2)}`); } override draw(context: RenderingContext): void { context.render(this.label); + + if (this.app.audio.locked) { + context.render(this.prompt); + } } override destroy(): void { @@ -78,8 +79,8 @@ class ReverbAndDelayScene extends Scene { this.delay?.destroy(); this.reverb?.destroy(); this.panel?.dispose(); - this.hud?.dispose(); this.label?.destroy(); + this.prompt?.destroy(); super.destroy(); } } @@ -88,7 +89,6 @@ const app = new Application({ scenes: { ReverbAndDelayScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(ReverbAndDelayScene); diff --git a/examples/audio-fx/signal-graph.js b/examples/audio-fx/signal-graph.js index 061a26da4..97b97a8e9 100644 --- a/examples/audio-fx/signal-graph.js +++ b/examples/audio-fx/signal-graph.js @@ -1,7 +1,8 @@ // Auto-generated from signal-graph.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { mountControlPanel } from '@examples/runtime'; const NODE_W = 180; const NODE_H = 86; const LANE_COLORS = { @@ -44,10 +45,10 @@ class SignalGraphScene extends Scene { flow = 0; graph = new Graphics(); panel = null; - hud; + hint; async load() { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impact = this.loader.get('audio/impact-light.ogg'); + this.music = await this.loader.load(assets.musicLoopMain); + this.impact = this.loader.get(assets.soundImpactLight); } init() { const audio = this.app.audio; @@ -90,13 +91,9 @@ class SignalGraphScene extends Scene { this.analysers.set('music', new AudioAnalyser({ source: audio.music, fftSize: 1024 })); this.analysers.set('sound', new AudioAnalyser({ source: audio.sound, fftSize: 1024 })); this.analysers.set('master', new AudioAnalyser({ source: audio.master, fftSize: 1024 })); - this.hud = mountControls({ - title: 'Audio Signal Graph', - controls: [{ keys: 'Click a node', action: 'edit its parameters' }], - status: 'Click or press any key to start audio…', - hint: 'Every box is a real source, effect or bus; every wire is a real connection. Meters show each bus output.', - corner: 'top-right', - }); + this.hint = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); this.app.input.onPointerDown.add(this.onPointerDown); // A stream is deferred until the unlock gesture, so it can be started now. this.musicVoice = audio.play(this.music, { bus: audio.music, loop: true, volume: this.musicVolume }); @@ -254,16 +251,11 @@ class SignalGraphScene extends Scene { n.status.text = n.enabled ? 'active' : 'bypassed'; } } - this.hud.setStatus( - this.app.audio.locked - ? 'Click or press any key to start audio…' - : `Music bus ${fmt(this.levels.music)} · SFX bus ${fmt(this.levels.sound)}`, - ); } draw(context) { const g = this.graph.clear(); const byId = id => this.nodes.find(n => n.id === id); - // Wires: a cubic Bézier from the right edge of one node to the left edge of + // Wires: a cubic Bezier from the right edge of one node to the left edge of // the next, brighter and thicker the louder the bus it carries. for (const edge of this.edges) { const a = byId(edge.from); @@ -325,6 +317,8 @@ class SignalGraphScene extends Scene { g.lineWidth = 0; context.render(g); super.draw(context); + this.hint.text = this.app.audio.locked ? 'Click or press any key to start audio' : 'Click a node to edit its parameters'; + context.render(this.hint); } destroy() { const audio = this.app.audio; @@ -343,7 +337,7 @@ class SignalGraphScene extends Scene { } audio.master.volume = 1; this.panel?.dispose(); - this.hud.dispose(); + this.hint?.destroy(); this.graph.destroy(); super.destroy(); } @@ -352,6 +346,5 @@ const app = new Application({ scenes: { SignalGraphScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(10, 12, 20), - loader: { basePath: 'assets/' }, }); await app.start(SignalGraphScene); diff --git a/examples/audio-fx/signal-graph.ts b/examples/audio-fx/signal-graph.ts index f7e169d16..bbfcb6d21 100644 --- a/examples/audio-fx/signal-graph.ts +++ b/examples/audio-fx/signal-graph.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioBus, type AudioEffect, type AudioStream, @@ -14,8 +13,9 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs-audio-fx'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AudioAnalyser, CompressorEffect, EqualizerEffect, LimiterEffect, ReverbEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { mountControlPanel } from '@examples/runtime'; type NodeKind = 'source' | 'effect' | 'bus' | 'output'; type Lane = 'music' | 'sound' | 'master'; @@ -95,11 +95,11 @@ class SignalGraphScene extends Scene { private graph = new Graphics(); private panel: ReturnType | null = null; - private hud!: ReturnType; + private hint!: Text; override async load(): Promise { - this.music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); - this.impact = this.loader.get('audio/impact-light.ogg'); + this.music = await this.loader.load(assets.musicLoopMain); + this.impact = this.loader.get(assets.soundImpactLight); } override init(): void { @@ -159,13 +159,9 @@ class SignalGraphScene extends Scene { this.analysers.set('sound', new AudioAnalyser({ source: audio.sound, fftSize: 1024 })); this.analysers.set('master', new AudioAnalyser({ source: audio.master, fftSize: 1024 })); - this.hud = mountControls({ - title: 'Audio Signal Graph', - controls: [{ keys: 'Click a node', action: 'edit its parameters' }], - status: 'Click or press any key to start audio…', - hint: 'Every box is a real source, effect or bus; every wire is a real connection. Meters show each bus output.', - corner: 'top-right', - }); + this.hint = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' }) + .setAnchor(0.5) + .setPosition(this.app.width / 2, this.app.height - 48); this.app.input.onPointerDown.add(this.onPointerDown); @@ -344,19 +340,13 @@ class SignalGraphScene extends Scene { n.status.text = n.enabled ? 'active' : 'bypassed'; } } - - this.hud.setStatus( - this.app.audio.locked - ? 'Click or press any key to start audio…' - : `Music bus ${fmt(this.levels.music)} · SFX bus ${fmt(this.levels.sound)}`, - ); } override draw(context: RenderingContext): void { const g = this.graph.clear(); const byId = (id: string): GraphNode => this.nodes.find(n => n.id === id)!; - // Wires: a cubic Bézier from the right edge of one node to the left edge of + // Wires: a cubic Bezier from the right edge of one node to the left edge of // the next, brighter and thicker the louder the bus it carries. for (const edge of this.edges) { const a = byId(edge.from); @@ -431,6 +421,8 @@ class SignalGraphScene extends Scene { context.render(g); super.draw(context); + this.hint.text = this.app.audio.locked ? 'Click or press any key to start audio' : 'Click a node to edit its parameters'; + context.render(this.hint); } override destroy(): void { @@ -455,7 +447,7 @@ class SignalGraphScene extends Scene { audio.master.volume = 1; this.panel?.dispose(); - this.hud.dispose(); + this.hint?.destroy(); this.graph.destroy(); super.destroy(); } @@ -465,7 +457,6 @@ const app = new Application({ scenes: { SignalGraphScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(10, 12, 20), - loader: { basePath: 'assets/' }, }); await app.start(SignalGraphScene); diff --git a/examples/audio-fx/vocoder.js b/examples/audio-fx/vocoder.js index 61d388f2f..91fa1991d 100644 --- a/examples/audio-fx/vocoder.js +++ b/examples/audio-fx/vocoder.js @@ -1,13 +1,14 @@ // 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 { 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. +import { Application, AudioBus, AudioGenerator, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser, VocoderEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; +// A spoken voice as modulator is what makes a vocoder recognisable as the +// classic "robot voice" effect. const PHRASES = [ - { key: 'congrats', label: 'Congratulations', asset: assets.demo.voice.congratulations }, - { key: 'mission', label: 'Mission complete', asset: assets.demo.voice.missionComplete }, - { key: 'objective', label: 'Objective achieved', asset: assets.demo.voice.objectiveAchieved }, + { key: 'congrats', label: 'Congratulations', asset: assets.voiceCongratulations }, + { key: 'mission', label: 'Mission complete', asset: assets.voiceMissionComplete }, + { key: 'objective', label: 'Objective achieved', asset: assets.voiceObjectiveAchieved }, ]; class VocoderScene extends Scene { sidechainBus; @@ -15,16 +16,12 @@ class VocoderScene extends Scene { vocoder; level; phrases = new Map(); - phraseIndex = 0; pulse = 0; gfx; phraseLabel; - hintLabel; tapPrompt; - hud; panel; carrierVoice = null; - processed = true; pendingPhrase = false; init() { const app = this.app; @@ -38,10 +35,7 @@ class VocoderScene extends Scene { this.modulatorBus = new AudioBus('modulator', { parent: this.sidechainBus }); app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { - // phrase.asset is a widened `string` (not a path literal), so the - // path-only get() overload can't infer Sound from the extension - - // use the explicit Sound token form. - this.phrases.set(phrase.key, this.loader.get(Asset.type('sound', phrase.asset))); + this.phrases.set(phrase.key, this.loader.get(phrase.asset)); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); app.audio.sound.addEffect(this.vocoder); @@ -55,81 +49,66 @@ class VocoderScene extends Scene { this.phraseLabel = new Text('', { fillColor: Color.white, fontSize: 28, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height / 2 - 130); - this.hintLabel = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 18 }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height / 2 + 130); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the queued carrier 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.hud = mountControls({ - title: 'Vocoder', - 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.panel = demoPanel({ + title: 'Voice', + controls: { + phrase: choice( + 'Phrase', + PHRASES.map(phrase => phrase.label), + ), + processed: toggle('Processed', true), + }, + actions: { 'Speak phrase': () => this.speak() }, + onChange: (key, _value, values) => { + if (key === 'processed') { + this.vocoder.wet = values.processed ? 1 : 0; + if (this.carrierVoice) { + this.carrierVoice.volume = values.processed ? 0.4 : 0; + } + } else if (this.panel) { + // The mount-time call arrives before the panel handle exists and must stay silent. 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.4 : 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.root.addChild(this.gfx, this.phraseLabel); // 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(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.4 : 0, + volume: this.panel.values.processed ? 0.4 : 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}"`; - } speak() { const app = this.app; // 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 { phrase: label, processed } = this.panel.values; + const phrase = PHRASES.find(candidate => candidate.label === label); const sound = this.phrases.get(phrase.key); if (sound) { - app.audio.play(sound, { bus: this.processed ? this.modulatorBus : app.audio.sound }); + app.audio.play(sound, { bus: processed ? this.modulatorBus : app.audio.sound }); } - this.hud.setStatus(`${this.processed ? 'Processed' : 'Dry'}: "${phrase.label}"`); - this.phraseLabel.text = `"${phrase.label}"`; + this.phraseLabel.text = `"${phrase.label}"${processed ? '' : ' (dry)'}`; } draw(context) { const app = this.app; @@ -154,7 +133,6 @@ class VocoderScene extends Scene { this.app.audio.unregisterBus(this.modulatorBus); this.app.audio.unregisterBus(this.sidechainBus); this.panel?.dispose(); - this.hud?.dispose(); this.tapPrompt?.destroy(); super.destroy(); } diff --git a/examples/audio-fx/vocoder.ts b/examples/audio-fx/vocoder.ts index 33ea1bf8f..9d94b8181 100644 --- a/examples/audio-fx/vocoder.ts +++ b/examples/audio-fx/vocoder.ts @@ -1,6 +1,5 @@ import { Application, - Asset, AudioBus, AudioGenerator, Color, @@ -12,15 +11,16 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser, VocoderEffect } from '@codexo/exojs-audio-fx'; -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: Array<{ key: string; label: string; asset: string }> = [ - { key: 'congrats', label: 'Congratulations', asset: assets.demo.voice.congratulations }, - { key: 'mission', label: 'Mission complete', asset: assets.demo.voice.missionComplete }, - { key: 'objective', label: 'Objective achieved', asset: assets.demo.voice.objectiveAchieved }, +import { AudioAnalyser, VocoderEffect } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; + +// A spoken voice as modulator is what makes a vocoder recognisable as the +// classic "robot voice" effect. +const PHRASES = [ + { key: 'congrats', label: 'Congratulations', asset: assets.voiceCongratulations }, + { key: 'mission', label: 'Mission complete', asset: assets.voiceMissionComplete }, + { key: 'objective', label: 'Objective achieved', asset: assets.voiceObjectiveAchieved }, ]; class VocoderScene extends Scene { @@ -29,16 +29,12 @@ class VocoderScene extends Scene { private vocoder!: VocoderEffect; private level!: AudioAnalyser; private phrases = new Map(); - private phraseIndex = 0; private pulse = 0; private gfx!: Graphics; private phraseLabel!: Text; - private hintLabel!: Text; private tapPrompt!: Text; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: DemoPanel<{ phrase: ChoiceSpec; processed: ToggleSpec }>; private carrierVoice: Voice | null = null; - private processed = true; private pendingPhrase = false; override init(): void { @@ -55,10 +51,7 @@ class VocoderScene extends Scene { app.audio.registerBus(this.modulatorBus); for (const phrase of PHRASES) { - // phrase.asset is a widened `string` (not a path literal), so the - // path-only get() overload can't infer Sound from the extension - - // use the explicit Sound token form. - this.phrases.set(phrase.key, this.loader.get(Asset.type('sound', phrase.asset))); + this.phrases.set(phrase.key, this.loader.get(phrase.asset)); } this.vocoder = new VocoderEffect({ modulator: this.modulatorBus, numBands: 16, wet: 1 }); @@ -74,9 +67,6 @@ class VocoderScene extends Scene { this.phraseLabel = new Text('', { fillColor: Color.white, fontSize: 28, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height / 2 - 130); - this.hintLabel = new Text('', { fillColor: new Color(150, 162, 186), fontSize: 18 }) - .setAnchor(0.5, 0.5) - .setPosition(width / 2, height / 2 + 130); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the queued carrier starts. @@ -84,37 +74,31 @@ class VocoderScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Vocoder', - 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.panel = demoPanel({ + title: 'Voice', + controls: { + phrase: choice( + 'Phrase', + PHRASES.map(phrase => phrase.label), + ), + processed: toggle('Processed', true), + }, + actions: { 'Speak phrase': () => this.speak() }, + onChange: (key, _value, values) => { + if (key === 'processed') { + this.vocoder.wet = values.processed ? 1 : 0; + + if (this.carrierVoice) { + this.carrierVoice.volume = values.processed ? 0.4 : 0; + } + } else if (this.panel) { + // The mount-time call arrives before the panel handle exists and must stay silent. 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.4 : 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.root.addChild(this.gfx, this.phraseLabel); // 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 @@ -122,14 +106,12 @@ class VocoderScene extends Scene { // gesture. Subscribing is safe even if audio unlocked earlier: // onUnlock replays. 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.4 : 0, + volume: this.panel.values.processed ? 0.4 : 0, }); if (this.pendingPhrase) { @@ -138,11 +120,6 @@ class VocoderScene extends Scene { } }; - private selectPhrase(next: number): void { - this.phraseIndex = (next + PHRASES.length) % PHRASES.length; - this.hintLabel.text = `${this.phraseIndex + 1} / ${PHRASES.length} — "${PHRASES[this.phraseIndex]!.label}"`; - } - private speak(): void { const app = this.app; @@ -150,20 +127,19 @@ class VocoderScene extends Scene { // 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 { phrase: label, processed } = this.panel.values; + const phrase = PHRASES.find(candidate => candidate.label === label)!; const sound = this.phrases.get(phrase.key); if (sound) { - app.audio.play(sound, { bus: this.processed ? this.modulatorBus : app.audio.sound }); + app.audio.play(sound, { bus: processed ? this.modulatorBus : app.audio.sound }); } - this.hud.setStatus(`${this.processed ? 'Processed' : 'Dry'}: "${phrase.label}"`); - this.phraseLabel.text = `"${phrase.label}"`; + this.phraseLabel.text = `"${phrase.label}"${processed ? '' : ' (dry)'}`; } override draw(context: RenderingContext): void { @@ -195,7 +171,6 @@ class VocoderScene extends Scene { this.app.audio.unregisterBus(this.modulatorBus); this.app.audio.unregisterBus(this.sidechainBus); this.panel?.dispose(); - this.hud?.dispose(); this.tapPrompt?.destroy(); super.destroy(); } diff --git a/examples/beat-detection/beat-sync-pulse.js b/examples/beat-detection/beat-sync-pulse.js index 07237aaf3..5acebcac4 100644 --- a/examples/beat-detection/beat-sync-pulse.js +++ b/examples/beat-detection/beat-sync-pulse.js @@ -1,35 +1,30 @@ // Auto-generated from beat-sync-pulse.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Vector } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; -import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Vector } from '@codexo/exojs'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; +import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +/** Scale added to the sprite on each detected beat, decaying back to 1. */ +const PULSE = 0.28; class BeatSyncPulseScene extends Scene { music; detector; sprite; pulse = 0; - intensity = 0.28; - beats = 0; particles; burst; - hud; tapPrompt; tempoReadout; async load() { const app = this.app; const { width, height } = app; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); 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 and Tempo', - hint: 'A detected beat pulses the sprite and particles. The tempo estimate and confidence update continuously.', - }); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); 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.particles = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 3500 }); this.systems.add(this.particles); this.particles.setPosition(width / 2, height / 2); this.burst = new BurstSpawn({ @@ -46,27 +41,15 @@ class BeatSyncPulseScene extends Scene { this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 64); - mountControlPanel({ title: 'Tuning' }).addSlider({ - label: 'Pulse intensity', - min: 0.1, - max: 0.5, - step: 0.01, - value: this.intensity, - onChange: value => { - this.intensity = value; - }, - }); this.detector = new BeatDetector(); this.detector.source = app.audio.music; this.detector.onBeat.add(() => { - this.pulse = this.intensity; + this.pulse = PULSE; this.burst.reset(); - this.beats += 1; - this.hud.setStatus(`Beats detected: ${this.beats}`); }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } update(delta) { this.pulse = Math.max(0, this.pulse - delta * 1.2); @@ -96,9 +79,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); await app.start(BeatSyncPulseScene); diff --git a/examples/beat-detection/beat-sync-pulse.ts b/examples/beat-detection/beat-sync-pulse.ts index 9bccce454..d1fee181c 100644 --- a/examples/beat-detection/beat-sync-pulse.ts +++ b/examples/beat-detection/beat-sync-pulse.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -11,20 +10,20 @@ import { Text, Vector, } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; -import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; +import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; + +/** Scale added to the sprite on each detected beat, decaying back to 1. */ +const PULSE = 0.28; class BeatSyncPulseScene extends Scene { private music!: AudioStream; private detector!: BeatDetector; private sprite!: Sprite; private pulse = 0; - private intensity = 0.28; - private beats = 0; private particles!: ParticleSystem; private burst!: BurstSpawn; - private hud!: ReturnType; private tapPrompt!: Text; private tempoReadout!: Text; @@ -33,20 +32,15 @@ class BeatSyncPulseScene extends Scene { const { width, height } = app; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); 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 and Tempo', - hint: 'A detected beat pulses the sprite and particles. The tempo estimate and confidence update continuously.', - }); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); 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.particles = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 3500 }); this.systems.add(this.particles); this.particles.setPosition(width / 2, height / 2); this.burst = new BurstSpawn({ @@ -65,29 +59,16 @@ class BeatSyncPulseScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 64); - mountControlPanel({ title: 'Tuning' }).addSlider({ - label: 'Pulse intensity', - min: 0.1, - max: 0.5, - step: 0.01, - value: this.intensity, - onChange: value => { - this.intensity = value; - }, - }); - this.detector = new BeatDetector(); this.detector.source = app.audio.music; this.detector.onBeat.add(() => { - this.pulse = this.intensity; + this.pulse = PULSE; this.burst.reset(); - this.beats += 1; - this.hud.setStatus(`Beats detected: ${this.beats}`); }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(delta: Seconds): void { @@ -121,9 +102,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); diff --git a/examples/beat-detection/frequency-bands.js b/examples/beat-detection/frequency-bands.js index b601043e1..1c97af45e 100644 --- a/examples/beat-detection/frequency-bands.js +++ b/examples/beat-detection/frequency-bands.js @@ -1,6 +1,7 @@ // Auto-generated from frequency-bands.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Eight perceptual frequency bands spanning the audible range, from the lowest // rumble to the airy top end. The FFT bins are linearly spaced over 0..nyquist, @@ -32,7 +33,7 @@ class FrequencyBandsScene extends Scene { async load() { const app = this.app; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); this.music = track; this.analyser = new AudioAnalyser({ fftSize: 2048, smoothingTimeConstant: 0.75 }); this.analyser.source = app.audio.music; @@ -68,7 +69,7 @@ class FrequencyBandsScene extends Scene { .setPosition(width / 2, height - 48); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } update() { const spectrum = this.analyser.getSpectrum(); @@ -118,8 +119,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 16, 22), - loader: { - basePath: 'assets/', - }, }); await app.start(FrequencyBandsScene); diff --git a/examples/beat-detection/frequency-bands.ts b/examples/beat-detection/frequency-bands.ts index 21e4a9882..cfc569a82 100644 --- a/examples/beat-detection/frequency-bands.ts +++ b/examples/beat-detection/frequency-bands.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -9,7 +8,8 @@ import { Scene, Text, } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Eight perceptual frequency bands spanning the audible range, from the lowest @@ -45,7 +45,7 @@ class FrequencyBandsScene extends Scene { override async load(): Promise { const app = this.app; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); this.music = track; this.analyser = new AudioAnalyser({ fftSize: 2048, smoothingTimeConstant: 0.75 }); @@ -90,7 +90,7 @@ class FrequencyBandsScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(): void { @@ -155,9 +155,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 16, 22), - loader: { - basePath: 'assets/', - }, }); await app.start(FrequencyBandsScene); diff --git a/examples/beat-detection/tempo-tracking.js b/examples/beat-detection/tempo-tracking.js index ea5dc9763..6e5785485 100644 --- a/examples/beat-detection/tempo-tracking.js +++ b/examples/beat-detection/tempo-tracking.js @@ -1,6 +1,7 @@ // Auto-generated from tempo-tracking.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class TempoTrackingScene extends Scene { music; @@ -20,7 +21,7 @@ class TempoTrackingScene extends Scene { const { width, height } = app; const marginX = width * 0.08; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); this.music = track; this.detector = new BeatDetector(); this.detector.source = app.audio.music; @@ -43,7 +44,7 @@ class TempoTrackingScene extends Scene { .setPosition(width / 2, height - 48); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } update(delta) { // Raw onset strength straight from the detector (positive spectral flux). @@ -95,8 +96,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 16, 22), - loader: { - basePath: 'assets/', - }, }); await app.start(TempoTrackingScene); diff --git a/examples/beat-detection/tempo-tracking.ts b/examples/beat-detection/tempo-tracking.ts index 5f1353f4c..8237f8606 100644 --- a/examples/beat-detection/tempo-tracking.ts +++ b/examples/beat-detection/tempo-tracking.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -10,7 +9,8 @@ import { type Seconds, Text, } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class TempoTrackingScene extends Scene { @@ -33,7 +33,7 @@ class TempoTrackingScene extends Scene { const marginX = width * 0.08; // AudioStream has no seamless adapter - await it explicitly. - const track = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + const track = await this.loader.load(assets.musicLoopMain); this.music = track; this.detector = new BeatDetector(); @@ -64,7 +64,7 @@ class TempoTrackingScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically. - app.audio.play(this.music, { loop: true, volume: 0.8 }); + app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(delta: Seconds): void { @@ -127,9 +127,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 16, 22), - loader: { - basePath: 'assets/', - }, }); await app.start(TempoTrackingScene); diff --git a/examples/custom-renderers/custom-render-pass.js b/examples/custom-renderers/custom-render-pass.js index f3d1d0c19..f5e992d5b 100644 --- a/examples/custom-renderers/custom-render-pass.js +++ b/examples/custom-renderers/custom-render-pass.js @@ -10,6 +10,7 @@ import { Scene, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class CustomRenderPassScene extends Scene { back; front; @@ -19,12 +20,12 @@ class CustomRenderPassScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.back = new Sprite(this.loader.get('image/ship-a.png')) + this.back = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2 - 200, height / 2) .setScale(2.2) .setTint(new Color(120, 170, 255)); - this.front = new Sprite(this.loader.get('image/ship-a.png')) + this.front = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2 + 200, height / 2) .setScale(2.2) @@ -66,8 +67,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CustomRenderPassScene); diff --git a/examples/custom-renderers/custom-render-pass.ts b/examples/custom-renderers/custom-render-pass.ts index 674f09b10..639f8a491 100644 --- a/examples/custom-renderers/custom-render-pass.ts +++ b/examples/custom-renderers/custom-render-pass.ts @@ -11,6 +11,7 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class CustomRenderPassScene extends Scene { private back!: Sprite; @@ -23,12 +24,12 @@ class CustomRenderPassScene extends Scene { const app = this.app; const { width, height } = app; - this.back = new Sprite(this.loader.get('image/ship-a.png')) + this.back = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2 - 200, height / 2) .setScale(2.2) .setTint(new Color(120, 170, 255)); - this.front = new Sprite(this.loader.get('image/ship-a.png')) + this.front = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2 + 200, height / 2) .setScale(2.2) @@ -74,9 +75,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(CustomRenderPassScene); diff --git a/examples/debug-layer/asset-browser.js b/examples/debug-layer/asset-browser.js index efdeacdaf..2be818664 100644 --- a/examples/debug-layer/asset-browser.js +++ b/examples/debug-layer/asset-browser.js @@ -1,1094 +1,1664 @@ // Auto-generated from asset-browser.ts - edit the .ts source, not this file. -import { Asset, FixedResolutionCanvasSizing } from '@codexo/exojs'; -import { Application, Color, Graphics, Scene, Sprite, Spritesheet, Text, Texture } from '@codexo/exojs'; -// Dynamic category accessor: maps a category key to the correct sub-object -// in the hierarchical assets catalog. Technical assets live under -// assets.technical; everything else is under assets.demo. -const getCategoryData = catKey => { - if (catKey === 'technical') { - return assets.technical; - } - return assets.demo[catKey] ?? {}; +import { + AnimatedSprite, + Application, + Asset, + BitmapText, + Color, + FixedResolutionCanvasSizing, + Graphics, + ScaleModes, + Scene, + Sprite, + Spritesheet, + Text, + Texture, + View, +} from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { ObjectKind, TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssetCategories, demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { ldtkExtension } from '@codexo/exojs-ldtk'; +import { tiledExtension } from '@codexo/exojs-tiled'; +import { choice, demoPanel, slider, toggle } from '@examples/runtime'; +// An asset browser for the standard demo catalog. It is an ordinary scene: +// +// - The list is derived from `demoAssets` and `demoAssetCategories`; nothing +// is fetched until an entry is selected. +// - Every selection loads into a child `LoaderScope` of the scene. Selecting +// something else destroys that scope, which cancels a load still in flight +// and frees what it held - unless another scope holds the same asset. +// - Each selection carries a generation number. A load that settles after a +// newer selection is discarded before it can show, play or log anything. +// - Nothing plays on selection. Play goes through `app.audio.onUnlock`, which +// waits for the browser's autoplay gesture and is cancelled on switch. +// - "Code" turns the current preview settings into a complete scene. +const STAGE = { width: 1280, height: 720 }; +const SIDEBAR_WIDTH = 272; +const INSPECTOR_WIDTH = 332; +const PREVIEW = { x: SIDEBAR_WIDTH, y: 0, width: STAGE.width - SIDEBAR_WIDTH - INSPECTOR_WIDTH, height: STAGE.height }; +const PREVIEW_CENTER = { x: PREVIEW.x + PREVIEW.width / 2, y: PREVIEW.height / 2 }; +const FONT = '"Segoe UI", system-ui, sans-serif'; +const MONO = '"JetBrains Mono", ui-monospace, monospace'; +const MANIFEST_URL = import.meta.resolve('@codexo/exojs-demo-assets/manifest.json'); +const GROUP_OF = { + textures: 'Textures', + technical: 'Textures', + backgrounds: 'Textures', + icons: 'Textures', + cursors: 'Textures', + spritesheets: 'Spritesheets', + inputPrompts: 'Spritesheets', + maps: 'Maps', + sound: 'Audio', + soundSprites: 'Audio', + music: 'Audio', + voice: 'Audio', + impulseResponses: 'Audio', + video: 'Video', + fonts: 'Fonts', }; -const W = 1280; -const H = 720; -const TOOLBAR_H = 110; -const SIDEBAR_W = 220; -const ITEM_H = 36; -const LIST_Y = TOOLBAR_H + 4; -const PREVIEW_X = SIDEBAR_W; -const PREVIEW_Y = TOOLBAR_H; -const PREVIEW_W = W - SIDEBAR_W; -const PREVIEW_H = H - TOOLBAR_H; -const CAT_BTN_W = 82; -const CAT_BTN_H = 38; -const CAT_BTN_Y1 = 6; -const CAT_BTN_Y2 = 58; -const CAT_BTN_X0 = 6; -const BG_BTN_SIZE = 26; -const BG_BTN_Y = 16; -const BG_BTN_X0 = W - 3 * (BG_BTN_SIZE + 5) - 6; -const CATEGORIES_ROW1 = [ - { id: 'textures', label: 'IMG', catKey: 'textures' }, - { id: 'sprites', label: 'SPR', catKey: 'sprites' }, - { id: 'audio', label: 'AUD', catKey: 'audio' }, - { id: 'svg', label: 'SVG', catKey: 'svg' }, - { id: 'fonts', label: 'FNT', catKey: 'fonts' }, - { id: 'video', label: 'VID', catKey: 'video' }, - { id: 'inputPrompts', label: 'INP', catKey: 'inputPrompts' }, - { id: 'technical', label: 'TECH', catKey: 'technical' }, -]; -const CATEGORIES_ROW2 = [ - { id: 'sound', label: 'SND', catKey: 'sound' }, - { id: 'music', label: 'MUS', catKey: 'music' }, - { id: 'soundSprites', label: 'SDS', catKey: 'soundSprites' }, - { id: 'spritesheets', label: 'SSH', catKey: 'spritesheets' }, - { id: 'tilesets', label: 'TLS', catKey: 'tilesets' }, - { id: 'backgrounds', label: 'BGS', catKey: 'backgrounds' }, - { id: 'cursors', label: 'CSR', catKey: 'cursors' }, - { id: 'vendor', label: 'VND', catKey: 'vendor' }, -]; -const CATEGORIES = [...CATEGORIES_ROW1, ...CATEGORIES_ROW2]; -const TECH_PURPOSE = { - alpha: 'alpha / premultiplied / halo checks', - filtering: 'nearest / linear / sampler checks', - color: 'ramp / gamma / color-shift checks', +const GROUP_ORDER = ['Textures', 'Spritesheets', 'Maps', 'Audio', 'Video', 'Fonts', 'Data']; +const GROUP_GLYPH = { + texture: 'IMG', + svg: 'SVG', + spritesheet: 'ATL', + tiled: 'MAP', + ldtk: 'MAP', + sound: 'SND', + music: 'MUS', + video: 'VID', + font: 'TTF', + bmFont: 'FNT', + data: '{ }', }; -const BG_OPTIONS = [ - { label: 'W', r: 255, g: 255, b: 255 }, - { label: 'G', r: 45, g: 50, b: 65 }, - { label: 'K', r: 0, g: 0, b: 0 }, -]; -const C = { - bg: new Color(26, 28, 38), - toolbar: new Color(30, 34, 46), - panel: new Color(32, 36, 48), - active: new Color(58, 120, 218), - hover: new Color(44, 50, 66), - border: new Color(52, 58, 76), - infoBar: new Color(18, 22, 32), - btnDark: new Color(44, 52, 72), - btnGreen: new Color(55, 150, 75), - white: new Color(255, 255, 255), - dim: new Color(140, 150, 170), - dimDark: new Color(90, 98, 120), - accent: new Color(100, 180, 255), - black: new Color(0, 0, 0), +const humanize = key => { + const words = key + .replace(/^(sound|music|font)(?=[A-Z])/, '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); }; -class AssetBrowserScene extends Scene { - cat = 'textures'; - key = null; - bgIdx = 1; - scrollOff = 0; - hoverIdx = null; - audioMusics = new Map(); - soundMusics = new Map(); - musicMusics = new Map(); - soundSpriteAudio = new Map(); - // Single-asset preview playback: one Voice at a time, tied to `previewKey`. - previewVoice = null; - previewKey = null; - frameIdx = 0; - frameTimer = 0; - animPlaying = true; - texSprites = new Map(); - sprSheets = new Map(); - sshSheets = new Map(); - svgSprites = new Map(); - inpSheets = new Map(); - fontFamilies = new Map(); - techSprites = new Map(); - bgSprites = new Map(); - cursorSprites = new Map(); - tilesetSprites = new Map(); - soundSpriteData = new Map(); - vendorData = new Map(); - animG = null; - audioG = null; - copyBtnBg = null; - fontSampleTexts = new Map(); - gToolbar = new Graphics(); - gSidebar = new Graphics(); - gPreview = new Graphics(); - gInfoBar = new Graphics(); - catBtnTexts = CATEGORIES.map(() => new Text('', { fillColor: C.white, fontSize: 11, fontWeight: 'bold' })); - bgBtnTexts = BG_OPTIONS.map(() => new Text('', { fillColor: C.dim, fontSize: 11, fontWeight: 'bold' })); - txtKey = new Text('', { fillColor: C.white, fontSize: 12, fontWeight: 'bold' }); - txtPath = new Text('', { fillColor: C.dim, fontSize: 11 }); - txtType = new Text('', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' }); - txtCopy = new Text('COPY KEY', { fillColor: C.white, fontSize: 10 }); - itemTexts = Array.from({ length: 22 }, () => new Text('', { fillColor: C.white, fontSize: 13 })); - techHeaderTexts = new Map([ - ['alpha', new Text('ALPHA', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ['filtering', new Text('FILTERING', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ['color', new Text('COLOR', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ]); - txtTechPurpose = new Text('', { fillColor: C.dim, fontSize: 11 }); - txtNoAssets = new Text('globalThis.assets is not available.\nRun this example in the ExoJS playground.', { - fillColor: C.dim, - fontSize: 16, - }); - txtEmptyCat = new Text('(empty)', { fillColor: C.dimDark, fontSize: 13 }); - txtNoSel = new Text('Select an asset from the list.', { fillColor: C.dimDark, fontSize: 14 }); - txtLoading = new Text('Loading assets…', { fillColor: C.dim, fontSize: 14 }); - txtMeta = new Text('', { fillColor: C.white, fontSize: 13, maxWidth: PREVIEW_W - 80 }); - txtAudioIcon = new Text('', { fillColor: C.white, fontSize: 28 }); - txtAnimPlay = new Text('', { fillColor: C.white, fontSize: 12 }); - txtAnimFrame = new Text('', { fillColor: C.dim, fontSize: 11 }); - // Lazy per-category loading: only the initial category is fetched up - // front; every other one loads on first visit (see ensureCategory), so - // the browser becomes interactive quickly instead of downloading the - // whole catalog before the first frame. - assetLoader; - loadedCats = new Set(); - loadingCats = new Set(); - async load() { - const app = this.app; - this.assetLoader = this.loader; - await this.ensureCategory(this.cat); - app.input.onPointerTap.add(p => this.onTap(p.x, p.y)); - app.input.onPointerMove.add(p => this.onMove(p.x, p.y)); - app.input.onMouseWheel.add((_deltaX, deltaY) => this.onWheel(deltaY)); - this.selectFirstInCategory(); +const kindOf = key => { + const descriptor = assets[key]; + switch (descriptor.type) { + case 'texture': + case 'svg': + case 'sound': + case 'music': + case 'video': + case 'font': + case 'bmFont': + return descriptor.type; + case 'json': + if (demoAssetCategories[key] !== 'maps') { + return 'data'; + } + return descriptor.source.endsWith('.ldtk') ? 'ldtk' : 'tiled'; + default: + return 'data'; } - /** Load a category's assets (and build its preview objects) exactly once. */ - async ensureCategory(catId) { - if (this.loadedCats.has(catId) || this.loadingCats.has(catId)) { - return; +}; +/** An atlas JSON and its image are separate descriptors named `Atlas` and `Image`; the browser lists them as one spritesheet. */ +const buildItems = () => { + const items = []; + for (const key of Object.keys(demoAssetCategories)) { + const base = key.replace(/(Atlas|Image)$/, ''); + const image = `${base}Image`; + const atlas = `${base}Atlas`; + if (base !== key && image in assets && atlas in assets) { + if (key === image) { + items.push({ id: base, name: humanize(base), group: 'Spritesheets', kind: 'spritesheet', key: image, atlasKey: atlas }); + } + continue; } - this.loadingCats.add(catId); + const kind = kindOf(key); + items.push({ id: key, name: humanize(key), group: kind === 'data' ? 'Data' : GROUP_OF[demoAssetCategories[key]], kind, key }); + } + return items; +}; +const ITEMS = buildItems(); +const GROUPS = GROUP_ORDER.filter(group => ITEMS.some(item => item.group === group)); +const fileName = source => decodeURIComponent(source.slice(source.lastIndexOf('/') + 1)); +const formatBytes = bytes => + bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`; +const formatSeconds = value => (Number.isFinite(value) ? `${value.toFixed(2)} s` : 'unknown'); +const isAbort = error => error instanceof DOMException && error.name === 'AbortError'; +class AssetBrowserScene extends Scene { + sidebar = document.createElement('nav'); + search = document.createElement('input'); + tabs = document.createElement('div'); + list = document.createElement('ul'); + inspector = document.createElement('aside'); + heading = document.createElement('h2'); + facts = document.createElement('dl'); + panelSlot = document.createElement('div'); + resident = document.createElement('details'); + codePanel = document.createElement('section'); + codeTitle = document.createElement('strong'); + code = document.createElement('pre'); + buttons = new Map(); + view = new View(STAGE.width / 2, STAGE.height / 2, STAGE.width, STAGE.height); + frame = new Graphics(); + message = new Text('', { fontSize: 22, fillColor: new Color(200, 214, 236), align: 'center' }); + backdrop = new Color(32, 37, 50); + group = GROUPS[0]; + selected = null; + generation = 0; + scope = null; + preview = null; + panel = null; + manifest = new Map(); + drag = null; + zoomTarget = null; + destroyed = false; + async load() { + // The package's manifest carries byte sizes and media metadata. It is + // optional here: without it the inspector only shows what loading tells. try { - await this.loadCategory(catId); - this.loadedCats.add(catId); - } finally { - this.loadingCats.delete(catId); + const manifest = await this.loader.load(Asset.type('json', MANIFEST_URL)); + this.manifest = new Map(manifest.entries.map(entry => [entry.key, entry])); + } catch (error) { + console.warn('The demo asset manifest is unavailable; sizes and media details are hidden.', error); } } - async loadCategory(catId) { - const loader = this.assetLoader; - // Every category below fetches its assets by real (dynamic, non-literal) - // path via `Asset.type(type, path)` + `load()` - the old alias-keyed batch-record form - // (`load(Type, { alias: path })` + `get(Type, alias)`) is gone; there is no - // alias indirection to reconstruct, so each item is loaded directly and its - // resolved handle/value is stored under the catalog's own key. - switch (catId) { - case 'textures': { - await Promise.all( - Object.entries(assets.demo.textures ?? {}).map(async ([k, url]) => { - const s = new Sprite(await loader.load(Asset.type('texture', url))); - s.setAnchor(0.5); - this.texSprites.set(k, s); - }), - ); - break; - } - case 'sprites': { - await Promise.all( - Object.entries(assets.demo.sprites ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', entry.image)), - loader.load(Asset.type('json', entry.data)), - ]); - const ss = new Spritesheet(tex, data); - this.sprSheets.set(k, ss); - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } - case 'spritesheets': { - await Promise.all( - Object.entries(assets.demo.spritesheets ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', entry.image)), - loader.load(Asset.type('json', entry.data)), - ]); - const ss = new Spritesheet(tex, data); - this.sshSheets.set(k, ss); - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } - case 'svg': { - await Promise.all( - Object.entries(assets.demo.svg ?? {}).map(async ([k, url]) => { - const s = new Sprite(new Texture(await loader.load(Asset.type('svg', url)))); - s.setAnchor(0.5); - this.svgSprites.set(k, s); - }), - ); - break; - } - case 'inputPrompts': { - await Promise.all( - Object.entries(assets.demo.inputPrompts ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', entry.image)), - loader.load(Asset.type('json', entry.data)), - ]); - const ss = new Spritesheet(tex, data); - this.inpSheets.set(k, ss); - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } - case 'audio': { - await Promise.all( - Object.entries(assets.demo.audio ?? {}).map(async ([k, url]) => { - this.audioMusics.set(k, await loader.load(Asset.type('music', url))); - }), - ); - break; - } - case 'sound': { - await Promise.all( - Object.entries(assets.demo.sound ?? {}).map(async ([k, url]) => { - this.soundMusics.set(k, await loader.load(Asset.type('music', url))); - }), - ); - break; - } - case 'music': { - await Promise.all( - Object.entries(assets.demo.music ?? {}).map(async ([k, url]) => { - this.musicMusics.set(k, await loader.load(Asset.type('music', url))); - }), - ); - break; - } - case 'soundSprites': { - await Promise.all( - Object.entries(assets.demo.soundSprites ?? {}).map(async ([k, entry]) => { - const [audio, data] = await Promise.all([ - loader.load(Asset.type('music', entry.audio)), - loader.load(Asset.type('json', entry.data)), - ]); - this.soundSpriteAudio.set(k, audio); - this.soundSpriteData.set(k, data); - }), - ); - break; - } - case 'fonts': { - await Promise.all( - Object.entries(assets.demo.fonts ?? {}).map(async ([k, url]) => { - // The fonts category mixes vector fonts (.ttf/.otf) with - // bitmap-font sidecars (.fnt/.png) that FontFace cannot - // parse. Load only the vector entries - the bitmap ones - // fall back to a path readout. - if (!/\.(ttf|otf|woff2?)$/i.test(url)) { - return; - } - const family = `assetbrowser_${k}`; - await loader.load(Asset.type('font', url, { family })); - this.fontFamilies.set(k, family); - }), - ); - break; - } - case 'technical': { - await Promise.all( - Object.entries(assets.technical ?? {}).flatMap(([subcat, items]) => - Object.entries(items).map(async ([k, u]) => { - const s = new Sprite(await loader.load(Asset.type('texture', u))); - s.setAnchor(0.5); - this.techSprites.set(`${subcat}.${k}`, s); - }), - ), - ); - break; - } - case 'backgrounds': { - await Promise.all( - Object.entries(assets.demo.backgrounds ?? {}).map(async ([k, url]) => { - const s = new Sprite(await loader.load(Asset.type('texture', url))); - s.setAnchor(0.5); - this.bgSprites.set(k, s); - }), - ); - break; - } - case 'cursors': { - await Promise.all( - Object.entries(assets.demo.cursors ?? {}).map(async ([k, url]) => { - const s = new Sprite(new Texture(await loader.load(Asset.type('svg', url)))); - s.setAnchor(0.5); - this.cursorSprites.set(k, s); - }), - ); - break; - } - case 'tilesets': { - await Promise.all( - Object.entries(assets.demo.tilesets ?? {}).map(async ([k, entry]) => { - const s = new Sprite(await loader.load(Asset.type('texture', entry.image))); - s.setAnchor(0.5); - this.tilesetSprites.set(k, s); - }), - ); - break; - } - case 'vendor': { - await Promise.all( - Object.entries(assets.demo.vendor ?? {}).map(async ([k, url]) => { - this.vendorData.set(k, await loader.load(Asset.type('json', url))); - }), - ); - break; - } - default: - // 'video' previews only show the asset URL - nothing to load. - break; - } + init() { + this.mountSidebar(); + this.mountInspector(); + this.mountCodePanel(); + this.message.setAnchor(0.5).setPosition(PREVIEW_CENTER.x, PREVIEW_CENTER.y); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerUp); + this.app.input.onMouseWheel.add(this.onWheel); + this.showGroup(this.group); + void this.select(ITEMS.find(item => item.group === this.group)); } - techFlatKeys() { - const out = []; - for (const subcat of ['alpha', 'filtering', 'color']) { - const items = assets.technical[subcat]; - if (!items) { - continue; + // --- Selection ------------------------------------------------------------ + async select(item) { + const generation = ++this.generation; + this.closePreview(); + this.selected = item; + this.markSelection(); + this.renderFacts(item, []); + this.message.text = 'Loading...'; + const scope = this.loader.createScope({ name: `preview:${item.id}` }); + const started = performance.now(); + this.scope = scope; + const context = { + scene: this, + item, + scope, + manifest: this.manifest.get(item.key), + isCurrent: () => generation === this.generation && !this.destroyed, + }; + try { + const preview = await openPreview(context); + // A newer selection destroyed this scope already; what the preview built + // is dropped without ever being shown. + if (!context.isCurrent()) { + preview.dispose(); + return; } - out.push(subcat); - for (const k of Object.keys(items)) { - out.push(`${subcat}.${k}`); + this.preview = preview; + this.message.text = ''; + this.renderFacts(item, preview.facts); + this.mountPanel(preview); + this.fit(); + this.renderResident(); + console.info(`Loaded ${item.name} (${item.kind}) in ${Math.round(performance.now() - started)} ms`); + } catch (error) { + if (!context.isCurrent() || isAbort(error)) { + return; } + this.message.text = `Could not load ${item.name}`; + this.renderResident(); + console.error(`Failed to load ${item.name}:`, error); } - return out; } - keys() { - if (this.cat === 'technical') { - return this.techFlatKeys(); + closePreview() { + this.hideCode(); + this.panel?.dispose(); + this.panel = null; + this.zoomTarget = null; + this.backdrop = new Color(32, 37, 50); + try { + this.preview?.dispose(); + } finally { + this.preview = null; + // Destroying the scope cancels a load that is still running and releases + // this selection's claims; assets another scope holds stay resident. + this.scope?.destroy(); + this.scope = null; } - const cat = CATEGORIES.find(c => c.id === this.cat); - const obj = getCategoryData(cat?.catKey ?? ''); - return obj ? Object.keys(obj) : []; } - assetPath() { - if (!this.key) { - return ''; - } - if (this.cat === 'technical') { - if (!this.key.includes('.')) { - return ''; + mountPanel(preview) { + const host = options => { + const panel = demoPanel({ ...options, corner: 'top-right' }); + Object.assign(panel.element.style, { + position: 'static', + maxWidth: 'none', + minWidth: '0', + padding: '10px', + boxShadow: 'none', + margin: '0', + }); + // The shared panel is sized for a canvas corner; in this column the + // sliders give way so every readout stays visible. + for (const input of panel.element.querySelectorAll('input')) { + input.style.minWidth = '0'; } - const [subcat, itemKey] = this.key.split('.'); - return assets.technical[subcat]?.[itemKey] ?? ''; - } - const cat = CATEGORIES.find(c => c.id === this.cat); - const v = getCategoryData(cat?.catKey ?? '')[this.key]; - if (typeof v === 'string') { - return v; - } - const record = v; - if (typeof record?.image === 'string') { - return record.image; - } - if (typeof record?.audio === 'string') { - return record.audio; - } - return ''; - } - assetExpression() { - const category = this.cat === 'technical' ? 'technical' : `demo.${this.cat}`; - return `assets.${category}.${this.key}`; + this.panelSlot.replaceChildren(panel.element); + this.panel = panel; + return panel; + }; + preview.mount(host); } - typeLabel() { - return this.assetPath().split('.').pop()?.toUpperCase() ?? ''; + /** The camera always zooms through the panel's Zoom control when a preview has one. */ + bindZoom(target) { + this.zoomTarget = target; } - selectFirstInCategory() { - const keys = this.keys(); - this.key = this.cat === 'technical' ? (keys.find(k => k.includes('.')) ?? null) : (keys[0] ?? null); - this.scrollOff = 0; - this.resetPreviewState(); + setZoom(zoom) { + const center = this.view.center; + // Zoom about the preview's centre, which is offset from the canvas centre. + const focusX = center.x + (PREVIEW_CENTER.x - STAGE.width / 2) / this.view.zoomLevel; + const focusY = center.y + (PREVIEW_CENTER.y - STAGE.height / 2) / this.view.zoomLevel; + this.view.setZoom(zoom); + this.lookAt(focusX, focusY); } - resetPreviewState() { - this.stopAllAudio(); - this.frameIdx = 0; - this.frameTimer = 0; - this.animPlaying = true; + /** Puts world point (x, y) at the centre of the preview area. */ + lookAt(x, y) { + const zoom = this.view.zoomLevel; + this.view.setCenter(x - (PREVIEW_CENTER.x - STAGE.width / 2) / zoom, y - (PREVIEW_CENTER.y - STAGE.height / 2) / zoom); } - stopAllAudio() { - if (this.previewVoice) { - this.previewVoice.stop(); - this.previewVoice = null; - this.previewKey = null; + fit() { + const bounds = this.preview?.bounds; + if (!bounds) { + this.view.setZoom(1); + this.lookAt(0, 0); + return; } + const zoom = Math.min(8, (PREVIEW.width * 0.9) / bounds.width, (PREVIEW.height * 0.82) / bounds.height); + this.view.setZoom(zoom); + this.lookAt(bounds.width / 2, bounds.height / 2); + this.zoomTarget?.set('zoom', zoom); } - /** True when the selected key's preview voice is live and not paused. */ - previewIsPlaying() { - return this.previewVoice !== null && !this.previewVoice.ended && !this.previewVoice.paused && this.previewKey === this.key; - } - currentPlayingMap() { - if (this.cat === 'audio') { - return this.audioMusics; - } - if (this.cat === 'sound') { - return this.soundMusics; - } - if (this.cat === 'music') { - return this.musicMusics; - } - if (this.cat === 'soundSprites') { - return this.soundSpriteAudio; - } - return null; + setBackdrop(color) { + this.backdrop = color; } - toggleAudio() { - const app = this.app; - if (!this.key) { + // --- Pointer: drag pans, wheel zooms, tap hits ---------------------------- + onPointerDown = (_pointer, x, y) => { + if (x >= PREVIEW.x && x <= PREVIEW.x + PREVIEW.width) { + this.drag = { x, y, moved: false }; + } + }; + onPointerMove = (_pointer, x, y) => { + if (!this.drag || !this.preview?.bounds) { return; } - const map = this.currentPlayingMap(); - if (!map) { + const dx = x - this.drag.x; + const dy = y - this.drag.y; + if (this.drag.moved || Math.hypot(dx, dy) > 4) { + const zoom = this.view.zoomLevel; + this.view.setCenter(this.view.center.x - dx / zoom, this.view.center.y - dy / zoom); + this.drag = { x, y, moved: true }; + } + }; + onPointerUp = (_pointer, x, y) => { + const drag = this.drag; + this.drag = null; + if (drag && !drag.moved && this.preview?.tap) { + // The four-argument form maps through the viewport at any zoom level; + // the stage's design size stands in for the canvas size. + const point = this.view.screenToWorld(x, y, STAGE.width, STAGE.height); + this.preview.tap(point.x, point.y); + } + }; + onWheel = (_deltaX, deltaY) => { + if (!this.preview?.bounds) { return; } - const stream = map.get(this.key); - if (!stream) { - return; - } - if (this.previewKey === this.key && this.previewVoice && !this.previewVoice.ended) { - // Same asset selected: pause / resume the live voice. - if (this.previewVoice.paused) { - this.previewVoice.resume(); - } else { - this.previewVoice.pause(); - } + const zoom = Math.min(32, Math.max(0.05, this.view.zoomLevel * Math.exp(-deltaY * 0.0015))); + if (this.zoomTarget) { + this.zoomTarget.set('zoom', zoom); } else { - // New asset: stop the previous preview and start this one. - this.previewVoice?.stop(); - this.previewVoice = app.audio.play(stream); - this.previewKey = this.key; + this.setZoom(zoom); } + }; + // --- Frame ---------------------------------------------------------------- + update(delta) { + this.preview?.update?.(delta); } - currentFrameKeys() { - if (this.cat === 'sprites') { - return [...(this.sprSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; + draw(context) { + context.clear(this.backdrop); + for (const node of this.preview?.world ?? []) { + context.render(node, { view: this.view }); } - if (this.cat === 'spritesheets') { - return [...(this.sshSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; + for (const node of this.preview?.overlay ?? []) { + context.render(node, { view: context.screenView }); } - if (this.cat === 'inputPrompts') { - return [...(this.inpSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; + if (this.message.text) { + context.render(this.message, { view: context.screenView }); } - return []; - } - maxScroll() { - const visibleItems = Math.floor((H - LIST_Y) / ITEM_H); - return Math.max(0, this.keys().length - visibleItems); } - isAudioLikeCategory() { - return this.cat === 'audio' || this.cat === 'sound' || this.cat === 'music' || this.cat === 'soundSprites'; + destroy() { + this.destroyed = true; + this.generation += 1; + this.closePreview(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerUp); + this.app.input.onMouseWheel.remove(this.onWheel); + this.sidebar.remove(); + this.inspector.remove(); + this.codePanel.remove(); + this.frame.destroy(); + this.message.destroy(); + this.view.destroy(); + super.destroy(); } - onTap(x, y) { - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - if (x >= bx && x < bx + CAT_BTN_W && y >= CAT_BTN_Y1 && y < CAT_BTN_Y1 + CAT_BTN_H) { - if (this.cat !== CATEGORIES_ROW1[i].id) { - this.cat = CATEGORIES_ROW1[i].id; - this.selectFirstInCategory(); - void this.ensureCategory(this.cat); - } - return; - } - } - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - if (x >= bx && x < bx + CAT_BTN_W && y >= CAT_BTN_Y2 && y < CAT_BTN_Y2 + CAT_BTN_H) { - if (this.cat !== CATEGORIES_ROW2[i].id) { - this.cat = CATEGORIES_ROW2[i].id; - this.selectFirstInCategory(); - void this.ensureCategory(this.cat); - } - return; - } - } - for (let i = 0; i < BG_OPTIONS.length; i++) { - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - if (x >= bx && x < bx + BG_BTN_SIZE && y >= BG_BTN_Y && y < BG_BTN_Y + BG_BTN_SIZE) { - this.bgIdx = i; - return; - } - } - if (x >= 0 && x < SIDEBAR_W && y >= TOOLBAR_H) { - const keys = this.keys(); - const idx = Math.floor((y - LIST_Y) / ITEM_H) + this.scrollOff; - if (idx >= 0 && idx < keys.length) { - const newKey = keys[idx]; - const isHeader = this.cat === 'technical' && !newKey.includes('.'); - if (!isHeader && newKey !== this.key) { - this.key = newKey; - this.resetPreviewState(); + // --- Sidebar -------------------------------------------------------------- + mountSidebar() { + Object.assign(this.sidebar.style, { + position: 'fixed', + inset: `0 auto 0 0`, + width: `${SIDEBAR_WIDTH}px`, + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '14px 12px', + boxSizing: 'border-box', + background: '#0b1019', + borderRight: '1px solid rgba(154, 193, 255, 0.18)', + color: '#f3f7ff', + font: `15px ${FONT}`, + zIndex: '5', + }); + this.sidebar.setAttribute('aria-label', 'Demo assets'); + this.search.type = 'search'; + this.search.placeholder = `Search ${ITEMS.length} assets`; + this.search.setAttribute('aria-label', 'Search assets'); + Object.assign(this.search.style, { + padding: '8px 10px', + border: '1px solid rgba(154, 193, 255, 0.32)', + borderRadius: '8px', + background: 'rgba(154, 193, 255, 0.08)', + color: 'inherit', + font: 'inherit', + }); + this.search.addEventListener('input', () => this.renderList()); + Object.assign(this.tabs.style, { display: 'flex', flexWrap: 'wrap', gap: '5px' }); + this.tabs.setAttribute('role', 'group'); + this.tabs.setAttribute('aria-label', 'Asset types'); + for (const group of GROUPS) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${group} ${ITEMS.filter(item => item.group === group).length}`; + button.dataset.group = group; + Object.assign(button.style, chipStyle(false)); + button.addEventListener('click', () => { + this.showGroup(group); + const first = this.visibleItems()[0]; + if (first && first.group !== this.selected?.group) { + void this.select(first); } - } - 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) { - navigator.clipboard?.writeText(this.assetExpression()).catch(() => undefined); - return; + }); + this.tabs.append(button); } - if (this.isAudioLikeCategory() && this.key) { - const bx = PREVIEW_X + (PREVIEW_W - 100) / 2; - const by = PREVIEW_Y + PREVIEW_H / 2 - 30; - if (x >= bx && x < bx + 100 && y >= by && y < by + 56) { - this.toggleAudio(); + Object.assign(this.list.style, { flex: '1 1 auto', overflowY: 'auto', listStyle: 'none', margin: '0', padding: '0' }); + this.list.setAttribute('aria-label', 'Assets'); + this.list.addEventListener('keydown', event => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { return; } - } - if ((this.cat === 'sprites' || this.cat === 'spritesheets' || this.cat === 'inputPrompts') && this.key) { - const bx = PREVIEW_X + 16; - const by = H - 48; - if (x >= bx && x < bx + 90 && y >= by && y < by + 30) { - this.animPlaying = !this.animPlaying; - return; + event.preventDefault(); + const visible = this.visibleItems(); + const index = visible.findIndex(item => item === this.selected); + const next = visible[(index + (event.key === 'ArrowDown' ? 1 : -1) + visible.length) % visible.length]; + if (next) { + void this.select(next); + this.buttons.get(next.id)?.focus(); } - } + }); + this.sidebar.append(this.search, this.tabs, this.list); + document.body.append(this.sidebar); } - onMove(x, y) { - this.hoverIdx = null; - if (x >= 0 && x < SIDEBAR_W && y >= TOOLBAR_H) { - const keys = this.keys(); - const idx = Math.floor((y - LIST_Y) / ITEM_H) + this.scrollOff; - if (idx >= 0 && idx < keys.length) { - const isHeader = this.cat === 'technical' && !keys[idx].includes('.'); - if (!isHeader) { - this.hoverIdx = idx; - } - } - } + showGroup(group) { + this.group = group; + this.search.value = ''; + for (const button of this.tabs.querySelectorAll('button')) { + Object.assign(button.style, chipStyle(button.dataset.group === group)); + button.setAttribute('aria-pressed', String(button.dataset.group === group)); + } + this.renderList(); } - onWheel(dy) { - const maxScroll = this.maxScroll(); - const delta = dy > 0 ? 1 : -1; - this.scrollOff = Math.max(0, Math.min(maxScroll, this.scrollOff + delta)); + visibleItems() { + const query = this.search.value.trim().toLowerCase(); + return query + ? ITEMS.filter(item => item.name.toLowerCase().includes(query) || item.key.toLowerCase().includes(query)) + : ITEMS.filter(item => item.group === this.group); } - update(delta) { - if (!this.animPlaying) { - return; - } - if (this.cat !== 'sprites' && this.cat !== 'spritesheets' && this.cat !== 'inputPrompts') { - return; - } - const frames = this.currentFrameKeys(); - if (!frames.length) { - return; - } - this.frameTimer += delta; - if (this.frameTimer >= 0.07) { - this.frameTimer = 0; - this.frameIdx = (this.frameIdx + 1) % frames.length; - } + renderList() { + const searching = this.search.value.trim() !== ''; + this.buttons.clear(); + this.list.replaceChildren( + ...this.visibleItems().map(item => { + const row = document.createElement('li'); + const button = document.createElement('button'); + const glyph = document.createElement('span'); + const label = document.createElement('span'); + button.type = 'button'; + button.dataset.asset = item.id; + glyph.textContent = GROUP_GLYPH[item.kind]; + glyph.setAttribute('aria-hidden', 'true'); + Object.assign(glyph.style, { flex: '0 0 34px', font: `600 10px ${MONO}`, color: '#7fb8ff', textAlign: 'center' }); + label.textContent = searching ? `${item.name} - ${item.group}` : item.name; + Object.assign(label.style, { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }); + Object.assign(button.style, { + display: 'flex', + alignItems: 'center', + gap: '6px', + width: '100%', + minHeight: '34px', + padding: '4px 6px', + textAlign: 'left', + border: '0', + borderRadius: '7px', + background: 'transparent', + color: 'inherit', + font: 'inherit', + cursor: 'pointer', + }); + button.append(glyph, label); + button.addEventListener('click', () => void this.select(item)); + this.buttons.set(item.id, button); + row.append(button); + return row; + }), + ); + this.markSelection(); } - draw(context) { - this.drawPreviewBg(context); - this.drawPreviewContent(context); - this.drawSidebar(context); - this.drawToolbar(context); + markSelection() { + for (const [id, button] of this.buttons) { + const current = id === this.selected?.id; + button.style.background = current ? 'rgba(127, 184, 255, 0.26)' : 'transparent'; + button.setAttribute('aria-current', String(current)); + if (current) { + button.scrollIntoView({ block: 'nearest' }); + } + } } - drawNoAssets(context) { - const g = this.gPreview; - g.clear(); - g.fillColor = C.panel; - g.drawRectangle(0, 0, W, H); - context.render(g); - context.render(this.txtNoAssets); + // --- Inspector ------------------------------------------------------------ + mountInspector() { + Object.assign(this.inspector.style, { + position: 'fixed', + inset: `0 0 0 auto`, + width: `${INSPECTOR_WIDTH}px`, + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '14px', + boxSizing: 'border-box', + overflowY: 'auto', + background: '#0b1019', + borderLeft: '1px solid rgba(154, 193, 255, 0.18)', + color: '#dce8ff', + font: `14px ${FONT}`, + zIndex: '5', + }); + this.inspector.setAttribute('aria-label', 'Selected asset'); + Object.assign(this.heading.style, { margin: '0', fontSize: '19px', color: '#ffffff' }); + Object.assign(this.facts.style, { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px', margin: '0', fontSize: '13px' }); + Object.assign(this.resident.style, { marginTop: 'auto', fontSize: '12px', color: '#8aa0c4' }); + this.inspector.append(this.heading, this.facts, this.panelSlot, this.resident); + document.body.append(this.inspector); } - drawToolbar(context) { - const g = this.gToolbar; - g.clear(); - g.fillColor = C.toolbar; - g.drawRectangle(0, 0, W, TOOLBAR_H); - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(0, TOOLBAR_H - 1, W, TOOLBAR_H - 1); - g.drawLine(0, CAT_BTN_Y2 - 4, W, CAT_BTN_Y2 - 4); - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - g.fillColor = this.cat === CATEGORIES_ROW1[i].id ? C.active : C.btnDark; - g.lineWidth = 0; - g.drawRectangle(bx, CAT_BTN_Y1, CAT_BTN_W, CAT_BTN_H); - } - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - g.fillColor = this.cat === CATEGORIES_ROW2[i].id ? C.active : C.btnDark; - g.lineWidth = 0; - g.drawRectangle(bx, CAT_BTN_Y2, CAT_BTN_W, CAT_BTN_H); - } - for (let i = 0; i < BG_OPTIONS.length; i++) { - const { r, g: gv, b } = BG_OPTIONS[i]; - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - g.fillColor = new Color(r, gv, b); - g.lineWidth = this.bgIdx === i ? 2 : 1; - g.lineColor = this.bgIdx === i ? C.accent : C.border; - g.drawRectangle(bx, BG_BTN_Y, BG_BTN_SIZE, BG_BTN_SIZE); - } - context.render(g); - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - const t = this.catBtnTexts[i]; - t.text = CATEGORIES_ROW1[i].label; - t.setPosition(bx + (CAT_BTN_W - 22) / 2, CAT_BTN_Y1 + 12); - context.render(t); - } - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - const t = this.catBtnTexts[CATEGORIES_ROW1.length + i]; - t.text = CATEGORIES_ROW2[i].label; - t.setPosition(bx + (CAT_BTN_W - 22) / 2, CAT_BTN_Y2 + 12); - context.render(t); - } - for (let i = 0; i < BG_OPTIONS.length; i++) { - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - const t = this.bgBtnTexts[i]; - t.text = BG_OPTIONS[i].label; - t.style.fillColor = i === 1 ? C.white : C.dim; - t.setPosition(bx + 7, BG_BTN_Y + 7); - context.render(t); - } + renderFacts(item, facts) { + const manifest = this.manifest.get(item.key); + const rows = [ + ['Asset', `assets.${item.key}`], + ['File', fileName(assets[item.key].source)], + ...(manifest ? [['Size', formatBytes(manifest.bytes + (item.atlasKey ? (this.manifest.get(item.atlasKey)?.bytes ?? 0) : 0))]] : []), + ...facts, + ]; + this.heading.textContent = item.name; + this.facts.replaceChildren( + ...rows.flatMap(([term, value]) => { + const dt = document.createElement('dt'); + const dd = document.createElement('dd'); + dt.textContent = term; + dd.textContent = value; + dt.style.color = '#8aa0c4'; + Object.assign(dd.style, { margin: '0', overflowWrap: 'anywhere', color: '#f3f7ff' }); + return [dt, dd]; + }), + ); } - drawSidebar(context) { - const g = this.gSidebar; - g.clear(); - g.fillColor = C.panel; - g.drawRectangle(0, TOOLBAR_H, SIDEBAR_W, H - TOOLBAR_H); - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(SIDEBAR_W, TOOLBAR_H, SIDEBAR_W, H); - const keys = this.keys(); - for (let i = 0; i < keys.length; i++) { - const iy = LIST_Y + (i - this.scrollOff) * ITEM_H; - if (iy + ITEM_H <= TOOLBAR_H || iy >= H) { - continue; - } - const isHeader = this.cat === 'technical' && !keys[i].includes('.'); - const isSelected = !isHeader && keys[i] === this.key; - const isHover = !isHeader && this.hoverIdx === i && !isSelected; - if (isHeader) { - g.fillColor = C.infoBar; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); - } else if (isSelected) { - g.fillColor = C.active; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); - } else if (isHover) { - g.fillColor = C.hover; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); - } - } - context.render(g); - for (let i = 0; i < keys.length && i - this.scrollOff < this.itemTexts.length; i++) { - const iy = LIST_Y + (i - this.scrollOff) * ITEM_H; - if (iy + ITEM_H <= TOOLBAR_H || iy >= H) { - continue; - } - if (this.cat === 'technical' && !keys[i].includes('.')) { - const ht = this.techHeaderTexts.get(keys[i]); - if (ht) { - ht.setPosition(10, iy + 12); - context.render(ht); - } - continue; - } - const t = this.itemTexts[i - this.scrollOff]; - if (!t) { - continue; - } - t.text = this.cat === 'technical' ? (keys[i].split('.')[1] ?? keys[i]) : keys[i]; - t.style.fillColor = keys[i] === this.key ? C.white : C.dim; - t.setPosition(this.cat === 'technical' ? 18 : 10, iy + 10); - context.render(t); - } - if (keys.length === 0) { - this.txtEmptyCat.setPosition(60, LIST_Y + 14); - context.render(this.txtEmptyCat); - } + /** Lists what the loader holds right now: the manifest plus the current selection, never an earlier one. */ + renderResident() { + const rows = this.app.loader.inspect(); + const summary = document.createElement('summary'); + const list = document.createElement('ul'); + summary.textContent = `Loader holds ${rows.length} asset${rows.length === 1 ? '' : 's'}`; + summary.style.cursor = 'pointer'; + Object.assign(list.style, { margin: '6px 0 0', paddingLeft: '16px', font: `11px ${MONO}` }); + list.append( + ...rows.map(row => { + const entry = document.createElement('li'); + entry.textContent = `${fileName(row.locator)} (${row.state})`; + return entry; + }), + ); + this.resident.dataset.residentCount = String(rows.length); + this.resident.replaceChildren(summary, list); } - drawPreviewBg(context) { - const { r, g, b } = BG_OPTIONS[this.bgIdx]; - const bg = this.gPreview; - bg.clear(); - bg.fillColor = new Color(r, g, b); - bg.drawRectangle(PREVIEW_X, PREVIEW_Y, PREVIEW_W, PREVIEW_H); - context.render(bg); + // --- Code and data panel -------------------------------------------------- + mountCodePanel() { + const header = document.createElement('div'); + const copy = document.createElement('button'); + const close = document.createElement('button'); + Object.assign(this.codePanel.style, { + position: 'fixed', + left: `${PREVIEW.x + 12}px`, + width: `${PREVIEW.width - 24}px`, + bottom: '12px', + maxHeight: '420px', + display: 'none', + flexDirection: 'column', + borderRadius: '10px', + border: '1px solid rgba(154, 193, 255, 0.3)', + background: 'rgba(8, 12, 20, 0.96)', + color: '#f3f7ff', + font: `14px ${FONT}`, + zIndex: '6', + }); + this.codePanel.setAttribute('aria-label', 'Code'); + Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px' }); + this.codeTitle.style.flex = '1 1 auto'; + for (const [button, label] of [ + [copy, 'Copy'], + [close, 'Close'], + ]) { + button.type = 'button'; + button.textContent = label; + Object.assign(button.style, chipStyle(false)); + } + copy.addEventListener('click', () => { + void navigator.clipboard.writeText(this.code.textContent ?? '').then( + () => { + copy.textContent = 'Copied'; + console.log(`Copied the ${this.codeTitle.textContent ?? 'code'} to the clipboard`); + }, + error => console.warn('The clipboard refused the copy:', error), + ); + }); + copy.addEventListener('blur', () => (copy.textContent = 'Copy')); + close.addEventListener('click', () => this.hideCode()); + Object.assign(this.code.style, { + margin: '0', + padding: '0 12px 12px', + overflow: 'auto', + font: `13px/1.45 ${MONO}`, + whiteSpace: 'pre', + color: '#dce8ff', + }); + this.code.tabIndex = 0; + header.append(this.codeTitle, copy, close); + this.codePanel.append(header, this.code); + document.body.append(this.codePanel); } - drawPreviewContent(context) { - if (!this.key) { - this.txtNoSel.setPosition(PREVIEW_X + PREVIEW_W / 2 - 130, H / 2 - 10); - context.render(this.txtNoSel); - this.drawInfoBar(context); - return; - } - // Category still fetching (lazy-load): keep the sidebar interactive - // and show a placeholder instead of an empty preview. - if (!this.loadedCats.has(this.cat)) { - this.txtLoading.setPosition(PREVIEW_X + PREVIEW_W / 2 - 70, H / 2 - 10); - context.render(this.txtLoading); - this.drawInfoBar(context); - return; - } - switch (this.cat) { - case 'textures': - this.drawTexPreview(context); - break; - case 'sprites': - this.drawSprPreview(context); - break; - case 'spritesheets': - this.drawSshPreview(context); - break; - case 'svg': - this.drawSvgPreview(context); - break; - case 'audio': - this.drawAudioPreview(context, this.audioMusics); - break; - case 'sound': - this.drawAudioPreview(context, this.soundMusics); - break; - case 'music': - this.drawAudioPreview(context, this.musicMusics); - break; - case 'soundSprites': - this.drawSoundSpritePreview(context); - break; - case 'fonts': - this.drawFontPreview(context); - break; - case 'video': - this.drawVideoPreview(context); - break; - case 'inputPrompts': - this.drawInpPreview(context); - break; - case 'technical': - this.drawTechPreview(context); - break; - case 'backgrounds': - this.drawBgPreview(context); - break; - case 'cursors': - this.drawCursorPreview(context); - break; - case 'tilesets': - this.drawTilesetPreview(context); - break; - case 'vendor': - this.drawVendorPreview(context); - break; + showCode() { + if (this.preview) { + this.openCodePanel('Code', this.preview.snippet()); } - this.drawInfoBar(context); } - drawInfoBar(context) { - const g = this.gInfoBar; - g.clear(); - g.fillColor = C.infoBar; - g.drawRectangle(PREVIEW_X, PREVIEW_Y, PREVIEW_W, 44); - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(PREVIEW_X, PREVIEW_Y + 44, W, PREVIEW_Y + 44); - context.render(g); - if (!this.key) { + async showData() { + const preview = this.preview; + const generation = this.generation; + if (!preview?.data) { return; } - this.txtKey.text = this.assetExpression(); - this.txtKey.setPosition(PREVIEW_X + 10, PREVIEW_Y + 5); - context.render(this.txtKey); - this.txtPath.text = this.assetPath(); - this.txtPath.setPosition(PREVIEW_X + 10, PREVIEW_Y + 24); - context.render(this.txtPath); - const ext = this.typeLabel(); - if (ext) { - this.txtType.text = ext; - this.txtType.setPosition(PREVIEW_X + PREVIEW_W - 160, PREVIEW_Y + 15); - context.render(this.txtType); - } - if (!this.copyBtnBg) { - this.copyBtnBg = new Graphics(); + this.openCodePanel('Data', 'Loading...'); + try { + const data = await preview.data(); + if (generation !== this.generation) { + return; + } + const text = JSON.stringify(data, null, 2); + const limit = 60_000; + this.openCodePanel('Data', text.length > limit ? `${text.slice(0, limit)}\n... ${text.length - limit} more characters` : text); + } catch (error) { + if (generation === this.generation && !isAbort(error)) { + this.openCodePanel('Data', `Could not load the data: ${error instanceof Error ? error.message : String(error)}`); + } } - const copyX = PREVIEW_X + PREVIEW_W - 84; - const copyY = PREVIEW_Y + 9; - this.copyBtnBg.clear(); - this.copyBtnBg.fillColor = C.btnDark; - this.copyBtnBg.lineWidth = 1; - this.copyBtnBg.lineColor = C.border; - this.copyBtnBg.drawRectangle(copyX, copyY, 72, 26); - context.render(this.copyBtnBg); - this.txtCopy.setPosition(copyX + 10, copyY + 7); - context.render(this.txtCopy); } - previewCenter() { - return { - cx: PREVIEW_X + PREVIEW_W / 2, - cy: PREVIEW_Y + 44 + (PREVIEW_H - 44) / 2, - maxW: PREVIEW_W - 80, - maxH: PREVIEW_H - 80, - }; + openCodePanel(title, text) { + this.codeTitle.textContent = title; + this.code.textContent = text; + this.codePanel.style.display = 'flex'; } - fitSprite(sprite, maxW, maxH, cx, cy) { - const tex = sprite.texture; - if (!tex) { - return; - } - const tw = tex.width || 128; - const th = tex.height || 128; - const scale = Math.min(maxW / tw, maxH / th, 3); - sprite.setScale(scale); - sprite.setPosition(cx, cy); + hideCode() { + this.codePanel.style.display = 'none'; } - drawTexPreview(context) { - const sprite = this.texSprites.get(this.key ?? ''); - if (!sprite) { - return; - } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); +} +const chipStyle = active => ({ + padding: '4px 9px', + border: `1px solid ${active ? 'rgba(127, 184, 255, 0.7)' : 'rgba(154, 193, 255, 0.28)'}`, + borderRadius: '999px', + background: active ? 'rgba(127, 184, 255, 0.24)' : 'transparent', + color: active ? '#ffffff' : '#c4d2ec', + font: `600 12px ${FONT}`, + cursor: 'pointer', +}); +// --- Previews --------------------------------------------------------------- +const openPreview = context => { + switch (context.item.kind) { + case 'texture': + case 'svg': + return openTexture(context); + case 'spritesheet': + return openSpritesheet(context); + case 'tiled': + case 'ldtk': + return openMap(context); + case 'sound': + case 'music': + return openAudio(context); + case 'video': + return openVideo(context); + case 'font': + case 'bmFont': + return openFont(context); + case 'data': + return openData(context); } - drawSprPreview(context) { - const ss = this.sprSheets.get(this.key ?? ''); - if (!ss) { - return; - } - const frames = [...ss.sprites.keys()]; - if (!frames.length) { - return; +}; +const BACKDROPS = { Checker: null, Dark: new Color(32, 37, 50), Light: new Color(236, 239, 244), Black: new Color(0, 0, 0) }; +const BACKDROP_NAMES = Object.keys(BACKDROPS); +/** A transparency checkerboard behind a `width` x `height` image. */ +const checkerboard = (width, height) => { + const graphics = new Graphics(); + const cell = Math.max(4, Math.round(Math.max(width, height) / 32)); + graphics.fillColor = new Color(58, 64, 78); + graphics.drawRectangle(0, 0, width, height); + graphics.fillColor = new Color(86, 93, 110); + for (let y = 0; y < height; y += cell) { + for (let x = (y / cell) % 2 === 0 ? 0 : cell; x < width; x += cell * 2) { + graphics.drawRectangle(x, y, Math.min(cell, width - x), Math.min(cell, height - y)); } - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); } - drawSshPreview(context) { - const ss = this.sshSheets.get(this.key ?? ''); - if (!ss) { - return; - } - const frames = [...ss.sprites.keys()]; - if (!frames.length) { - return; + return graphics; +}; +const zoomSlider = (value = 1) => slider('Zoom', { min: 0.05, max: 16, step: 0.05, value, format: zoom => `${Math.round(zoom * 100)}%` }); +const openTexture = async ({ scene, item, scope }) => { + const descriptor = assets[item.key]; + const svg = descriptor.type === 'svg'; + // An SVG loads as an image element; a texture over it is this preview's to destroy. + const texture = svg ? new Texture(await scope.load(descriptor)) : await scope.load(descriptor); + const sprite = new Sprite(texture); + const backdrop = checkerboard(texture.width, texture.height); + const state = { zoom: 1, nearest: false, backdrop: 'Checker' }; + return { + world: [backdrop, sprite], + overlay: [], + bounds: { width: texture.width, height: texture.height }, + facts: [ + ['Pixels', `${texture.width} x ${texture.height}`], + ['Format', svg ? 'SVG, rasterized at its intrinsic size' : (fileName(descriptor.source).split('.').pop() ?? '').toUpperCase()], + ], + mount(host) { + const panel = host({ + title: 'Texture', + controls: { + zoom: zoomSlider(), + filtering: choice('Filtering', ['Linear', 'Nearest']), + backdrop: choice('Backdrop', BACKDROP_NAMES), + }, + actions: { Fit: () => scene.fit(), '100%': () => panel.set('zoom', 1), Code: () => scene.showCode() }, + onChange(key, _value, values) { + state.zoom = values.zoom; + state.nearest = values.filtering === 'Nearest'; + state.backdrop = values.backdrop; + if (key === 'zoom') { + scene.setZoom(values.zoom); + } else if (key === 'filtering') { + // Sampling is per texture handle; this preview's scope owns this one. + texture.scaleMode = state.nearest ? ScaleModes.Nearest : ScaleModes.Linear; + } else if (key === 'backdrop') { + backdrop.visible = values.backdrop === 'Checker'; + scene.setBackdrop(BACKDROPS[values.backdrop] ?? new Color(32, 37, 50)); + } + }, + }); + scene.bindZoom(panel); + }, + snippet: () => + textureSnippet(item, { + zoom: state.zoom, + nearest: state.nearest, + width: texture.width, + height: texture.height, + }), + dispose() { + sprite.destroy(); + backdrop.destroy(); + if (svg) { + texture.destroy(); + } + }, + }; +}; +const openSpritesheet = async ({ scene, item, scope }) => { + const [texture, data] = await Promise.all([scope.load(assets[item.key]), scope.load(assets[item.atlasKey])]); + const sheet = new Spritesheet(texture, data); + const names = [...sheet.frames.keys()]; + const animations = [...sheet.animations.keys()]; + const atlas = new Sprite(texture); + const backdrop = checkerboard(texture.width, texture.height); + const outlines = new Graphics(); + const strip = new Graphics(); + const frameLabel = new Text('', { fontSize: 15, fillColor: new Color(200, 214, 236) }); + const animationLabel = new Text('', { fontSize: 15, fillColor: new Color(200, 214, 236) }); + const animated = new AnimatedSprite(texture).setAnchor(0.5); + const selectedSprite = new Sprite(texture).setAnchor(0.5); + const STRIP_Y = PREVIEW.height - 208; + const boxWidth = (PREVIEW.width - 36) / 2; + const frameBox = { x: PREVIEW.x + 12, y: STRIP_Y, width: boxWidth, height: 196 }; + const animationBox = { x: frameBox.x + boxWidth + 12, y: STRIP_Y, width: boxWidth, height: 196 }; + const state = { + animation: animations.length > 0 ? animations[0] : 'Frame range', + first: 0, + last: Math.min(names.length - 1, 1), + selected: 0, + fps: 8, + loop: true, + scale: 1, + bounds: true, + }; + const clipFrames = () => + state.animation === 'Frame range' + ? names.slice(Math.min(state.first, state.last), Math.max(state.first, state.last) + 1) + : [...sheet.getAnimationFrameNames(state.animation)]; + const fitInBox = (sprite, rect, box, scale) => { + // The two-line label takes the top 44 px of the box. + const room = box.height - 52; + sprite.setScale(Math.min(scale, room / rect.height)).setPosition(box.x + box.width / 2, box.y + 44 + (box.height - 44) / 2); + }; + const redraw = () => { + const range = new Set(clipFrames()); + outlines.clear(); + if (state.bounds) { + for (const [name, rect] of sheet.frames) { + const selected = names.indexOf(name) === state.selected; + outlines.lineWidth = selected ? 3 : 1; + outlines.lineColor = selected + ? new Color(255, 214, 102) + : range.has(name) + ? new Color(127, 220, 160, 0.95) + : new Color(127, 184, 255, 0.55); + outlines.drawRectangle(rect.x, rect.y, rect.width, rect.height); + } } - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); + const selectedName = names[state.selected]; + const selectedRect = sheet.getFrame(selectedName); + selectedSprite.setTextureFrame(selectedRect); + fitInBox(selectedSprite, selectedRect, frameBox, 4); + frameLabel.text = `Frame ${state.selected + 1}/${names.length} +${selectedName}, ${selectedRect.width} x ${selectedRect.height}`; + const frames = clipFrames().map(name => sheet.getFrame(name)); + animated.setClips({ preview: { frames, fps: state.fps, repeat: state.loop ? -1 : 1 } }).play('preview', { restart: true }); + fitInBox(animated, frames[0], animationBox, state.scale); + animationLabel.text = `Animation: ${state.animation === 'Frame range' ? `frames ${Math.min(state.first, state.last) + 1}-${Math.max(state.first, state.last) + 1}` : state.animation} +${frames.length} frames, ${state.fps} fps${state.loop ? ', loop' : ''}`; + }; + strip.fillColor = new Color(11, 16, 25, 0.94); + strip.drawRectangle(PREVIEW.x, STRIP_Y - 12, PREVIEW.width, PREVIEW.height - STRIP_Y + 12); + strip.fillColor = new Color(24, 30, 44); + for (const box of [frameBox, animationBox]) { + strip.drawRectangle(box.x, box.y, box.width, box.height); } - drawInpPreview(context) { - const ss = this.inpSheets.get(this.key ?? ''); - if (ss) { - const frames = [...ss.sprites.keys()]; - if (frames.length > 0) { - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); - return; + frameLabel.setPosition(frameBox.x + 10, frameBox.y + 6); + animationLabel.setPosition(animationBox.x + 10, animationBox.y + 6); + redraw(); + return { + world: [backdrop, atlas, outlines], + overlay: [strip, selectedSprite, animated, frameLabel, animationLabel], + // Fit the atlas into the area above the frame strip. + bounds: { width: texture.width, height: texture.height * (PREVIEW.height / (STRIP_Y - 24)) }, + facts: [ + ['Atlas', `${texture.width} x ${texture.height} px`], + ['Frames', String(names.length)], + ['Animations', animations.length > 0 ? animations.join(', ') : 'none in the atlas data - pick a frame range'], + ], + mount(host) { + const last = Math.max(0, names.length - 1); + const frameName = index => `#${Math.round(index) + 1}`; + const controls = { + zoom: zoomSlider(), + animation: choice('Animation', ['Frame range', ...animations], state.animation), + first: slider('First frame', { min: 0, max: last, step: 1, value: state.first, format: frameName }), + last: slider('Last frame', { min: 0, max: last, step: 1, value: state.last, format: frameName }), + fps: slider('FPS', { min: 1, max: 30, step: 1, value: state.fps, format: fps => `${fps}` }), + loop: toggle('Loop', state.loop), + scale: slider('Scale', { min: 0.25, max: 6, step: 0.25, value: state.scale, format: scale => `${scale}x` }), + bounds: toggle('Frame bounds', state.bounds), + }; + const handle = host({ + title: 'Spritesheet', + controls, + actions: { + 'Range from selected': () => handle.set('first', state.selected), + 'Range to selected': () => handle.set('last', state.selected), + Fit: () => scene.fit(), + Code: () => scene.showCode(), + Data: () => void scene.showData(), + }, + onChange(key, _value, values) { + if (key === 'zoom') { + scene.setZoom(values.zoom); + return; + } + Object.assign(state, { + animation: values.animation, + first: values.first, + last: values.last, + fps: values.fps, + loop: values.loop, + scale: values.scale, + bounds: values.bounds, + }); + redraw(); + }, + }); + scene.bindZoom(handle); + handle.status('Click a frame in the atlas to select it.'); + }, + update(delta) { + animated.update(delta); + }, + tap(x, y) { + const index = names.findIndex(name => sheet.getFrame(name).contains(x, y)); + if (index >= 0) { + state.selected = index; + redraw(); + console.log(`Frame ${index + 1}: ${names[index]}`); + } + }, + snippet: () => spritesheetSnippet(item, { ...state, frames: clipFrames() }), + data: () => Promise.resolve(data), + dispose() { + animated.destroy(); + selectedSprite.destroy(); + atlas.destroy(); + backdrop.destroy(); + outlines.destroy(); + strip.destroy(); + frameLabel.destroy(); + animationLabel.destroy(); + sheet.destroy(); + }, + }; +}; +const openMap = async ({ scene, item, scope }) => { + const source = assets[item.key].source; + const ldtk = item.kind === 'ldtk'; + // Tileset images resolve relative to the map file, which is why the map + // loads from the catalog's absolute URL through the format's own type. + const world = ldtk ? await scope.load(Asset.type('ldtkMap', source)) : null; + const levels = world ? world.levels : [await scope.load(Asset.type('tileMap', source))]; + const state = { level: 0, objects: true, zoom: 1, layers: new Map() }; + const objects = new Graphics(); + let node = null; + const preview = { + world: [], + overlay: [], + bounds: null, + facts: [], + mount(host) { + const map = levels[state.level]; + const layerControls = Object.fromEntries( + map.renderableLayers.map(layer => [`layer:${layer.name}`, toggle(`Layer ${layer.name}`, true)]), + ); + const panel = host({ + title: ldtk ? 'LDtk world' : 'Tiled map', + controls: { + zoom: zoomSlider(), + ...(levels.length > 1 + ? { + level: choice( + 'Level', + levels.map(level => level.name), + ), + } + : {}), + objects: toggle('Object shapes', state.objects), + ...layerControls, + }, + actions: { Fit: () => scene.fit(), Code: () => scene.showCode(), Data: () => void scene.showData() }, + onChange(key, value) { + if (key === 'zoom') { + state.zoom = value; + scene.setZoom(value); + } else if (key === 'level') { + state.level = Math.max( + 0, + levels.findIndex(level => level.name === value), + ); + build(); + scene.fit(); + } else if (key === 'objects') { + state.objects = value; + objects.visible = state.objects; + } else if (String(key).startsWith('layer:')) { + const name = String(key).slice('layer:'.length); + const layerNode = node?.getLayerNode(name); + state.layers.set(name, value); + if (layerNode) { + layerNode.visible = value; + } + } + }, + }); + scene.bindZoom(panel); + }, + snippet: () => mapSnippet(item, { level: levels[state.level].name, zoom: state.zoom, ldtk }), + data: async () => scope.load(assets[item.key]), + dispose() { + node?.destroy(); + objects.destroy(); + }, + }; + const build = () => { + const map = levels[state.level]; + node?.destroy(); + node = new TileMapNode(map); + objects.clear(); + objects.lineWidth = 2; + objects.lineColor = new Color(255, 191, 82, 0.9); + objects.fillColor = new Color(255, 191, 82, 0.16); + let count = 0; + for (const layer of map.objectLayers) { + for (const object of layer.objects) { + count += 1; + if (object.kind === ObjectKind.Point) { + objects.drawCircle(object.x, object.y, 6); + } else if (object.kind === ObjectKind.Ellipse) { + objects.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); + } else { + objects.drawRectangle(object.x, object.y, object.width, object.height); + } } } - } - drawSvgPreview(context) { - const sprite = this.svgSprites.get(this.key ?? ''); - if (!sprite) { + objects.visible = state.objects; + preview.world.splice(0, preview.world.length, node, objects); + Object.assign(preview, { + bounds: { width: map.pixelWidth ?? (map.width ?? 0) * map.tileWidth, height: map.pixelHeight ?? (map.height ?? 0) * map.tileHeight }, + facts: [ + ['Format', ldtk ? 'LDtk world' : 'Tiled map'], + ...(levels.length > 1 ? [['Levels', levels.map(level => level.name).join(', ')]] : []), + ['Tiles', `${map.width ?? 'unbounded'} x ${map.height ?? 'unbounded'} at ${map.tileWidth} x ${map.tileHeight} px`], + ['Layers', map.renderableLayers.map(layer => layer.name).join(', ') || 'none'], + ['Objects', String(count)], + ], + }); + }; + build(); + return preview; +}; +const openAudio = async ({ scene, item, scope, isCurrent }) => { + const descriptor = assets[item.key]; + const music = descriptor.type === 'music'; + // A sound sprite ships its clip table as JSON next to the audio file. + const spriteTable = demoAssetCategories[item.key] === 'soundSprites' ? descriptor.source.replace(/\.[a-z0-9]+$/, '.json') : null; + const [playable, table] = await Promise.all([ + music ? scope.load(descriptor) : scope.load(descriptor), + spriteTable ? scope.load(Asset.type('json', spriteTable)) : Promise.resolve(null), + ]); + const clips = table ? Object.keys(table) : []; + const audio = scene.app.audio; + const state = { clip: clips[0] ?? '', volume: 0.8, loop: false }; + let voice = null; + let pendingStart = null; + let status = null; + let reported = ''; + const waveform = new Graphics(); + const playhead = new Graphics(); + const live = new Graphics(); + const analyser = new AudioAnalyser(); + const bright = new Color(127, 184, 255); + const dim = new Color(127, 184, 255, 0.25); + const muted = new Color(138, 160, 196); + const buffer = music ? null : playable.audioBuffer; + const channels = buffer?.numberOfChannels ?? 0; + const channelNames = channels === 2 ? ['L', 'R'] : Array.from({ length: channels }, (_, channel) => String(channel + 1)); + const wave = { width: PREVIEW.width - 80, height: channels > 1 ? 300 : 220, gap: 16, left: PREVIEW.x + 40 }; + // A sound keeps its overview on top and the live signal in a strip below; + // streamed music has no overview, so the live signal takes the middle. + const scopeHeight = buffer ? 100 : 220; + const top = buffer ? PREVIEW_CENTER.y - (wave.height + 48 + scopeHeight) / 2 : PREVIEW_CENTER.y - scopeHeight / 2; + const lane = { height: (wave.height - wave.gap * Math.max(0, channels - 1)) / Math.max(1, channels), top }; + const strip = { top: buffer ? top + wave.height + 48 : top, height: scopeHeight }; + const label = (text, y) => + new Text(text, { fontSize: 14, fontFamily: MONO, fillColor: muted }).setAnchor(1, 0.5).setPosition(wave.left - 10, y); + const labels = [ + ...(channels > 1 + ? channelNames.map((name, channel) => label(name, lane.top + channel * (lane.height + wave.gap) + lane.height / 2)) + : []), + ...(buffer + ? [ + new Text('Live signal', { fontSize: 14, fontFamily: MONO, fillColor: muted }) + .setAnchor(0, 1) + .setPosition(wave.left, strip.top - 8), + ] + : []), + ]; + const caption = new Text(music ? 'Press Play to see the live signal' : '', { fontSize: 18, fillColor: muted }); + // A decoded sound draws one lane per channel: a min/max bar per column, with + // the selected sprite clip highlighted across all lanes. + const drawWaveform = () => { + waveform.clear(); + if (!buffer) { return; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); - } - drawAudioPreview(context, musicMap) { - if (!this.audioG) { - this.audioG = new Graphics(); - } - const music = musicMap.get(this.key ?? ''); - const isPlaying = music ? this.previewIsPlaying() : false; - const g = this.audioG; - const { cx, cy } = this.previewCenter(); - const bx = cx - 50; - const by = cy - 28; - g.clear(); - g.fillColor = isPlaying ? C.btnGreen : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 100, 56); - context.render(g); - this.txtAudioIcon.text = isPlaying ? '⏸' : '▶'; - this.txtAudioIcon.setPosition(bx + (isPlaying ? 30 : 34), by + 10); - context.render(this.txtAudioIcon); - this.txtMeta.text = this.typeLabel(); - this.txtMeta.setPosition(cx - 15, by + 68); - context.render(this.txtMeta); - } - drawSoundSpritePreview(context) { - if (!this.audioG) { - this.audioG = new Graphics(); - } - const music = this.soundSpriteAudio.get(this.key ?? ''); - const data = this.soundSpriteData.get(this.key ?? ''); - const isPlaying = music ? this.previewIsPlaying() : false; - const sprites = data?.sprites ?? {}; - const g = this.audioG; - const { cx } = this.previewCenter(); - const bx = cx - 50; - const by = PREVIEW_Y + 56; - g.clear(); - g.fillColor = isPlaying ? C.btnGreen : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 100, 44); - context.render(g); - this.txtAudioIcon.text = isPlaying ? '⏸' : '▶'; - this.txtAudioIcon.setPosition(bx + (isPlaying ? 30 : 34), by + 6); - context.render(this.txtAudioIcon); - let y = by + 64; - for (const [name, info] of Object.entries(sprites)) { - this.txtMeta.text = `${name} start:${info.start.toFixed(3)}s dur:${info.duration.toFixed(3)}s`; - this.txtMeta.setPosition(PREVIEW_X + 30, y); - context.render(this.txtMeta); - y += 20; - if (y > H - 20) { - break; + const clip = table && state.clip ? table[state.clip] : undefined; + if (clip) { + waveform.fillColor = new Color(127, 184, 255, 0.16); + waveform.drawRectangle( + wave.left + (clip.start / buffer.duration) * wave.width, + lane.top, + ((clip.end - clip.start) / buffer.duration) * wave.width, + wave.height, + ); + } + for (let channel = 0; channel < channels; channel += 1) { + const samples = buffer.getChannelData(channel); + const middle = lane.top + channel * (lane.height + wave.gap) + lane.height / 2; + const scale = lane.height / 2; + waveform.fillColor = dim; + waveform.drawRectangle(wave.left, middle - 0.5, wave.width, 1); + waveform.fillColor = bright; + for (let column = 0; column < wave.width; column += 2) { + const from = Math.floor((column / wave.width) * samples.length); + const to = Math.max(from + 1, Math.floor(((column + 2) / wave.width) * samples.length)); + let low = 0; + let high = 0; + for (let index = from; index < to; index += 1) { + low = Math.min(low, samples[index]); + high = Math.max(high, samples[index]); + } + waveform.drawRectangle(wave.left + column, middle - high * scale, 1.5, Math.max(1, (high - low) * scale)); } } - } - drawFontPreview(context) { - const family = this.fontFamilies.get(this.key ?? ''); - if (!family) { - this.txtMeta.text = `Font: ${this.key}\n${this.assetPath()}`; - this.txtMeta.setPosition(PREVIEW_X + 40, PREVIEW_Y + 80); - context.render(this.txtMeta); + }; + // The analyser's window is the last few milliseconds the voice produced, + // down-mixed to one channel - an oscilloscope, not a second overview. + const drawLive = signal => { + const middle = strip.top + strip.height / 2; + const scale = strip.height / 2; + live.clear(); + live.fillColor = dim; + live.drawRectangle(wave.left, middle - 0.5, wave.width, 1); + if (!signal) { return; } - let t = this.fontSampleTexts.get(this.key ?? ''); - if (!t) { - t = new Text('AaBbCc 123 !?', { fontFamily: family, fontSize: 48, fillColor: C.white }); - this.fontSampleTexts.set(this.key ?? '', t); - } - t.style.fillColor = this.bgIdx === 0 ? C.black : C.white; - t.setPosition(PREVIEW_X + 40, PREVIEW_Y + 120); - context.render(t); - let t2 = this.fontSampleTexts.get(`${this.key}_sm`); - if (!t2) { - t2 = new Text('The quick brown fox jumps over the lazy dog', { fontFamily: family, fontSize: 20, fillColor: C.dim }); - this.fontSampleTexts.set(`${this.key}_sm`, t2); - } - t2.style.fillColor = this.bgIdx === 0 ? C.dimDark : C.dim; - t2.setPosition(PREVIEW_X + 40, PREVIEW_Y + 190); - context.render(t2); - } - drawVideoPreview(context) { - this.txtMeta.text = `VIDEO\n\nKey: assets.video.${this.key}\nURL: ${this.assetPath()}\n\nOpen the URL in your browser to preview.`; - this.txtMeta.setPosition(PREVIEW_X + 40, PREVIEW_Y + 80); - context.render(this.txtMeta); - } - drawTechPreview(context) { - if (!this.key?.includes('.')) { - return; + live.fillColor = bright; + for (let column = 0; column < wave.width; column += 2) { + const from = Math.floor((column / wave.width) * signal.length); + const to = Math.max(from + 1, Math.floor(((column + 2) / wave.width) * signal.length)); + let low = 0; + let high = 0; + for (let index = from; index < to; index += 1) { + low = Math.min(low, signal[index]); + high = Math.max(high, signal[index]); + } + live.drawRectangle(wave.left + column, middle - high * scale, 1.5, Math.max(1, (high - low) * scale)); } - const sprite = this.techSprites.get(this.key); - if (!sprite) { + }; + const drawPlayhead = () => { + playhead.clear(); + if (!buffer || !voice || voice.ended || !('time' in voice)) { return; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 28, cx, cy - 14); - context.render(sprite); - const subcat = this.key.split('.')[0]; - const purpose = TECH_PURPOSE[subcat] ?? ''; - if (purpose) { - this.txtTechPurpose.text = purpose; - this.txtTechPurpose.setPosition(PREVIEW_X + 16, PREVIEW_Y + PREVIEW_H - 22); - context.render(this.txtTechPurpose); - } - } - drawBgPreview(context) { - const sprite = this.bgSprites.get(this.key ?? ''); - if (!sprite) { + // A sprite voice reports its time from the start of its clip. + const offset = table && state.clip ? (table[state.clip]?.start ?? 0) : 0; + const x = wave.left + ((offset + voice.time) / buffer.duration) * wave.width; + playhead.fillColor = Color.white; + playhead.drawRectangle(x - 0.75, lane.top, 1.5, wave.height); + }; + caption.setAnchor(0.5).setPosition(PREVIEW_CENTER.x, strip.top + strip.height + 40); + drawWaveform(); + drawLive(null); + const target = () => (clips.length > 0 ? playable.sprite(state.clip) : playable); + const pausable = candidate => (candidate && 'pause' in candidate && 'resume' in candidate ? candidate : null); + const play = () => { + const current = pausable(voice); + if (current && !current.ended && current.paused) { + current.resume(); return; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); - } - drawCursorPreview(context) { - const sprite = this.cursorSprites.get(this.key ?? ''); - if (!sprite) { + if (pendingStart || (voice && !voice.ended)) { return; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, Math.min(maxW, 256), Math.min(maxH, 256), cx, cy); - context.render(sprite); - } - drawTilesetPreview(context) { - const sprite = this.tilesetSprites.get(this.key ?? ''); - if (!sprite) { + // Runs once audio is usable - at once when it already is, after the + // visitor's first gesture otherwise. Removed again by Stop and on switch, + // so a start that is still waiting never plays for another selection. + pendingStart = () => { + pendingStart = null; + if (!isCurrent()) { + return; + } + voice = audio.play(target(), { volume: state.volume, loop: state.loop }); + analyser.source = voice; + console.log(`Playing ${item.name}${clips.length > 0 ? ` / ${state.clip}` : ''}`); + }; + audio.onUnlock.add(pendingStart); + if (audio.locked) { + status?.('Waiting for the browser to allow audio...'); + } + }; + const stop = () => { + if (pendingStart) { + audio.onUnlock.remove(pendingStart); + pendingStart = null; + } + voice?.stop(); + voice = null; + analyser.source = null; + }; + return { + world: [], + overlay: [waveform, playhead, live, caption, ...labels], + bounds: null, + facts: [ + ['Kind', music ? 'Music, streamed from an audio element' : 'Sound, decoded into memory'], + ['Duration', formatSeconds(playable.duration)], + ...(buffer ? [['Channels', channels === 1 ? 'Mono' : channels === 2 ? 'Stereo' : String(channels)]] : []), + ...(clips.length > 0 ? [['Clips', clips.join(', ')]] : []), + ], + mount(host) { + const panel = host({ + title: music ? 'Music' : 'Sound', + controls: { + ...(clips.length > 0 ? { clip: choice('Clip', clips) } : {}), + volume: slider('Volume', { min: 0, max: 1, step: 0.05, value: state.volume, format: volume => `${Math.round(volume * 100)}%` }), + loop: toggle('Loop', state.loop), + }, + actions: { + Play: play, + Pause: () => { + const current = pausable(voice); + if (current && !current.ended) { + current.pause(); + } + }, + Stop: stop, + Code: () => scene.showCode(), + }, + onChange(key, value) { + if (key === 'clip') { + state.clip = value; + stop(); + drawWaveform(); + } else if (key === 'volume') { + state.volume = value; + if (voice) { + voice.volume = state.volume; + } + } else if (key === 'loop') { + state.loop = value; + } + }, + }); + status = text => panel.status(text); + panel.status('Press Play to listen.'); + }, + update() { + const current = pausable(voice); + const text = pendingStart + ? audio.locked + ? 'Waiting for the browser to allow audio...' + : 'Starting...' + : !voice || voice.ended + ? 'Stopped' + : current?.paused + ? 'Paused' + : 'Playing'; + if (text !== reported) { + reported = text; + status?.(text); + } + const playing = text === 'Playing' || text === 'Paused'; + caption.visible = !playing; + drawLive(playing ? analyser.getWaveformFloat() : null); + drawPlayhead(); + }, + snippet: () => audioSnippet(item, { ...state, music, clip: clips.length > 0 ? state.clip : null }), + dispose() { + stop(); + status = null; + analyser.destroy(); + waveform.destroy(); + playhead.destroy(); + live.destroy(); + caption.destroy(); + labels.forEach(text => text.destroy()); + }, + }; +}; +const openVideo = async ({ scene, item, scope, manifest, isCurrent }) => { + const video = await scope.load(assets[item.key]); + const element = video.videoElement; + const meta = manifest?.media; + const audio = scene.app.audio; + const state = { muted: true, loop: true, rate: '1x', zoom: 1 }; + let pendingStart = null; + let panel = null; + let syncing = false; + let mounted = false; + let since = 0; + const play = () => { + if (pendingStart || video.playing) { return; } - const entry = assets.demo.tilesets[this.key ?? '']; - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 30, cx, cy - 15); - context.render(sprite); - if (entry?.tileWidth && entry?.tileHeight) { - this.txtMeta.text = `tile: ${entry.tileWidth}×${entry.tileHeight}px`; - this.txtMeta.setPosition(PREVIEW_X + 16, PREVIEW_Y + PREVIEW_H - 22); - context.render(this.txtMeta); - } - } - drawVendorPreview(context) { - const data = this.vendorData.get(this.key ?? ''); - if (!data) { - this.txtMeta.text = `Loading: ${this.assetPath()}`; - this.txtMeta.setPosition(PREVIEW_X + 30, PREVIEW_Y + 80); - context.render(this.txtMeta); + // A muted video may start at once; one with sound waits for the gesture. + if (video.muted) { + video.play(); + console.log(`Playing ${item.name}`); return; } - const packs = data.packs ?? []; - const lines = [ - `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)`), - ]; - this.txtMeta.text = lines.join('\n'); - this.txtMeta.setPosition(PREVIEW_X + 30, PREVIEW_Y + 60); - context.render(this.txtMeta); - } - drawAnimControls(context, frameCount) { - if (!this.animG) { - this.animG = new Graphics(); - } - const g = this.animG; - const bx = PREVIEW_X + 16; - const by = H - 48; - g.clear(); - g.fillColor = this.animPlaying ? C.active : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 88, 30); - context.render(g); - this.txtAnimPlay.text = this.animPlaying ? '⏸ PAUSE' : '▶ PLAY'; - this.txtAnimPlay.setPosition(bx + 14, by + 8); - context.render(this.txtAnimPlay); - this.txtAnimFrame.text = `${(this.frameIdx % frameCount) + 1} / ${frameCount}`; - this.txtAnimFrame.setPosition(PREVIEW_X + 112, by + 10); - context.render(this.txtAnimFrame); + pendingStart = () => { + pendingStart = null; + if (isCurrent()) { + video.play(); + console.log(`Playing ${item.name} with sound`); + } + }; + audio.onUnlock.add(pendingStart); + }; + const cancelStart = () => { + if (pendingStart) { + audio.onUnlock.remove(pendingStart); + pendingStart = null; + } + }; + const frameRate = meta?.video?.frameRate?.split('/').map(Number); + return { + world: [video], + overlay: [], + bounds: { width: video.width, height: video.height }, + facts: [ + ['Resolution', `${element.videoWidth} x ${element.videoHeight}`], + ['Duration', formatSeconds(video.duration)], + ...(meta?.container ? [['Container', meta.container]] : []), + ...(meta?.video?.codec ? [['Video', [meta.video.codec, meta.video.profile].filter(Boolean).join(' ')]] : []), + ...(frameRate && frameRate.length === 2 + ? [['Frame rate', `${(frameRate[0] / frameRate[1]).toFixed(2).replace(/\.00$/, '')} fps`]] + : []), + ...(meta?.video?.pixelFormat + ? [['Pixels', `${meta.video.pixelFormat}${meta.video.bitDepth ? `, ${meta.video.bitDepth}-bit` : ''}`]] + : []), + ...(meta?.audio?.codec ? [['Audio', `${meta.audio.codec}, ${meta.audio.channels ?? '?'} ch`]] : [['Audio', 'none']]), + // The file's dynamic range is metadata; the preview renders it through + // the regular canvas pipeline either way. + ...(meta?.dynamicRange ? [['Dynamic range', `${meta.dynamicRange} (file metadata)`]] : []), + ], + mount(host) { + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1; + const handle = host({ + title: 'Video', + controls: { + zoom: zoomSlider(), + position: slider('Position', { min: 0, max: duration, step: duration / 200, value: 0, format: formatSeconds }), + muted: toggle('Muted', state.muted), + loop: toggle('Loop', state.loop), + rate: choice('Speed', ['0.5x', '1x', '2x'], state.rate), + }, + actions: { + Play: play, + Pause: () => { + cancelStart(); + video.pause(); + }, + Stop: () => { + cancelStart(); + video.stop(); + }, + Fit: () => scene.fit(), + Code: () => scene.showCode(), + }, + onChange(key, value) { + if (key === 'zoom') { + state.zoom = value; + scene.setZoom(value); + } else if (key === 'position' && !syncing) { + video.currentTime = value; + } else if (key === 'muted') { + state.muted = value; + video.muted = state.muted; + } else if (key === 'loop') { + state.loop = value; + video.loop = state.loop; + } else if (key === 'rate') { + state.rate = value; + video.playbackRate = Number.parseFloat(state.rate); + } + }, + }); + panel = handle; + mounted = true; + video.applyOptions({ loop: state.loop, muted: state.muted }); + video.pause(); + scene.bindZoom(handle); + handle.status('Press Play to start.'); + }, + update(delta) { + since += delta; + if (panel && since >= 0.25) { + since = 0; + syncing = true; + panel.set('position', video.currentTime); + syncing = false; + panel.status(pendingStart ? 'Waiting for the browser to allow audio...' : video.playing ? 'Playing' : 'Paused'); + } + }, + snippet: () => videoSnippet(item, state), + dispose() { + cancelStart(); + panel = null; + // The video belongs to the scope and may be shared with a newer preview + // of the same asset, so only a preview that was mounted pauses it. + if (mounted) { + video.pause(); + } + }, + }; +}; +const measureContext = document.createElement('canvas').getContext('2d'); +const measureWidth = (text, font) => { + if (!measureContext) { + return text.length * 0.6 * Number.parseFloat(font); } + measureContext.font = font; + return measureContext.measureText(text).width; +}; +const SAMPLE_TEXT = 'The quick brown fox jumps over the lazy dog'; +const openFont = async ({ scene, item, scope }) => { + const descriptor = assets[item.key]; + const bitmap = descriptor.type === 'bmFont'; + const font = bitmap ? await scope.load(descriptor) : null; + const face = bitmap ? null : await scope.load(descriptor); + const family = face ? face.family.replaceAll('"', '') : ''; + const state = { text: SAMPLE_TEXT, size: 40, color: 'White' }; + const colors = { White: Color.white, Gold: new Color(255, 214, 102), Ink: new Color(28, 32, 44) }; + const lines = []; + const preview = { + world: [], + overlay: [], + bounds: null, + facts: bitmap + ? [['Kind', 'Bitmap font (.fnt with page textures)']] + : [ + ['Family', family], + ['Kind', 'Font face, registered with the document'], + ], + mount(host) { + const panel = host({ + title: 'Font', + controls: { + size: slider('Size', { min: 10, max: 120, step: 2, value: state.size, format: size => `${size}px` }), + color: choice('Colour', ['White', 'Gold', 'Ink']), + }, + actions: { Code: () => scene.showCode() }, + onChange(key, value) { + if (key === 'size') { + state.size = value; + } else { + state.color = value; + scene.setBackdrop(state.color === 'Ink' ? new Color(236, 239, 244) : new Color(32, 37, 50)); + } + layout(); + }, + }); + const input = document.createElement('textarea'); + input.value = state.text; + input.rows = 2; + input.setAttribute('aria-label', 'Preview text'); + Object.assign(input.style, { + width: '100%', + boxSizing: 'border-box', + marginTop: '8px', + padding: '6px 8px', + borderRadius: '8px', + border: '1px solid rgba(154, 193, 255, 0.32)', + background: 'rgba(154, 193, 255, 0.08)', + color: 'inherit', + font: `13px ${FONT}`, + resize: 'vertical', + }); + input.addEventListener('input', () => { + state.text = input.value; + layout(); + }); + input.addEventListener('change', () => console.log(`Preview text: "${input.value}"`)); + panel.element.append(input); + }, + snippet: () => fontSnippet(item, { ...state, bitmap }), + dispose() { + for (const line of lines) { + line.destroy(); + } + }, + }; + const layout = () => { + for (const line of lines.splice(0)) { + line.destroy(); + } + const sizes = [state.size, Math.round(state.size * 0.6), Math.round(state.size * 0.4)]; + let y = 60; + for (const size of sizes) { + const line = font + ? new BitmapText(state.text, font).setScale(size / 32) + : new Text(state.text, { fontFamily: family, fontSize: size, fillColor: colors[state.color], maxWidth: PREVIEW.width - 64 }); + // Text lays out lazily, so the number of wrapped lines is measured with + // the same font on a scratch 2D context rather than read back. + const wrapped = font ? 1 : Math.ceil(measureWidth(state.text, `${size}px "${family}"`) / (PREVIEW.width - 64)) + 0.2; + line.setPosition(PREVIEW.x + 32, y); + lines.push(line); + y += wrapped * size * 1.3 + 28; + } + preview.overlay.splice(0, preview.overlay.length, ...lines); + }; + layout(); + return preview; +}; +const openData = async ({ scene, item, scope }) => { + const data = await scope.load(assets[item.key]); + return { + world: [], + overlay: [], + bounds: null, + facts: [['Kind', 'JSON document']], + mount(host) { + host({ title: 'Data', controls: {}, actions: { Data: () => void scene.showData(), Code: () => scene.showCode() } }); + void scene.showData(); + }, + snippet: () => dataSnippet(item), + data: () => Promise.resolve(data), + dispose() { + // Nothing to release beyond the scope. + }, + }; +}; +// --- Snippets --------------------------------------------------------------- +// +// Each snippet is a complete module: imports, one scene and the application, +// using only public ExoJS APIs and the demo catalog. +const round = (value, digits = 2) => Number(value.toFixed(digits)); +const program = ( + imports, + body, + options = {}, +) => `${[...imports, "import { demoAssets as assets } from '@codexo/exojs-demo-assets';"].sort().join('\n')} + +class PreviewScene extends Scene { +${body} +} + +const app = new Application({ + scenes: { PreviewScene }, + canvas: { width: 1280, height: 720, mount: document.body },${options.extensions ? `\n extensions: [${options.extensions}],` : ''} +}); + +await app.start(PreviewScene); +`; +const textureSnippet = (item, state) => { + const svg = assets[item.key].type === 'svg'; + const scale = round(state.zoom); + const names = [ + 'Application', + 'Scene', + 'Sprite', + ...(svg ? ['Texture'] : []), + ...(state.nearest ? (svg ? ['ScaleModes'] : ['Asset', 'ScaleModes']) : []), + ].sort(); + const load = svg + ? ` // An SVG loads as an image; the texture over it is yours to destroy. + const image = await this.loader.load(assets.${item.key}); + const texture = new Texture(image${state.nearest ? ', { scaleMode: ScaleModes.Nearest }' : ''});` + : state.nearest + ? ` // Sampling is part of the texture handle, chosen when it is loaded. + const texture = await this.loader.load( + Asset.type('texture', assets.${item.key}.source, { textureOptions: { scaleMode: ScaleModes.Nearest } }), + );` + : ` const texture = await this.loader.load(assets.${item.key});`; + return program( + [`import { ${names.join(', ')} } from '@codexo/exojs';`], + ` override async load(): Promise { +${load} + const sprite = new Sprite(texture).setAnchor(0.5).setScale(${scale}).setPosition(640, 360); + + this.addChild(sprite); + }`, + ); +}; +const spritesheetSnippet = (item, state) => { + const named = state.animation !== 'Frame range'; + const frames = named + ? `sheet.getAnimationFrameNames(${JSON.stringify(state.animation)}).map(name => sheet.getFrame(name))` + : `[${state.frames.map(name => JSON.stringify(name)).join(', ')}].map(name => sheet.getFrame(name))`; + return program( + ["import { AnimatedSprite, Application, Scene, Spritesheet } from '@codexo/exojs';"], + ` override async load(): Promise { + const [texture, data] = await Promise.all([this.loader.load(assets.${item.key}), this.loader.load(assets.${item.atlasKey})]); + const sheet = new Spritesheet(texture, data); + const sprite = new AnimatedSprite(texture, { + preview: { frames: ${frames}, fps: ${state.fps}, repeat: ${state.loop ? -1 : 1} }, + }); + + sprite.setAnchor(0.5).setScale(${round(state.scale)}).setPosition(640, 360); + this.addChild(sprite); + sprite.play('preview'); + }`, + ); +}; +const mapSnippet = (item, state) => + state.ldtk + ? program( + [ + "import { Application, Asset, Scene } from '@codexo/exojs';", + "import { TileMapNode } from '@codexo/exojs/tilemap';", + "import { ldtkExtension } from '@codexo/exojs-ldtk';", + ], + ` override async load(): Promise { + // Tileset paths resolve against the world's URL, so load it from the catalog's absolute source. + const world = await this.loader.load(Asset.type('ldtkMap', assets.${item.key}.source)); + const level = world.getLevelByName(${JSON.stringify(state.level)}) ?? world.levels[0]!; + + this.addChild(new TileMapNode(level).setScale(${round(state.zoom)})); + }`, + { extensions: 'ldtkExtension' }, + ) + : program( + [ + "import { Application, Asset, Scene } from '@codexo/exojs';", + "import { TileMapNode } from '@codexo/exojs/tilemap';", + "import { tiledExtension } from '@codexo/exojs-tiled';", + ], + ` override async load(): Promise { + // Tileset images resolve against the map's URL, so load it from the catalog's absolute source. + const map = await this.loader.load(Asset.type('tileMap', assets.${item.key}.source)); + + this.addChild(new TileMapNode(map).setScale(${round(state.zoom)})); + }`, + { extensions: 'tiledExtension' }, + ); +const audioSnippet = (item, state) => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const ${state.music ? 'music' : 'sound'} = await this.loader.load(assets.${item.key}); + + // Browsers keep audio locked until the first click or key press; + // onUnlock runs the handler as soon as playback is allowed. + this.app.audio.onUnlock.add(() => { + this.app.audio.play(${state.music ? 'music' : state.clip ? `sound.sprite(${JSON.stringify(state.clip)})` : 'sound'}, { volume: ${round(state.volume)}, loop: ${state.loop} }); + }); + }`, + ); +const videoSnippet = (item, state) => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const video = await this.loader.load(assets.${item.key}); + + video.applyOptions({ loop: ${state.loop}, muted: ${state.muted}, playbackRate: ${Number.parseFloat(state.rate)} }); + video.setAnchor(0.5).setScale(${round(state.zoom)}).setPosition(640, 360); + this.addChild(video); +${ + state.muted + ? ' // A muted video may start without a user gesture.\n video.play();' + : ' // With sound, playback waits for the gesture that unlocks audio.\n this.app.audio.onUnlock.add(() => video.play());' } + }`, + ); +const fontSnippet = (item, state) => + state.bitmap + ? program( + ["import { Application, BitmapText, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const font = await this.loader.load(assets.${item.key}); + + this.addChild(new BitmapText(${JSON.stringify(state.text)}, font).setScale(${round(state.size / 32)}).setPosition(40, 60)); + }`, + ) + : program( + ["import { Application, Color, Scene, Text } from '@codexo/exojs';"], + ` override async load(): Promise { + // Loading a font registers its face with the document. + const face = await this.loader.load(assets.${item.key}); + const text = new Text(${JSON.stringify(state.text)}, { + fontFamily: face.family.replaceAll('"', ''), + fontSize: ${state.size}, + fillColor: Color.white, + }); + + this.addChild(text.setPosition(40, 60)); + }`, + ); +const dataSnippet = item => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const data = await this.loader.load(assets.${item.key}); + + console.log(Object.keys(data as object)); + }`, + ); const app = new Application({ scenes: { AssetBrowserScene }, - canvas: { width: W, height: H, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, - clearColor: C.bg, + canvas: { width: STAGE.width, height: STAGE.height, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(32, 37, 50), + extensions: [tiledExtension, ldtkExtension], }); await app.start(AssetBrowserScene); diff --git a/examples/debug-layer/asset-browser.ts b/examples/debug-layer/asset-browser.ts index 87c780406..51c6dfc56 100644 --- a/examples/debug-layer/asset-browser.ts +++ b/examples/debug-layer/asset-browser.ts @@ -1,1387 +1,2009 @@ -import { Asset, FixedResolutionCanvasSizing } from '@codexo/exojs'; import { + AnimatedSprite, Application, + Asset, type AudioStream, + BitmapText, + type BmFont, Color, + FixedResolutionCanvasSizing, Graphics, - type Pausable, + type LoaderScope, + type Rectangle, type RenderingContext, + type RenderNode, + ScaleModes, Scene, type Seconds, - type Seekable, + type Sound, Sprite, Spritesheet, type SpritesheetData, Text, Texture, + View, type Voice, } from '@codexo/exojs'; - -// Dynamic category accessor: maps a category key to the correct sub-object -// in the hierarchical assets catalog. Technical assets live under -// assets.technical; everything else is under assets.demo. -const getCategoryData = (catKey: string): Record => { - if (catKey === 'technical') { - return assets.technical as unknown as Record; - } - - return (assets.demo as unknown as Record>)[catKey] ?? {}; -}; - -// Catalog entries are heterogeneous by category, and `getCategoryData` hands -// them back as `unknown` because the key is dynamic. These name the two shapes -// the loading paths below actually read. -interface ImagePairEntry { - image: string; - data: string; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { ObjectKind, type TileMap, TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssetCategories, type DemoAssetCategory, type DemoAssetKey, demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { ldtkExtension } from '@codexo/exojs-ldtk'; +import { tiledExtension } from '@codexo/exojs-tiled'; +import { choice, type DemoControlSpec, demoPanel, slider, toggle } from '@examples/runtime'; + +// An asset browser for the standard demo catalog. It is an ordinary scene: +// +// - The list is derived from `demoAssets` and `demoAssetCategories`; nothing +// is fetched until an entry is selected. +// - Every selection loads into a child `LoaderScope` of the scene. Selecting +// something else destroys that scope, which cancels a load still in flight +// and frees what it held - unless another scope holds the same asset. +// - Each selection carries a generation number. A load that settles after a +// newer selection is discarded before it can show, play or log anything. +// - Nothing plays on selection. Play goes through `app.audio.onUnlock`, which +// waits for the browser's autoplay gesture and is cancelled on switch. +// - "Code" turns the current preview settings into a complete scene. + +const STAGE = { width: 1280, height: 720 }; +const SIDEBAR_WIDTH = 272; +const INSPECTOR_WIDTH = 332; +const PREVIEW = { x: SIDEBAR_WIDTH, y: 0, width: STAGE.width - SIDEBAR_WIDTH - INSPECTOR_WIDTH, height: STAGE.height }; +const PREVIEW_CENTER = { x: PREVIEW.x + PREVIEW.width / 2, y: PREVIEW.height / 2 }; +const FONT = '"Segoe UI", system-ui, sans-serif'; +const MONO = '"JetBrains Mono", ui-monospace, monospace'; +const MANIFEST_URL = import.meta.resolve('@codexo/exojs-demo-assets/manifest.json'); + +type Group = 'Textures' | 'Spritesheets' | 'Maps' | 'Audio' | 'Video' | 'Fonts' | 'Data'; +type Kind = 'texture' | 'svg' | 'spritesheet' | 'tiled' | 'ldtk' | 'sound' | 'music' | 'video' | 'font' | 'bmFont' | 'data'; + +interface BrowserItem { + readonly id: string; + readonly name: string; + readonly group: Group; + readonly kind: Kind; + readonly key: DemoAssetKey; + /** The atlas JSON of a spritesheet; `key` is its image. */ + readonly atlasKey?: DemoAssetKey; } -interface AudioPairEntry { - audio: string; - data: string; +/** The fields of the package's `manifest.json` this browser reads. */ +interface DemoManifest { + readonly entries: ReadonlyArray<{ + readonly key: string; + readonly bytes: number; + readonly path: string; + readonly media?: { + readonly container?: string; + readonly durationSeconds?: number; + readonly dynamicRange?: string; + readonly video?: { + codec?: string; + profile?: string; + width?: number; + height?: number; + frameRate?: string; + pixelFormat?: string; + bitDepth?: number; + }; + readonly audio?: { codec?: string; sampleRate?: number; channels?: number }; + }; + }>; } -interface SoundSpriteData { - sprites: Record; -} +const GROUP_OF: Record = { + textures: 'Textures', + technical: 'Textures', + backgrounds: 'Textures', + icons: 'Textures', + cursors: 'Textures', + spritesheets: 'Spritesheets', + inputPrompts: 'Spritesheets', + maps: 'Maps', + sound: 'Audio', + soundSprites: 'Audio', + music: 'Audio', + voice: 'Audio', + impulseResponses: 'Audio', + video: 'Video', + fonts: 'Fonts', +}; +const GROUP_ORDER: readonly Group[] = ['Textures', 'Spritesheets', 'Maps', 'Audio', 'Video', 'Fonts', 'Data']; +const GROUP_GLYPH: Record = { + texture: 'IMG', + svg: 'SVG', + spritesheet: 'ATL', + tiled: 'MAP', + ldtk: 'MAP', + sound: 'SND', + music: 'MUS', + video: 'VID', + font: 'TTF', + bmFont: 'FNT', + data: '{ }', +}; -// Union of the tileset catalog entry shapes - every entry declares -// tileWidth/tileHeight even though only some also carry `data`. -type TilesetEntry = (typeof assets.demo.tilesets)[keyof typeof assets.demo.tilesets]; +const humanize = (key: string): string => { + const words = key + .replace(/^(sound|music|font)(?=[A-Z])/, '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .toLowerCase(); -// Shape of the vendor pack manifest JSON files loaded per `vendor/*.json` key. -interface VendorPack { - slug: string; - fileCountByExtension?: Record; -} + return words.charAt(0).toUpperCase() + words.slice(1); +}; -interface VendorManifest { - license?: string; - packs?: VendorPack[]; -} +const kindOf = (key: DemoAssetKey): Kind => { + const descriptor = assets[key]; + + switch (descriptor.type) { + case 'texture': + case 'svg': + case 'sound': + case 'music': + case 'video': + case 'font': + case 'bmFont': + return descriptor.type as Kind; + case 'json': + if (demoAssetCategories[key] !== 'maps') { + return 'data'; + } -const W = 1280; -const H = 720; -const TOOLBAR_H = 110; -const SIDEBAR_W = 220; -const ITEM_H = 36; -const LIST_Y = TOOLBAR_H + 4; -const PREVIEW_X = SIDEBAR_W; -const PREVIEW_Y = TOOLBAR_H; -const PREVIEW_W = W - SIDEBAR_W; -const PREVIEW_H = H - TOOLBAR_H; - -const CAT_BTN_W = 82; -const CAT_BTN_H = 38; -const CAT_BTN_Y1 = 6; -const CAT_BTN_Y2 = 58; -const CAT_BTN_X0 = 6; - -const BG_BTN_SIZE = 26; -const BG_BTN_Y = 16; -const BG_BTN_X0 = W - 3 * (BG_BTN_SIZE + 5) - 6; - -const CATEGORIES_ROW1 = [ - { id: 'textures', label: 'IMG', catKey: 'textures' }, - { id: 'sprites', label: 'SPR', catKey: 'sprites' }, - { id: 'audio', label: 'AUD', catKey: 'audio' }, - { id: 'svg', label: 'SVG', catKey: 'svg' }, - { id: 'fonts', label: 'FNT', catKey: 'fonts' }, - { id: 'video', label: 'VID', catKey: 'video' }, - { id: 'inputPrompts', label: 'INP', catKey: 'inputPrompts' }, - { id: 'technical', label: 'TECH', catKey: 'technical' }, -]; - -const CATEGORIES_ROW2 = [ - { id: 'sound', label: 'SND', catKey: 'sound' }, - { id: 'music', label: 'MUS', catKey: 'music' }, - { id: 'soundSprites', label: 'SDS', catKey: 'soundSprites' }, - { id: 'spritesheets', label: 'SSH', catKey: 'spritesheets' }, - { id: 'tilesets', label: 'TLS', catKey: 'tilesets' }, - { id: 'backgrounds', label: 'BGS', catKey: 'backgrounds' }, - { id: 'cursors', label: 'CSR', catKey: 'cursors' }, - { id: 'vendor', label: 'VND', catKey: 'vendor' }, -]; - -const CATEGORIES = [...CATEGORIES_ROW1, ...CATEGORIES_ROW2]; - -const TECH_PURPOSE: Record = { - alpha: 'alpha / premultiplied / halo checks', - filtering: 'nearest / linear / sampler checks', - color: 'ramp / gamma / color-shift checks', + return descriptor.source.endsWith('.ldtk') ? 'ldtk' : 'tiled'; + default: + return 'data'; + } }; -const BG_OPTIONS = [ - { label: 'W', r: 255, g: 255, b: 255 }, - { label: 'G', r: 45, g: 50, b: 65 }, - { label: 'K', r: 0, g: 0, b: 0 }, -]; - -const C = { - bg: new Color(26, 28, 38), - toolbar: new Color(30, 34, 46), - panel: new Color(32, 36, 48), - active: new Color(58, 120, 218), - hover: new Color(44, 50, 66), - border: new Color(52, 58, 76), - infoBar: new Color(18, 22, 32), - btnDark: new Color(44, 52, 72), - btnGreen: new Color(55, 150, 75), - white: new Color(255, 255, 255), - dim: new Color(140, 150, 170), - dimDark: new Color(90, 98, 120), - accent: new Color(100, 180, 255), - black: new Color(0, 0, 0), -}; +/** An atlas JSON and its image are separate descriptors named `Atlas` and `Image`; the browser lists them as one spritesheet. */ +const buildItems = (): BrowserItem[] => { + const items: BrowserItem[] = []; -class AssetBrowserScene extends Scene { - cat = 'textures'; - key: string | null = null; - bgIdx = 1; - scrollOff = 0; - hoverIdx: number | null = null; - - audioMusics = new Map(); - soundMusics = new Map(); - musicMusics = new Map(); - soundSpriteAudio = new Map(); - - // Single-asset preview playback: one Voice at a time, tied to `previewKey`. - private previewVoice: (Voice & Pausable & Seekable) | null = null; - private previewKey: string | null = null; - - frameIdx = 0; - frameTimer = 0; - animPlaying = true; - - texSprites = new Map(); - sprSheets = new Map(); - sshSheets = new Map(); - svgSprites = new Map(); - inpSheets = new Map(); - fontFamilies = new Map(); - techSprites = new Map(); - bgSprites = new Map(); - cursorSprites = new Map(); - tilesetSprites = new Map(); - soundSpriteData = new Map(); - vendorData = new Map(); - - animG: Graphics | null = null; - audioG: Graphics | null = null; - copyBtnBg: Graphics | null = null; - fontSampleTexts = new Map(); - - gToolbar = new Graphics(); - gSidebar = new Graphics(); - gPreview = new Graphics(); - gInfoBar = new Graphics(); - - catBtnTexts = CATEGORIES.map(() => new Text('', { fillColor: C.white, fontSize: 11, fontWeight: 'bold' })); - bgBtnTexts = BG_OPTIONS.map(() => new Text('', { fillColor: C.dim, fontSize: 11, fontWeight: 'bold' })); - - txtKey = new Text('', { fillColor: C.white, fontSize: 12, fontWeight: 'bold' }); - txtPath = new Text('', { fillColor: C.dim, fontSize: 11 }); - txtType = new Text('', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' }); - txtCopy = new Text('COPY KEY', { fillColor: C.white, fontSize: 10 }); - - itemTexts = Array.from({ length: 22 }, () => new Text('', { fillColor: C.white, fontSize: 13 })); - - techHeaderTexts = new Map([ - ['alpha', new Text('ALPHA', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ['filtering', new Text('FILTERING', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ['color', new Text('COLOR', { fillColor: C.accent, fontSize: 10, fontWeight: 'bold' })], - ]); + for (const key of Object.keys(demoAssetCategories) as DemoAssetKey[]) { + const base = key.replace(/(Atlas|Image)$/, ''); + const image = `${base}Image` as DemoAssetKey; + const atlas = `${base}Atlas` as DemoAssetKey; - txtTechPurpose = new Text('', { fillColor: C.dim, fontSize: 11 }); - - txtNoAssets = new Text('globalThis.assets is not available.\nRun this example in the ExoJS playground.', { - fillColor: C.dim, - fontSize: 16, - }); - txtEmptyCat = new Text('(empty)', { fillColor: C.dimDark, fontSize: 13 }); - txtNoSel = new Text('Select an asset from the list.', { fillColor: C.dimDark, fontSize: 14 }); - txtLoading = new Text('Loading assets…', { fillColor: C.dim, fontSize: 14 }); - txtMeta = new Text('', { fillColor: C.white, fontSize: 13, maxWidth: PREVIEW_W - 80 }); - txtAudioIcon = new Text('', { fillColor: C.white, fontSize: 28 }); - txtAnimPlay = new Text('', { fillColor: C.white, fontSize: 12 }); - txtAnimFrame = new Text('', { fillColor: C.dim, fontSize: 11 }); - - // Lazy per-category loading: only the initial category is fetched up - // front; every other one loads on first visit (see ensureCategory), so - // the browser becomes interactive quickly instead of downloading the - // whole catalog before the first frame. - private assetLoader!: Scene['loader']; - loadedCats = new Set(); - loadingCats = new Set(); + if (base !== key && image in assets && atlas in assets) { + if (key === image) { + items.push({ id: base, name: humanize(base), group: 'Spritesheets', kind: 'spritesheet', key: image, atlasKey: atlas }); + } - override async load(): Promise { - const app = this.app; - this.assetLoader = this.loader; - await this.ensureCategory(this.cat); + continue; + } - app.input.onPointerTap.add(p => this.onTap(p.x, p.y)); - app.input.onPointerMove.add(p => this.onMove(p.x, p.y)); - app.input.onMouseWheel.add((_deltaX, deltaY) => this.onWheel(deltaY)); + const kind = kindOf(key); - this.selectFirstInCategory(); + items.push({ id: key, name: humanize(key), group: kind === 'data' ? 'Data' : GROUP_OF[demoAssetCategories[key]], kind, key }); } - /** Load a category's assets (and build its preview objects) exactly once. */ - private async ensureCategory(catId: string): Promise { - if (this.loadedCats.has(catId) || this.loadingCats.has(catId)) { - return; - } - - this.loadingCats.add(catId); + return items; +}; - try { - await this.loadCategory(catId); - this.loadedCats.add(catId); - } finally { - this.loadingCats.delete(catId); - } - } +const ITEMS = buildItems(); +const GROUPS = GROUP_ORDER.filter(group => ITEMS.some(item => item.group === group)); + +const fileName = (source: string): string => decodeURIComponent(source.slice(source.lastIndexOf('/') + 1)); +const formatBytes = (bytes: number): string => + bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`; +const formatSeconds = (value: number): string => (Number.isFinite(value) ? `${value.toFixed(2)} s` : 'unknown'); +const isAbort = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'; + +/** What the scene hands a preview: the selection's own scope and a way to ask whether it is still the current one. */ +interface PreviewContext { + readonly scene: AssetBrowserScene; + readonly item: BrowserItem; + readonly scope: LoaderScope; + readonly manifest: DemoManifest['entries'][number] | undefined; + isCurrent(): boolean; +} - private async loadCategory(catId: string): Promise { - const loader = this.assetLoader; - - // Every category below fetches its assets by real (dynamic, non-literal) - // path via `Asset.type(type, path)` + `load()` - the old alias-keyed batch-record form - // (`load(Type, { alias: path })` + `get(Type, alias)`) is gone; there is no - // alias indirection to reconstruct, so each item is loaded directly and its - // resolved handle/value is stored under the catalog's own key. - switch (catId) { - case 'textures': { - await Promise.all( - Object.entries(assets.demo.textures ?? {}).map(async ([k, url]) => { - const s = new Sprite(await loader.load(Asset.type('texture', url as string))); - s.setAnchor(0.5); - this.texSprites.set(k, s); - }), - ); - break; - } +/** What the scene asks of a preview once its assets are loaded. */ +interface Preview { + /** Content drawn through the pan-and-zoom camera; its top-left is the world origin. */ + readonly world: RenderNode[]; + /** Content drawn in canvas pixels above the world. */ + readonly overlay: RenderNode[]; + /** The size of the world content, for fitting it into view; `null` when nothing pans. */ + readonly bounds: { readonly width: number; readonly height: number } | null; + readonly facts: ReadonlyArray; + /** Mounts the controls. Runs only for the current selection, after every load settled. */ + mount(panel: PanelHost): void; + update?(delta: Seconds): void; + /** A tap on the world content, in world coordinates. */ + tap?(x: number, y: number): void; + snippet(): string; + /** Loads the raw JSON behind the preview, for the Data view. */ + data?(): Promise; + /** Stops playback and destroys what the preview created; loaded assets belong to the scope. */ + dispose(): void; +} - case 'sprites': { - await Promise.all( - Object.entries(assets.demo.sprites ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', (entry as ImagePairEntry).image)), - loader.load(Asset.type('json', (entry as ImagePairEntry).data)), - ]); - const ss = new Spritesheet(tex, data); - this.sprSheets.set(k, ss); - - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } +/** Mounts a preview's control panel inside the inspector column. */ +type PanelHost = >( + options: Parameters>[0], +) => ReturnType>; - case 'spritesheets': { - await Promise.all( - Object.entries(assets.demo.spritesheets ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', (entry as ImagePairEntry).image)), - loader.load(Asset.type('json', (entry as ImagePairEntry).data)), - ]); - const ss = new Spritesheet(tex, data); - this.sshSheets.set(k, ss); - - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } +class AssetBrowserScene extends Scene { + private readonly sidebar = document.createElement('nav'); + private readonly search = document.createElement('input'); + private readonly tabs = document.createElement('div'); + private readonly list = document.createElement('ul'); + private readonly inspector = document.createElement('aside'); + private readonly heading = document.createElement('h2'); + private readonly facts = document.createElement('dl'); + private readonly panelSlot = document.createElement('div'); + private readonly resident = document.createElement('details'); + private readonly codePanel = document.createElement('section'); + private readonly codeTitle = document.createElement('strong'); + private readonly code = document.createElement('pre'); + private readonly buttons = new Map(); + + private readonly view = new View(STAGE.width / 2, STAGE.height / 2, STAGE.width, STAGE.height); + private readonly frame = new Graphics(); + private readonly message = new Text('', { fontSize: 22, fillColor: new Color(200, 214, 236), align: 'center' }); + private backdrop = new Color(32, 37, 50); + + private group: Group = GROUPS[0]!; + private selected: BrowserItem | null = null; + private generation = 0; + private scope: LoaderScope | null = null; + private preview: Preview | null = null; + private panel: { dispose(): void } | null = null; + private manifest = new Map(); + private drag: { x: number; y: number; moved: boolean } | null = null; + private zoomTarget: { set(key: 'zoom', value: number): void } | null = null; + private destroyed = false; - case 'svg': { - await Promise.all( - Object.entries(assets.demo.svg ?? {}).map(async ([k, url]) => { - const s = new Sprite(new Texture(await loader.load(Asset.type('svg', url as string)))); - s.setAnchor(0.5); - this.svgSprites.set(k, s); - }), - ); - break; - } + override async load(): Promise { + // The package's manifest carries byte sizes and media metadata. It is + // optional here: without it the inspector only shows what loading tells. + try { + const manifest = await this.loader.load(Asset.type('json', MANIFEST_URL)); - case 'inputPrompts': { - await Promise.all( - Object.entries(assets.demo.inputPrompts ?? {}).map(async ([k, entry]) => { - const [tex, data] = await Promise.all([ - loader.load(Asset.type('texture', (entry as ImagePairEntry).image)), - loader.load(Asset.type('json', (entry as ImagePairEntry).data)), - ]); - const ss = new Spritesheet(tex, data); - this.inpSheets.set(k, ss); - - for (const s of ss.sprites.values()) { - s.setAnchor(0.5); - } - }), - ); - break; - } + this.manifest = new Map(manifest.entries.map(entry => [entry.key, entry])); + } catch (error) { + console.warn('The demo asset manifest is unavailable; sizes and media details are hidden.', error); + } + } - case 'audio': { - await Promise.all( - Object.entries(assets.demo.audio ?? {}).map(async ([k, url]) => { - this.audioMusics.set(k, await loader.load(Asset.type('music', url as string))); - }), - ); - break; - } + override init(): void { + this.mountSidebar(); + this.mountInspector(); + this.mountCodePanel(); + this.message.setAnchor(0.5).setPosition(PREVIEW_CENTER.x, PREVIEW_CENTER.y); + this.app.input.onPointerDown.add(this.onPointerDown); + this.app.input.onPointerMove.add(this.onPointerMove); + this.app.input.onPointerUp.add(this.onPointerUp); + this.app.input.onMouseWheel.add(this.onWheel); + this.showGroup(this.group); + void this.select(ITEMS.find(item => item.group === this.group)!); + } - case 'sound': { - await Promise.all( - Object.entries(assets.demo.sound ?? {}).map(async ([k, url]) => { - this.soundMusics.set(k, await loader.load(Asset.type('music', url as string))); - }), - ); - break; - } + // --- Selection ------------------------------------------------------------ - case 'music': { - await Promise.all( - Object.entries(assets.demo.music ?? {}).map(async ([k, url]) => { - this.musicMusics.set(k, await loader.load(Asset.type('music', url as string))); - }), - ); - break; - } + async select(item: BrowserItem): Promise { + const generation = ++this.generation; - case 'soundSprites': { - await Promise.all( - Object.entries(assets.demo.soundSprites ?? {}).map(async ([k, entry]) => { - const [audio, data] = await Promise.all([ - loader.load(Asset.type('music', (entry as AudioPairEntry).audio)), - loader.load(Asset.type('json', (entry as AudioPairEntry).data)), - ]); - this.soundSpriteAudio.set(k, audio); - this.soundSpriteData.set(k, data); - }), - ); - break; - } + this.closePreview(); + this.selected = item; + this.markSelection(); + this.renderFacts(item, []); + this.message.text = 'Loading...'; - case 'fonts': { - await Promise.all( - Object.entries(assets.demo.fonts ?? {}).map(async ([k, url]) => { - // The fonts category mixes vector fonts (.ttf/.otf) with - // bitmap-font sidecars (.fnt/.png) that FontFace cannot - // parse. Load only the vector entries - the bitmap ones - // fall back to a path readout. - if (!/\.(ttf|otf|woff2?)$/i.test(url as string)) { - return; - } + const scope = this.loader.createScope({ name: `preview:${item.id}` }); + const started = performance.now(); - const family = `assetbrowser_${k}`; - await loader.load(Asset.type('font', url as string, { family })); - this.fontFamilies.set(k, family); - }), - ); - break; - } + this.scope = scope; - case 'technical': { - await Promise.all( - Object.entries(assets.technical ?? {}).flatMap(([subcat, items]) => - Object.entries(items as Record).map(async ([k, u]) => { - const s = new Sprite(await loader.load(Asset.type('texture', u))); - s.setAnchor(0.5); - this.techSprites.set(`${subcat}.${k}`, s); - }), - ), - ); - break; - } + const context: PreviewContext = { + scene: this, + item, + scope, + manifest: this.manifest.get(item.key), + isCurrent: () => generation === this.generation && !this.destroyed, + }; - case 'backgrounds': { - await Promise.all( - Object.entries(assets.demo.backgrounds ?? {}).map(async ([k, url]) => { - const s = new Sprite(await loader.load(Asset.type('texture', url as string))); - s.setAnchor(0.5); - this.bgSprites.set(k, s); - }), - ); - break; - } + try { + const preview = await openPreview(context); - case 'cursors': { - await Promise.all( - Object.entries(assets.demo.cursors ?? {}).map(async ([k, url]) => { - const s = new Sprite(new Texture(await loader.load(Asset.type('svg', url as string)))); - s.setAnchor(0.5); - this.cursorSprites.set(k, s); - }), - ); - break; - } + // A newer selection destroyed this scope already; what the preview built + // is dropped without ever being shown. + if (!context.isCurrent()) { + preview.dispose(); - case 'tilesets': { - await Promise.all( - Object.entries(assets.demo.tilesets ?? {}).map(async ([k, entry]) => { - const s = new Sprite(await loader.load(Asset.type('texture', (entry as ImagePairEntry).image))); - s.setAnchor(0.5); - this.tilesetSprites.set(k, s); - }), - ); - break; + return; } - case 'vendor': { - await Promise.all( - Object.entries(assets.demo.vendor ?? {}).map(async ([k, url]) => { - this.vendorData.set(k, await loader.load(Asset.type('json', url as string))); - }), - ); - break; + this.preview = preview; + this.message.text = ''; + this.renderFacts(item, preview.facts); + this.mountPanel(preview); + this.fit(); + this.renderResident(); + console.info(`Loaded ${item.name} (${item.kind}) in ${Math.round(performance.now() - started)} ms`); + } catch (error) { + if (!context.isCurrent() || isAbort(error)) { + return; } - default: - // 'video' previews only show the asset URL - nothing to load. - break; + this.message.text = `Could not load ${item.name}`; + this.renderResident(); + console.error(`Failed to load ${item.name}:`, error); } } - private techFlatKeys(): string[] { - const out: string[] = []; + private closePreview(): void { + this.hideCode(); + this.panel?.dispose(); + this.panel = null; + this.zoomTarget = null; + this.backdrop = new Color(32, 37, 50); - for (const subcat of ['alpha', 'filtering', 'color'] as const) { - const items = assets.technical[subcat]; + try { + this.preview?.dispose(); + } finally { + this.preview = null; + // Destroying the scope cancels a load that is still running and releases + // this selection's claims; assets another scope holds stay resident. + this.scope?.destroy(); + this.scope = null; + } + } - if (!items) { - continue; + private mountPanel(preview: Preview): void { + const host: PanelHost = options => { + const panel = demoPanel({ ...options, corner: 'top-right' }); + + Object.assign(panel.element.style, { + position: 'static', + maxWidth: 'none', + minWidth: '0', + padding: '10px', + boxShadow: 'none', + margin: '0', + }); + + // The shared panel is sized for a canvas corner; in this column the + // sliders give way so every readout stays visible. + for (const input of panel.element.querySelectorAll('input')) { + input.style.minWidth = '0'; } - out.push(subcat); + this.panelSlot.replaceChildren(panel.element); + this.panel = panel; - for (const k of Object.keys(items)) { - out.push(`${subcat}.${k}`); - } - } + return panel; + }; - return out; + preview.mount(host); } - private keys(): string[] { - if (this.cat === 'technical') { - return this.techFlatKeys(); - } - - const cat = CATEGORIES.find(c => c.id === this.cat); - const obj = getCategoryData(cat?.catKey ?? ''); - - return obj ? Object.keys(obj) : []; + /** The camera always zooms through the panel's Zoom control when a preview has one. */ + bindZoom(target: { set(key: 'zoom', value: number): void } | null): void { + this.zoomTarget = target; } - private assetPath(): string { - if (!this.key) { - return ''; - } - - if (this.cat === 'technical') { - if (!this.key.includes('.')) { - return ''; - } + setZoom(zoom: number): void { + const center = this.view.center; - const [subcat, itemKey] = this.key.split('.'); + // Zoom about the preview's centre, which is offset from the canvas centre. + const focusX = center.x + (PREVIEW_CENTER.x - STAGE.width / 2) / this.view.zoomLevel; + const focusY = center.y + (PREVIEW_CENTER.y - STAGE.height / 2) / this.view.zoomLevel; - return (assets.technical as unknown as Record>)[subcat]?.[itemKey] ?? ''; - } + this.view.setZoom(zoom); + this.lookAt(focusX, focusY); + } - const cat = CATEGORIES.find(c => c.id === this.cat); - const v = getCategoryData(cat?.catKey ?? '')[this.key]; + /** Puts world point (x, y) at the centre of the preview area. */ + lookAt(x: number, y: number): void { + const zoom = this.view.zoomLevel; - if (typeof v === 'string') { - return v; - } + this.view.setCenter(x - (PREVIEW_CENTER.x - STAGE.width / 2) / zoom, y - (PREVIEW_CENTER.y - STAGE.height / 2) / zoom); + } - const record = v as Partial | undefined; + fit(): void { + const bounds = this.preview?.bounds; - if (typeof record?.image === 'string') { - return record.image; - } + if (!bounds) { + this.view.setZoom(1); + this.lookAt(0, 0); - if (typeof record?.audio === 'string') { - return record.audio; + return; } - return ''; - } - - private assetExpression(): string { - const category = this.cat === 'technical' ? 'technical' : `demo.${this.cat}`; + const zoom = Math.min(8, (PREVIEW.width * 0.9) / bounds.width, (PREVIEW.height * 0.82) / bounds.height); - return `assets.${category}.${this.key}`; + this.view.setZoom(zoom); + this.lookAt(bounds.width / 2, bounds.height / 2); + this.zoomTarget?.set('zoom', zoom); } - private typeLabel(): string { - return this.assetPath().split('.').pop()?.toUpperCase() ?? ''; + setBackdrop(color: Color): void { + this.backdrop = color; } - private selectFirstInCategory(): void { - const keys = this.keys(); - this.key = this.cat === 'technical' ? (keys.find(k => k.includes('.')) ?? null) : (keys[0] ?? null); - this.scrollOff = 0; - this.resetPreviewState(); - } - - private resetPreviewState(): void { - this.stopAllAudio(); - this.frameIdx = 0; - this.frameTimer = 0; - this.animPlaying = true; - } + // --- Pointer: drag pans, wheel zooms, tap hits ---------------------------- - private stopAllAudio(): void { - if (this.previewVoice) { - this.previewVoice.stop(); - this.previewVoice = null; - this.previewKey = null; + private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { + if (x >= PREVIEW.x && x <= PREVIEW.x + PREVIEW.width) { + this.drag = { x, y, moved: false }; } - } + }; - /** True when the selected key's preview voice is live and not paused. */ - private previewIsPlaying(): boolean { - return this.previewVoice !== null && !this.previewVoice.ended && !this.previewVoice.paused && this.previewKey === this.key; - } - - private currentPlayingMap(): Map | null { - if (this.cat === 'audio') { - return this.audioMusics; + private readonly onPointerMove = (_pointer: unknown, x: number, y: number): void => { + if (!this.drag || !this.preview?.bounds) { + return; } - if (this.cat === 'sound') { - return this.soundMusics; - } + const dx = x - this.drag.x; + const dy = y - this.drag.y; - if (this.cat === 'music') { - return this.musicMusics; - } + if (this.drag.moved || Math.hypot(dx, dy) > 4) { + const zoom = this.view.zoomLevel; - if (this.cat === 'soundSprites') { - return this.soundSpriteAudio; + this.view.setCenter(this.view.center.x - dx / zoom, this.view.center.y - dy / zoom); + this.drag = { x, y, moved: true }; } + }; - return null; - } + private readonly onPointerUp = (_pointer: unknown, x: number, y: number): void => { + const drag = this.drag; - private toggleAudio(): void { - const app = this.app; + this.drag = null; - if (!this.key) { - return; - } + if (drag && !drag.moved && this.preview?.tap) { + // The four-argument form maps through the viewport at any zoom level; + // the stage's design size stands in for the canvas size. + const point = this.view.screenToWorld(x, y, STAGE.width, STAGE.height); - const map = this.currentPlayingMap(); - - if (!map) { - return; + this.preview.tap(point.x, point.y); } + }; - const stream = map.get(this.key); - - if (!stream) { + private readonly onWheel = (_deltaX: number, deltaY: number): void => { + if (!this.preview?.bounds) { return; } - if (this.previewKey === this.key && this.previewVoice && !this.previewVoice.ended) { - // Same asset selected: pause / resume the live voice. - if (this.previewVoice.paused) { - this.previewVoice.resume(); - } else { - this.previewVoice.pause(); - } + const zoom = Math.min(32, Math.max(0.05, this.view.zoomLevel * Math.exp(-deltaY * 0.0015))); + + if (this.zoomTarget) { + this.zoomTarget.set('zoom', zoom); } else { - // New asset: stop the previous preview and start this one. - this.previewVoice?.stop(); - this.previewVoice = app.audio.play(stream) as Voice & Pausable & Seekable; - this.previewKey = this.key; + this.setZoom(zoom); } + }; + + // --- Frame ---------------------------------------------------------------- + + override update(delta: Seconds): void { + this.preview?.update?.(delta); } - private currentFrameKeys(): string[] { - if (this.cat === 'sprites') { - return [...(this.sprSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; - } + override draw(context: RenderingContext): void { + context.clear(this.backdrop); - if (this.cat === 'spritesheets') { - return [...(this.sshSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; + for (const node of this.preview?.world ?? []) { + context.render(node, { view: this.view }); } - if (this.cat === 'inputPrompts') { - return [...(this.inpSheets.get(this.key ?? '')?.sprites.keys() ?? [])]; + for (const node of this.preview?.overlay ?? []) { + context.render(node, { view: context.screenView }); } - return []; - } - - private maxScroll(): number { - const visibleItems = Math.floor((H - LIST_Y) / ITEM_H); - - return Math.max(0, this.keys().length - visibleItems); + if (this.message.text) { + context.render(this.message, { view: context.screenView }); + } } - private isAudioLikeCategory(): boolean { - return this.cat === 'audio' || this.cat === 'sound' || this.cat === 'music' || this.cat === 'soundSprites'; + override destroy(): void { + this.destroyed = true; + this.generation += 1; + this.closePreview(); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerUp.remove(this.onPointerUp); + this.app.input.onMouseWheel.remove(this.onWheel); + this.sidebar.remove(); + this.inspector.remove(); + this.codePanel.remove(); + this.frame.destroy(); + this.message.destroy(); + this.view.destroy(); + super.destroy(); } - private onTap(x: number, y: number): void { - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - - if (x >= bx && x < bx + CAT_BTN_W && y >= CAT_BTN_Y1 && y < CAT_BTN_Y1 + CAT_BTN_H) { - if (this.cat !== CATEGORIES_ROW1[i].id) { - this.cat = CATEGORIES_ROW1[i].id; - this.selectFirstInCategory(); - void this.ensureCategory(this.cat); + // --- Sidebar -------------------------------------------------------------- + + private mountSidebar(): void { + Object.assign(this.sidebar.style, { + position: 'fixed', + inset: `0 auto 0 0`, + width: `${SIDEBAR_WIDTH}px`, + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '14px 12px', + boxSizing: 'border-box', + background: '#0b1019', + borderRight: '1px solid rgba(154, 193, 255, 0.18)', + color: '#f3f7ff', + font: `15px ${FONT}`, + zIndex: '5', + }); + this.sidebar.setAttribute('aria-label', 'Demo assets'); + + this.search.type = 'search'; + this.search.placeholder = `Search ${ITEMS.length} assets`; + this.search.setAttribute('aria-label', 'Search assets'); + Object.assign(this.search.style, { + padding: '8px 10px', + border: '1px solid rgba(154, 193, 255, 0.32)', + borderRadius: '8px', + background: 'rgba(154, 193, 255, 0.08)', + color: 'inherit', + font: 'inherit', + }); + this.search.addEventListener('input', () => this.renderList()); + + Object.assign(this.tabs.style, { display: 'flex', flexWrap: 'wrap', gap: '5px' }); + this.tabs.setAttribute('role', 'group'); + this.tabs.setAttribute('aria-label', 'Asset types'); + + for (const group of GROUPS) { + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = `${group} ${ITEMS.filter(item => item.group === group).length}`; + button.dataset.group = group; + Object.assign(button.style, chipStyle(false)); + button.addEventListener('click', () => { + this.showGroup(group); + const first = this.visibleItems()[0]; + + if (first && first.group !== this.selected?.group) { + void this.select(first); } - - return; - } + }); + this.tabs.append(button); } - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - - if (x >= bx && x < bx + CAT_BTN_W && y >= CAT_BTN_Y2 && y < CAT_BTN_Y2 + CAT_BTN_H) { - if (this.cat !== CATEGORIES_ROW2[i].id) { - this.cat = CATEGORIES_ROW2[i].id; - this.selectFirstInCategory(); - void this.ensureCategory(this.cat); - } - + Object.assign(this.list.style, { flex: '1 1 auto', overflowY: 'auto', listStyle: 'none', margin: '0', padding: '0' }); + this.list.setAttribute('aria-label', 'Assets'); + this.list.addEventListener('keydown', event => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { return; } - } - - for (let i = 0; i < BG_OPTIONS.length; i++) { - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - if (x >= bx && x < bx + BG_BTN_SIZE && y >= BG_BTN_Y && y < BG_BTN_Y + BG_BTN_SIZE) { - this.bgIdx = i; + event.preventDefault(); + const visible = this.visibleItems(); + const index = visible.findIndex(item => item === this.selected); + const next = visible[(index + (event.key === 'ArrowDown' ? 1 : -1) + visible.length) % visible.length]; - return; + if (next) { + void this.select(next); + this.buttons.get(next.id)?.focus(); } - } + }); - if (x >= 0 && x < SIDEBAR_W && y >= TOOLBAR_H) { - const keys = this.keys(); - const idx = Math.floor((y - LIST_Y) / ITEM_H) + this.scrollOff; - - if (idx >= 0 && idx < keys.length) { - const newKey = keys[idx]; - const isHeader = this.cat === 'technical' && !newKey.includes('.'); + this.sidebar.append(this.search, this.tabs, this.list); + document.body.append(this.sidebar); + } - if (!isHeader && newKey !== this.key) { - this.key = newKey; - this.resetPreviewState(); - } - } + private showGroup(group: Group): void { + this.group = group; + this.search.value = ''; - return; + for (const button of this.tabs.querySelectorAll('button')) { + Object.assign(button.style, chipStyle(button.dataset.group === group)); + button.setAttribute('aria-pressed', String(button.dataset.group === group)); } - if (this.key && x >= PREVIEW_X + PREVIEW_W - 84 && x < PREVIEW_X + PREVIEW_W - 10 && y >= PREVIEW_Y + 8 && y < PREVIEW_Y + 8 + 26) { - navigator.clipboard?.writeText(this.assetExpression()).catch(() => undefined); - - return; - } + this.renderList(); + } - if (this.isAudioLikeCategory() && this.key) { - const bx = PREVIEW_X + (PREVIEW_W - 100) / 2; - const by = PREVIEW_Y + PREVIEW_H / 2 - 30; + private visibleItems(): BrowserItem[] { + const query = this.search.value.trim().toLowerCase(); - if (x >= bx && x < bx + 100 && y >= by && y < by + 56) { - this.toggleAudio(); + return query + ? ITEMS.filter(item => item.name.toLowerCase().includes(query) || item.key.toLowerCase().includes(query)) + : ITEMS.filter(item => item.group === this.group); + } - return; - } - } + private renderList(): void { + const searching = this.search.value.trim() !== ''; + + this.buttons.clear(); + this.list.replaceChildren( + ...this.visibleItems().map(item => { + const row = document.createElement('li'); + const button = document.createElement('button'); + const glyph = document.createElement('span'); + const label = document.createElement('span'); + + button.type = 'button'; + button.dataset.asset = item.id; + glyph.textContent = GROUP_GLYPH[item.kind]; + glyph.setAttribute('aria-hidden', 'true'); + Object.assign(glyph.style, { flex: '0 0 34px', font: `600 10px ${MONO}`, color: '#7fb8ff', textAlign: 'center' }); + label.textContent = searching ? `${item.name} - ${item.group}` : item.name; + Object.assign(label.style, { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }); + Object.assign(button.style, { + display: 'flex', + alignItems: 'center', + gap: '6px', + width: '100%', + minHeight: '34px', + padding: '4px 6px', + textAlign: 'left', + border: '0', + borderRadius: '7px', + background: 'transparent', + color: 'inherit', + font: 'inherit', + cursor: 'pointer', + }); + button.append(glyph, label); + button.addEventListener('click', () => void this.select(item)); + this.buttons.set(item.id, button); + row.append(button); + + return row; + }), + ); + this.markSelection(); + } - if ((this.cat === 'sprites' || this.cat === 'spritesheets' || this.cat === 'inputPrompts') && this.key) { - const bx = PREVIEW_X + 16; - const by = H - 48; + private markSelection(): void { + for (const [id, button] of this.buttons) { + const current = id === this.selected?.id; - if (x >= bx && x < bx + 90 && y >= by && y < by + 30) { - this.animPlaying = !this.animPlaying; + button.style.background = current ? 'rgba(127, 184, 255, 0.26)' : 'transparent'; + button.setAttribute('aria-current', String(current)); - return; + if (current) { + button.scrollIntoView({ block: 'nearest' }); } } } - private onMove(x: number, y: number): void { - this.hoverIdx = null; + // --- Inspector ------------------------------------------------------------ + + private mountInspector(): void { + Object.assign(this.inspector.style, { + position: 'fixed', + inset: `0 0 0 auto`, + width: `${INSPECTOR_WIDTH}px`, + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '14px', + boxSizing: 'border-box', + overflowY: 'auto', + background: '#0b1019', + borderLeft: '1px solid rgba(154, 193, 255, 0.18)', + color: '#dce8ff', + font: `14px ${FONT}`, + zIndex: '5', + }); + this.inspector.setAttribute('aria-label', 'Selected asset'); + Object.assign(this.heading.style, { margin: '0', fontSize: '19px', color: '#ffffff' }); + Object.assign(this.facts.style, { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px', margin: '0', fontSize: '13px' }); + Object.assign(this.resident.style, { marginTop: 'auto', fontSize: '12px', color: '#8aa0c4' }); + this.inspector.append(this.heading, this.facts, this.panelSlot, this.resident); + document.body.append(this.inspector); + } - if (x >= 0 && x < SIDEBAR_W && y >= TOOLBAR_H) { - const keys = this.keys(); - const idx = Math.floor((y - LIST_Y) / ITEM_H) + this.scrollOff; + private renderFacts(item: BrowserItem, facts: ReadonlyArray): void { + const manifest = this.manifest.get(item.key); + const rows: Array = [ + ['Asset', `assets.${item.key}`], + ['File', fileName(assets[item.key].source)], + ...(manifest + ? [['Size', formatBytes(manifest.bytes + (item.atlasKey ? (this.manifest.get(item.atlasKey)?.bytes ?? 0) : 0))] as const] + : []), + ...facts, + ]; - if (idx >= 0 && idx < keys.length) { - const isHeader = this.cat === 'technical' && !keys[idx].includes('.'); + this.heading.textContent = item.name; + this.facts.replaceChildren( + ...rows.flatMap(([term, value]) => { + const dt = document.createElement('dt'); + const dd = document.createElement('dd'); - if (!isHeader) { - this.hoverIdx = idx; - } - } - } + dt.textContent = term; + dd.textContent = value; + dt.style.color = '#8aa0c4'; + Object.assign(dd.style, { margin: '0', overflowWrap: 'anywhere', color: '#f3f7ff' }); + + return [dt, dd]; + }), + ); } - private onWheel(dy: number): void { - const maxScroll = this.maxScroll(); - const delta = dy > 0 ? 1 : -1; - this.scrollOff = Math.max(0, Math.min(maxScroll, this.scrollOff + delta)); + /** Lists what the loader holds right now: the manifest plus the current selection, never an earlier one. */ + private renderResident(): void { + const rows = this.app.loader.inspect(); + const summary = document.createElement('summary'); + const list = document.createElement('ul'); + + summary.textContent = `Loader holds ${rows.length} asset${rows.length === 1 ? '' : 's'}`; + summary.style.cursor = 'pointer'; + Object.assign(list.style, { margin: '6px 0 0', paddingLeft: '16px', font: `11px ${MONO}` }); + list.append( + ...rows.map(row => { + const entry = document.createElement('li'); + + entry.textContent = `${fileName(row.locator)} (${row.state})`; + + return entry; + }), + ); + this.resident.dataset.residentCount = String(rows.length); + this.resident.replaceChildren(summary, list); } - override update(delta: Seconds): void { - if (!this.animPlaying) { - return; - } + // --- Code and data panel -------------------------------------------------- + + private mountCodePanel(): void { + const header = document.createElement('div'); + const copy = document.createElement('button'); + const close = document.createElement('button'); + + Object.assign(this.codePanel.style, { + position: 'fixed', + left: `${PREVIEW.x + 12}px`, + width: `${PREVIEW.width - 24}px`, + bottom: '12px', + maxHeight: '420px', + display: 'none', + flexDirection: 'column', + borderRadius: '10px', + border: '1px solid rgba(154, 193, 255, 0.3)', + background: 'rgba(8, 12, 20, 0.96)', + color: '#f3f7ff', + font: `14px ${FONT}`, + zIndex: '6', + }); + this.codePanel.setAttribute('aria-label', 'Code'); + Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px' }); + this.codeTitle.style.flex = '1 1 auto'; + + for (const [button, label] of [ + [copy, 'Copy'], + [close, 'Close'], + ] as const) { + button.type = 'button'; + button.textContent = label; + Object.assign(button.style, chipStyle(false)); + } + + copy.addEventListener('click', () => { + void navigator.clipboard.writeText(this.code.textContent ?? '').then( + () => { + copy.textContent = 'Copied'; + console.log(`Copied the ${this.codeTitle.textContent ?? 'code'} to the clipboard`); + }, + (error: unknown) => console.warn('The clipboard refused the copy:', error), + ); + }); + copy.addEventListener('blur', () => (copy.textContent = 'Copy')); + close.addEventListener('click', () => this.hideCode()); + Object.assign(this.code.style, { + margin: '0', + padding: '0 12px 12px', + overflow: 'auto', + font: `13px/1.45 ${MONO}`, + whiteSpace: 'pre', + color: '#dce8ff', + }); + this.code.tabIndex = 0; + header.append(this.codeTitle, copy, close); + this.codePanel.append(header, this.code); + document.body.append(this.codePanel); + } - if (this.cat !== 'sprites' && this.cat !== 'spritesheets' && this.cat !== 'inputPrompts') { - return; + showCode(): void { + if (this.preview) { + this.openCodePanel('Code', this.preview.snippet()); } + } - const frames = this.currentFrameKeys(); + async showData(): Promise { + const preview = this.preview; + const generation = this.generation; - if (!frames.length) { + if (!preview?.data) { return; } - this.frameTimer += delta; + this.openCodePanel('Data', 'Loading...'); + + try { + const data = await preview.data(); + + if (generation !== this.generation) { + return; + } + + const text = JSON.stringify(data, null, 2); + const limit = 60_000; - if (this.frameTimer >= 0.07) { - this.frameTimer = 0; - this.frameIdx = (this.frameIdx + 1) % frames.length; + this.openCodePanel('Data', text.length > limit ? `${text.slice(0, limit)}\n... ${text.length - limit} more characters` : text); + } catch (error) { + if (generation === this.generation && !isAbort(error)) { + this.openCodePanel('Data', `Could not load the data: ${error instanceof Error ? error.message : String(error)}`); + } } } - override draw(context: RenderingContext): void { - this.drawPreviewBg(context); - this.drawPreviewContent(context); - this.drawSidebar(context); - this.drawToolbar(context); + private openCodePanel(title: string, text: string): void { + this.codeTitle.textContent = title; + this.code.textContent = text; + this.codePanel.style.display = 'flex'; } - private drawNoAssets(context: RenderingContext): void { - const g = this.gPreview; - g.clear(); - g.fillColor = C.panel; - g.drawRectangle(0, 0, W, H); - context.render(g); - context.render(this.txtNoAssets); + private hideCode(): void { + this.codePanel.style.display = 'none'; } +} - private drawToolbar(context: RenderingContext): void { - const g = this.gToolbar; - g.clear(); - - g.fillColor = C.toolbar; - g.drawRectangle(0, 0, W, TOOLBAR_H); - - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(0, TOOLBAR_H - 1, W, TOOLBAR_H - 1); - g.drawLine(0, CAT_BTN_Y2 - 4, W, CAT_BTN_Y2 - 4); - - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - g.fillColor = this.cat === CATEGORIES_ROW1[i].id ? C.active : C.btnDark; - g.lineWidth = 0; - g.drawRectangle(bx, CAT_BTN_Y1, CAT_BTN_W, CAT_BTN_H); - } - - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - g.fillColor = this.cat === CATEGORIES_ROW2[i].id ? C.active : C.btnDark; - g.lineWidth = 0; - g.drawRectangle(bx, CAT_BTN_Y2, CAT_BTN_W, CAT_BTN_H); - } +const chipStyle = (active: boolean): Partial => ({ + padding: '4px 9px', + border: `1px solid ${active ? 'rgba(127, 184, 255, 0.7)' : 'rgba(154, 193, 255, 0.28)'}`, + borderRadius: '999px', + background: active ? 'rgba(127, 184, 255, 0.24)' : 'transparent', + color: active ? '#ffffff' : '#c4d2ec', + font: `600 12px ${FONT}`, + cursor: 'pointer', +}); - for (let i = 0; i < BG_OPTIONS.length; i++) { - const { r, g: gv, b } = BG_OPTIONS[i]; - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - g.fillColor = new Color(r, gv, b); - g.lineWidth = this.bgIdx === i ? 2 : 1; - g.lineColor = this.bgIdx === i ? C.accent : C.border; - g.drawRectangle(bx, BG_BTN_Y, BG_BTN_SIZE, BG_BTN_SIZE); - } +// --- Previews --------------------------------------------------------------- + +const openPreview = (context: PreviewContext): Promise => { + switch (context.item.kind) { + case 'texture': + case 'svg': + return openTexture(context); + case 'spritesheet': + return openSpritesheet(context); + case 'tiled': + case 'ldtk': + return openMap(context); + case 'sound': + case 'music': + return openAudio(context); + case 'video': + return openVideo(context); + case 'font': + case 'bmFont': + return openFont(context); + case 'data': + return openData(context); + } +}; - context.render(g); +const BACKDROPS = { Checker: null, Dark: new Color(32, 37, 50), Light: new Color(236, 239, 244), Black: new Color(0, 0, 0) } as const; +const BACKDROP_NAMES = Object.keys(BACKDROPS) as Array; - for (let i = 0; i < CATEGORIES_ROW1.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - const t = this.catBtnTexts[i]; - t.text = CATEGORIES_ROW1[i].label; - t.setPosition(bx + (CAT_BTN_W - 22) / 2, CAT_BTN_Y1 + 12); - context.render(t); - } +/** A transparency checkerboard behind a `width` x `height` image. */ +const checkerboard = (width: number, height: number): Graphics => { + const graphics = new Graphics(); + const cell = Math.max(4, Math.round(Math.max(width, height) / 32)); - for (let i = 0; i < CATEGORIES_ROW2.length; i++) { - const bx = CAT_BTN_X0 + i * (CAT_BTN_W + 4); - const t = this.catBtnTexts[CATEGORIES_ROW1.length + i]; - t.text = CATEGORIES_ROW2[i].label; - t.setPosition(bx + (CAT_BTN_W - 22) / 2, CAT_BTN_Y2 + 12); - context.render(t); - } + graphics.fillColor = new Color(58, 64, 78); + graphics.drawRectangle(0, 0, width, height); + graphics.fillColor = new Color(86, 93, 110); - for (let i = 0; i < BG_OPTIONS.length; i++) { - const bx = BG_BTN_X0 + i * (BG_BTN_SIZE + 5); - const t = this.bgBtnTexts[i]; - t.text = BG_OPTIONS[i].label; - t.style.fillColor = i === 1 ? C.white : C.dim; - t.setPosition(bx + 7, BG_BTN_Y + 7); - context.render(t); + for (let y = 0; y < height; y += cell) { + for (let x = (y / cell) % 2 === 0 ? 0 : cell; x < width; x += cell * 2) { + graphics.drawRectangle(x, y, Math.min(cell, width - x), Math.min(cell, height - y)); } } - private drawSidebar(context: RenderingContext): void { - const g = this.gSidebar; - g.clear(); - - g.fillColor = C.panel; - g.drawRectangle(0, TOOLBAR_H, SIDEBAR_W, H - TOOLBAR_H); - - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(SIDEBAR_W, TOOLBAR_H, SIDEBAR_W, H); - - const keys = this.keys(); - - for (let i = 0; i < keys.length; i++) { - const iy = LIST_Y + (i - this.scrollOff) * ITEM_H; + return graphics; +}; - if (iy + ITEM_H <= TOOLBAR_H || iy >= H) { - continue; +const zoomSlider = (value = 1) => slider('Zoom', { min: 0.05, max: 16, step: 0.05, value, format: zoom => `${Math.round(zoom * 100)}%` }); + +const openTexture = async ({ scene, item, scope }: PreviewContext): Promise => { + const descriptor = assets[item.key]; + const svg = descriptor.type === 'svg'; + // An SVG loads as an image element; a texture over it is this preview's to destroy. + const texture = svg + ? new Texture(await scope.load(descriptor as typeof assets.iconPlay)) + : await scope.load(descriptor as typeof assets.ship); + const sprite = new Sprite(texture); + const backdrop = checkerboard(texture.width, texture.height); + const state = { zoom: 1, nearest: false, backdrop: 'Checker' as keyof typeof BACKDROPS }; + + return { + world: [backdrop, sprite], + overlay: [], + bounds: { width: texture.width, height: texture.height }, + facts: [ + ['Pixels', `${texture.width} x ${texture.height}`], + ['Format', svg ? 'SVG, rasterized at its intrinsic size' : (fileName(descriptor.source).split('.').pop() ?? '').toUpperCase()], + ], + mount(host) { + const panel = host({ + title: 'Texture', + controls: { + zoom: zoomSlider(), + filtering: choice('Filtering', ['Linear', 'Nearest'] as const), + backdrop: choice('Backdrop', BACKDROP_NAMES), + }, + actions: { Fit: () => scene.fit(), '100%': () => panel.set('zoom', 1), Code: () => scene.showCode() }, + onChange(key, _value, values) { + state.zoom = values.zoom; + state.nearest = values.filtering === 'Nearest'; + state.backdrop = values.backdrop; + + if (key === 'zoom') { + scene.setZoom(values.zoom); + } else if (key === 'filtering') { + // Sampling is per texture handle; this preview's scope owns this one. + texture.scaleMode = state.nearest ? ScaleModes.Nearest : ScaleModes.Linear; + } else if (key === 'backdrop') { + backdrop.visible = values.backdrop === 'Checker'; + scene.setBackdrop(BACKDROPS[values.backdrop] ?? new Color(32, 37, 50)); + } + }, + }); + + scene.bindZoom(panel); + }, + snippet: () => + textureSnippet(item, { + zoom: state.zoom, + nearest: state.nearest, + width: texture.width, + height: texture.height, + }), + dispose() { + sprite.destroy(); + backdrop.destroy(); + + if (svg) { + texture.destroy(); } + }, + }; +}; - const isHeader = this.cat === 'technical' && !keys[i].includes('.'); - const isSelected = !isHeader && keys[i] === this.key; - const isHover = !isHeader && this.hoverIdx === i && !isSelected; - - if (isHeader) { - g.fillColor = C.infoBar; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); - } else if (isSelected) { - g.fillColor = C.active; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); - } else if (isHover) { - g.fillColor = C.hover; - g.lineWidth = 0; - g.drawRectangle(0, iy, SIDEBAR_W - 1, ITEM_H); +const openSpritesheet = async ({ scene, item, scope }: PreviewContext): Promise => { + const [texture, data] = await Promise.all([ + scope.load(assets[item.key] as typeof assets.platformerCharactersImage), + scope.load(assets[item.atlasKey!] as typeof assets.platformerCharactersAtlas), + ]); + const sheet = new Spritesheet(texture, data as SpritesheetData); + const names = [...sheet.frames.keys()]; + const animations = [...sheet.animations.keys()]; + const atlas = new Sprite(texture); + const backdrop = checkerboard(texture.width, texture.height); + const outlines = new Graphics(); + const strip = new Graphics(); + const frameLabel = new Text('', { fontSize: 15, fillColor: new Color(200, 214, 236) }); + const animationLabel = new Text('', { fontSize: 15, fillColor: new Color(200, 214, 236) }); + const animated = new AnimatedSprite(texture).setAnchor(0.5); + const selectedSprite = new Sprite(texture).setAnchor(0.5); + const STRIP_Y = PREVIEW.height - 208; + const boxWidth = (PREVIEW.width - 36) / 2; + const frameBox = { x: PREVIEW.x + 12, y: STRIP_Y, width: boxWidth, height: 196 }; + const animationBox = { x: frameBox.x + boxWidth + 12, y: STRIP_Y, width: boxWidth, height: 196 }; + const state = { + animation: animations.length > 0 ? animations[0]! : 'Frame range', + first: 0, + last: Math.min(names.length - 1, 1), + selected: 0, + fps: 8, + loop: true, + scale: 1, + bounds: true, + }; + const clipFrames = (): string[] => + state.animation === 'Frame range' + ? names.slice(Math.min(state.first, state.last), Math.max(state.first, state.last) + 1) + : [...sheet.getAnimationFrameNames(state.animation)]; + + const fitInBox = (sprite: Sprite, rect: Rectangle, box: typeof frameBox, scale: number): void => { + // The two-line label takes the top 44 px of the box. + const room = box.height - 52; + + sprite.setScale(Math.min(scale, room / rect.height)).setPosition(box.x + box.width / 2, box.y + 44 + (box.height - 44) / 2); + }; + + const redraw = (): void => { + const range = new Set(clipFrames()); + + outlines.clear(); + + if (state.bounds) { + for (const [name, rect] of sheet.frames) { + const selected = names.indexOf(name) === state.selected; + + outlines.lineWidth = selected ? 3 : 1; + outlines.lineColor = selected + ? new Color(255, 214, 102) + : range.has(name) + ? new Color(127, 220, 160, 0.95) + : new Color(127, 184, 255, 0.55); + outlines.drawRectangle(rect.x, rect.y, rect.width, rect.height); } } - context.render(g); + const selectedName = names[state.selected]!; + const selectedRect = sheet.getFrame(selectedName); - for (let i = 0; i < keys.length && i - this.scrollOff < this.itemTexts.length; i++) { - const iy = LIST_Y + (i - this.scrollOff) * ITEM_H; + selectedSprite.setTextureFrame(selectedRect); + fitInBox(selectedSprite, selectedRect, frameBox, 4); + frameLabel.text = `Frame ${state.selected + 1}/${names.length} +${selectedName}, ${selectedRect.width} x ${selectedRect.height}`; - if (iy + ITEM_H <= TOOLBAR_H || iy >= H) { - continue; - } + const frames = clipFrames().map(name => sheet.getFrame(name)); - if (this.cat === 'technical' && !keys[i].includes('.')) { - const ht = this.techHeaderTexts.get(keys[i]); + animated.setClips({ preview: { frames, fps: state.fps, repeat: state.loop ? -1 : 1 } }).play('preview', { restart: true }); + fitInBox(animated, frames[0]!, animationBox, state.scale); + animationLabel.text = `Animation: ${state.animation === 'Frame range' ? `frames ${Math.min(state.first, state.last) + 1}-${Math.max(state.first, state.last) + 1}` : state.animation} +${frames.length} frames, ${state.fps} fps${state.loop ? ', loop' : ''}`; + }; - if (ht) { - ht.setPosition(10, iy + 12); - context.render(ht); - } + strip.fillColor = new Color(11, 16, 25, 0.94); + strip.drawRectangle(PREVIEW.x, STRIP_Y - 12, PREVIEW.width, PREVIEW.height - STRIP_Y + 12); + strip.fillColor = new Color(24, 30, 44); - continue; - } - - const t = this.itemTexts[i - this.scrollOff]; + for (const box of [frameBox, animationBox]) { + strip.drawRectangle(box.x, box.y, box.width, box.height); + } - if (!t) { - continue; + frameLabel.setPosition(frameBox.x + 10, frameBox.y + 6); + animationLabel.setPosition(animationBox.x + 10, animationBox.y + 6); + redraw(); + + return { + world: [backdrop, atlas, outlines], + overlay: [strip, selectedSprite, animated, frameLabel, animationLabel], + // Fit the atlas into the area above the frame strip. + bounds: { width: texture.width, height: texture.height * (PREVIEW.height / (STRIP_Y - 24)) }, + facts: [ + ['Atlas', `${texture.width} x ${texture.height} px`], + ['Frames', String(names.length)], + ['Animations', animations.length > 0 ? animations.join(', ') : 'none in the atlas data - pick a frame range'], + ], + mount(host) { + const last = Math.max(0, names.length - 1); + const frameName = (index: number): string => `#${Math.round(index) + 1}`; + const controls = { + zoom: zoomSlider(), + animation: choice('Animation', ['Frame range', ...animations], state.animation), + first: slider('First frame', { min: 0, max: last, step: 1, value: state.first, format: frameName }), + last: slider('Last frame', { min: 0, max: last, step: 1, value: state.last, format: frameName }), + fps: slider('FPS', { min: 1, max: 30, step: 1, value: state.fps, format: fps => `${fps}` }), + loop: toggle('Loop', state.loop), + scale: slider('Scale', { min: 0.25, max: 6, step: 0.25, value: state.scale, format: scale => `${scale}x` }), + bounds: toggle('Frame bounds', state.bounds), + }; + const handle = host({ + title: 'Spritesheet', + controls, + actions: { + 'Range from selected': () => handle.set('first', state.selected), + 'Range to selected': () => handle.set('last', state.selected), + Fit: () => scene.fit(), + Code: () => scene.showCode(), + Data: () => void scene.showData(), + }, + onChange(key, _value, values) { + if (key === 'zoom') { + scene.setZoom(values.zoom); + + return; + } + + Object.assign(state, { + animation: values.animation, + first: values.first, + last: values.last, + fps: values.fps, + loop: values.loop, + scale: values.scale, + bounds: values.bounds, + }); + redraw(); + }, + }); + + scene.bindZoom(handle); + handle.status('Click a frame in the atlas to select it.'); + }, + update(delta) { + animated.update(delta); + }, + tap(x, y) { + const index = names.findIndex(name => sheet.getFrame(name).contains(x, y)); + + if (index >= 0) { + state.selected = index; + redraw(); + console.log(`Frame ${index + 1}: ${names[index]}`); } + }, + snippet: () => spritesheetSnippet(item, { ...state, frames: clipFrames() }), + data: () => Promise.resolve(data), + dispose() { + animated.destroy(); + selectedSprite.destroy(); + atlas.destroy(); + backdrop.destroy(); + outlines.destroy(); + strip.destroy(); + frameLabel.destroy(); + animationLabel.destroy(); + sheet.destroy(); + }, + }; +}; - t.text = this.cat === 'technical' ? (keys[i].split('.')[1] ?? keys[i]) : keys[i]; - t.style.fillColor = keys[i] === this.key ? C.white : C.dim; - t.setPosition(this.cat === 'technical' ? 18 : 10, iy + 10); - context.render(t); +const openMap = async ({ scene, item, scope }: PreviewContext): Promise => { + const source = assets[item.key].source; + const ldtk = item.kind === 'ldtk'; + // Tileset images resolve relative to the map file, which is why the map + // loads from the catalog's absolute URL through the format's own type. + const world = ldtk ? await scope.load(Asset.type('ldtkMap', source)) : null; + const levels: readonly TileMap[] = world ? world.levels : [await scope.load(Asset.type('tileMap', source))]; + const state = { level: 0, objects: true, zoom: 1, layers: new Map() }; + const objects = new Graphics(); + let node: TileMapNode | null = null; + const preview: Preview & { world: RenderNode[] } = { + world: [], + overlay: [], + bounds: null, + facts: [], + mount(host) { + const map = levels[state.level]!; + const layerControls = Object.fromEntries( + map.renderableLayers.map(layer => [`layer:${layer.name}`, toggle(`Layer ${layer.name}`, true)]), + ); + const panel = host({ + title: ldtk ? 'LDtk world' : 'Tiled map', + controls: { + zoom: zoomSlider(), + ...(levels.length > 1 + ? { + level: choice( + 'Level', + levels.map(level => level.name), + ), + } + : {}), + objects: toggle('Object shapes', state.objects), + ...layerControls, + }, + actions: { Fit: () => scene.fit(), Code: () => scene.showCode(), Data: () => void scene.showData() }, + onChange(key, value) { + if (key === 'zoom') { + state.zoom = value as number; + scene.setZoom(value as number); + } else if (key === 'level') { + state.level = Math.max( + 0, + levels.findIndex(level => level.name === value), + ); + build(); + scene.fit(); + } else if (key === 'objects') { + state.objects = value as boolean; + objects.visible = state.objects; + } else if (String(key).startsWith('layer:')) { + const name = String(key).slice('layer:'.length); + const layerNode = node?.getLayerNode(name); + + state.layers.set(name, value as boolean); + + if (layerNode) { + layerNode.visible = value as boolean; + } + } + }, + }); + + scene.bindZoom(panel as unknown as { set(key: 'zoom', value: number): void }); + }, + snippet: () => mapSnippet(item, { level: levels[state.level]!.name, zoom: state.zoom, ldtk }), + data: async () => scope.load(assets[item.key] as typeof assets.harborPlazaMap), + dispose() { + node?.destroy(); + objects.destroy(); + }, + }; + + const build = (): void => { + const map = levels[state.level]!; + + node?.destroy(); + node = new TileMapNode(map); + objects.clear(); + objects.lineWidth = 2; + objects.lineColor = new Color(255, 191, 82, 0.9); + objects.fillColor = new Color(255, 191, 82, 0.16); + + let count = 0; + + for (const layer of map.objectLayers) { + for (const object of layer.objects) { + count += 1; + + if (object.kind === ObjectKind.Point) { + objects.drawCircle(object.x, object.y, 6); + } else if (object.kind === ObjectKind.Ellipse) { + objects.drawEllipse(object.x + object.width / 2, object.y + object.height / 2, object.width / 2, object.height / 2); + } else { + objects.drawRectangle(object.x, object.y, object.width, object.height); + } + } } - if (keys.length === 0) { - this.txtEmptyCat.setPosition(60, LIST_Y + 14); - context.render(this.txtEmptyCat); - } - } + objects.visible = state.objects; + preview.world.splice(0, preview.world.length, node, objects); + Object.assign(preview, { + bounds: { width: map.pixelWidth ?? (map.width ?? 0) * map.tileWidth, height: map.pixelHeight ?? (map.height ?? 0) * map.tileHeight }, + facts: [ + ['Format', ldtk ? 'LDtk world' : 'Tiled map'], + ...(levels.length > 1 ? [['Levels', levels.map(level => level.name).join(', ')] as const] : []), + ['Tiles', `${map.width ?? 'unbounded'} x ${map.height ?? 'unbounded'} at ${map.tileWidth} x ${map.tileHeight} px`], + ['Layers', map.renderableLayers.map(layer => layer.name).join(', ') || 'none'], + ['Objects', String(count)], + ], + }); + }; - private drawPreviewBg(context: RenderingContext): void { - const { r, g, b } = BG_OPTIONS[this.bgIdx]; - const bg = this.gPreview; - bg.clear(); - bg.fillColor = new Color(r, g, b); - bg.drawRectangle(PREVIEW_X, PREVIEW_Y, PREVIEW_W, PREVIEW_H); - context.render(bg); - } + build(); - private drawPreviewContent(context: RenderingContext): void { - if (!this.key) { - this.txtNoSel.setPosition(PREVIEW_X + PREVIEW_W / 2 - 130, H / 2 - 10); - context.render(this.txtNoSel); - this.drawInfoBar(context); + return preview; +}; +const openAudio = async ({ scene, item, scope, isCurrent }: PreviewContext): Promise => { + const descriptor = assets[item.key]; + const music = descriptor.type === 'music'; + // A sound sprite ships its clip table as JSON next to the audio file. + const spriteTable = demoAssetCategories[item.key] === 'soundSprites' ? descriptor.source.replace(/\.[a-z0-9]+$/, '.json') : null; + const [playable, table] = await Promise.all([ + music ? scope.load(descriptor as typeof assets.musicLoopMain) : scope.load(descriptor as typeof assets.soundUiClick), + spriteTable ? scope.load(Asset.type>('json', spriteTable)) : Promise.resolve(null), + ]); + const clips = table ? Object.keys(table) : []; + const audio = scene.app.audio; + const state = { clip: clips[0] ?? '', volume: 0.8, loop: false }; + let voice: Voice | null = null; + let pendingStart: (() => void) | null = null; + let status: ((text: string) => void) | null = null; + let reported = ''; + const waveform = new Graphics(); + const playhead = new Graphics(); + const live = new Graphics(); + const analyser = new AudioAnalyser(); + const bright = new Color(127, 184, 255); + const dim = new Color(127, 184, 255, 0.25); + const muted = new Color(138, 160, 196); + const buffer = music ? null : (playable as Sound).audioBuffer; + const channels = buffer?.numberOfChannels ?? 0; + const channelNames = channels === 2 ? ['L', 'R'] : Array.from({ length: channels }, (_, channel) => String(channel + 1)); + const wave = { width: PREVIEW.width - 80, height: channels > 1 ? 300 : 220, gap: 16, left: PREVIEW.x + 40 }; + // A sound keeps its overview on top and the live signal in a strip below; + // streamed music has no overview, so the live signal takes the middle. + const scopeHeight = buffer ? 100 : 220; + const top = buffer ? PREVIEW_CENTER.y - (wave.height + 48 + scopeHeight) / 2 : PREVIEW_CENTER.y - scopeHeight / 2; + const lane = { height: (wave.height - wave.gap * Math.max(0, channels - 1)) / Math.max(1, channels), top }; + const strip = { top: buffer ? top + wave.height + 48 : top, height: scopeHeight }; + const label = (text: string, y: number): Text => + new Text(text, { fontSize: 14, fontFamily: MONO, fillColor: muted }).setAnchor(1, 0.5).setPosition(wave.left - 10, y); + const labels = [ + ...(channels > 1 + ? channelNames.map((name, channel) => label(name, lane.top + channel * (lane.height + wave.gap) + lane.height / 2)) + : []), + ...(buffer + ? [ + new Text('Live signal', { fontSize: 14, fontFamily: MONO, fillColor: muted }) + .setAnchor(0, 1) + .setPosition(wave.left, strip.top - 8), + ] + : []), + ]; + const caption = new Text(music ? 'Press Play to see the live signal' : '', { fontSize: 18, fillColor: muted }); + + // A decoded sound draws one lane per channel: a min/max bar per column, with + // the selected sprite clip highlighted across all lanes. + const drawWaveform = (): void => { + waveform.clear(); + + if (!buffer) { return; } - // Category still fetching (lazy-load): keep the sidebar interactive - // and show a placeholder instead of an empty preview. - if (!this.loadedCats.has(this.cat)) { - this.txtLoading.setPosition(PREVIEW_X + PREVIEW_W / 2 - 70, H / 2 - 10); - context.render(this.txtLoading); - this.drawInfoBar(context); + const clip = table && state.clip ? table[state.clip] : undefined; - return; + if (clip) { + waveform.fillColor = new Color(127, 184, 255, 0.16); + waveform.drawRectangle( + wave.left + (clip.start / buffer.duration) * wave.width, + lane.top, + ((clip.end - clip.start) / buffer.duration) * wave.width, + wave.height, + ); } - switch (this.cat) { - case 'textures': - this.drawTexPreview(context); - break; - case 'sprites': - this.drawSprPreview(context); - break; - case 'spritesheets': - this.drawSshPreview(context); - break; - case 'svg': - this.drawSvgPreview(context); - break; - case 'audio': - this.drawAudioPreview(context, this.audioMusics); - break; - case 'sound': - this.drawAudioPreview(context, this.soundMusics); - break; - case 'music': - this.drawAudioPreview(context, this.musicMusics); - break; - case 'soundSprites': - this.drawSoundSpritePreview(context); - break; - case 'fonts': - this.drawFontPreview(context); - break; - case 'video': - this.drawVideoPreview(context); - break; - case 'inputPrompts': - this.drawInpPreview(context); - break; - case 'technical': - this.drawTechPreview(context); - break; - case 'backgrounds': - this.drawBgPreview(context); - break; - case 'cursors': - this.drawCursorPreview(context); - break; - case 'tilesets': - this.drawTilesetPreview(context); - break; - case 'vendor': - this.drawVendorPreview(context); - break; - } + for (let channel = 0; channel < channels; channel += 1) { + const samples = buffer.getChannelData(channel); + const middle = lane.top + channel * (lane.height + wave.gap) + lane.height / 2; + const scale = lane.height / 2; - this.drawInfoBar(context); - } + waveform.fillColor = dim; + waveform.drawRectangle(wave.left, middle - 0.5, wave.width, 1); + waveform.fillColor = bright; - private drawInfoBar(context: RenderingContext): void { - const g = this.gInfoBar; - g.clear(); - g.fillColor = C.infoBar; - g.drawRectangle(PREVIEW_X, PREVIEW_Y, PREVIEW_W, 44); - g.lineWidth = 1; - g.lineColor = C.border; - g.drawLine(PREVIEW_X, PREVIEW_Y + 44, W, PREVIEW_Y + 44); - context.render(g); - - if (!this.key) { - return; - } + for (let column = 0; column < wave.width; column += 2) { + const from = Math.floor((column / wave.width) * samples.length); + const to = Math.max(from + 1, Math.floor(((column + 2) / wave.width) * samples.length)); + let low = 0; + let high = 0; - this.txtKey.text = this.assetExpression(); - this.txtKey.setPosition(PREVIEW_X + 10, PREVIEW_Y + 5); - context.render(this.txtKey); + for (let index = from; index < to; index += 1) { + low = Math.min(low, samples[index]!); + high = Math.max(high, samples[index]!); + } - this.txtPath.text = this.assetPath(); - this.txtPath.setPosition(PREVIEW_X + 10, PREVIEW_Y + 24); - context.render(this.txtPath); + waveform.drawRectangle(wave.left + column, middle - high * scale, 1.5, Math.max(1, (high - low) * scale)); + } + } + }; - const ext = this.typeLabel(); + // The analyser's window is the last few milliseconds the voice produced, + // down-mixed to one channel - an oscilloscope, not a second overview. + const drawLive = (signal: Float32Array | null): void => { + const middle = strip.top + strip.height / 2; + const scale = strip.height / 2; - if (ext) { - this.txtType.text = ext; - this.txtType.setPosition(PREVIEW_X + PREVIEW_W - 160, PREVIEW_Y + 15); - context.render(this.txtType); - } + live.clear(); + live.fillColor = dim; + live.drawRectangle(wave.left, middle - 0.5, wave.width, 1); - if (!this.copyBtnBg) { - this.copyBtnBg = new Graphics(); + if (!signal) { + return; } - const copyX = PREVIEW_X + PREVIEW_W - 84; - const copyY = PREVIEW_Y + 9; - this.copyBtnBg.clear(); - this.copyBtnBg.fillColor = C.btnDark; - this.copyBtnBg.lineWidth = 1; - this.copyBtnBg.lineColor = C.border; - this.copyBtnBg.drawRectangle(copyX, copyY, 72, 26); - context.render(this.copyBtnBg); - this.txtCopy.setPosition(copyX + 10, copyY + 7); - context.render(this.txtCopy); - } + live.fillColor = bright; - private previewCenter(): { cx: number; cy: number; maxW: number; maxH: number } { - return { - cx: PREVIEW_X + PREVIEW_W / 2, - cy: PREVIEW_Y + 44 + (PREVIEW_H - 44) / 2, - maxW: PREVIEW_W - 80, - maxH: PREVIEW_H - 80, - }; - } + for (let column = 0; column < wave.width; column += 2) { + const from = Math.floor((column / wave.width) * signal.length); + const to = Math.max(from + 1, Math.floor(((column + 2) / wave.width) * signal.length)); + let low = 0; + let high = 0; - private fitSprite(sprite: Sprite, maxW: number, maxH: number, cx: number, cy: number): void { - const tex = sprite.texture; + for (let index = from; index < to; index += 1) { + low = Math.min(low, signal[index]!); + high = Math.max(high, signal[index]!); + } - if (!tex) { - return; + live.drawRectangle(wave.left + column, middle - high * scale, 1.5, Math.max(1, (high - low) * scale)); } + }; - const tw = tex.width || 128; - const th = tex.height || 128; - const scale = Math.min(maxW / tw, maxH / th, 3); - sprite.setScale(scale); - sprite.setPosition(cx, cy); - } - - private drawTexPreview(context: RenderingContext): void { - const sprite = this.texSprites.get(this.key ?? ''); + const drawPlayhead = (): void => { + playhead.clear(); - if (!sprite) { + if (!buffer || !voice || voice.ended || !('time' in voice)) { return; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); - } + // A sprite voice reports its time from the start of its clip. + const offset = table && state.clip ? (table[state.clip]?.start ?? 0) : 0; + const x = wave.left + ((offset + (voice as Voice & { time: number }).time) / buffer.duration) * wave.width; - private drawSprPreview(context: RenderingContext): void { - const ss = this.sprSheets.get(this.key ?? ''); + playhead.fillColor = Color.white; + playhead.drawRectangle(x - 0.75, lane.top, 1.5, wave.height); + }; - if (!ss) { - return; - } + caption.setAnchor(0.5).setPosition(PREVIEW_CENTER.x, strip.top + strip.height + 40); + drawWaveform(); + drawLive(null); - const frames = [...ss.sprites.keys()]; + const target = (): Sound | AudioStream => (clips.length > 0 ? (playable as Sound).sprite(state.clip) : playable); + const pausable = (candidate: Voice | null): (Voice & { paused: boolean; pause(): void; resume(): void }) | null => + candidate && 'pause' in candidate && 'resume' in candidate + ? (candidate as Voice & { paused: boolean; pause(): void; resume(): void }) + : null; - if (!frames.length) { - return; - } + const play = (): void => { + const current = pausable(voice); - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); - } + if (current && !current.ended && current.paused) { + current.resume(); - private drawSshPreview(context: RenderingContext): void { - const ss = this.sshSheets.get(this.key ?? ''); - - if (!ss) { return; } - const frames = [...ss.sprites.keys()]; - - if (!frames.length) { + if (pendingStart || (voice && !voice.ended)) { return; } - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); - } - - private drawInpPreview(context: RenderingContext): void { - const ss = this.inpSheets.get(this.key ?? ''); - - if (ss) { - const frames = [...ss.sprites.keys()]; - - if (frames.length > 0) { - const sprite = ss.getFrameSprite(frames[this.frameIdx % frames.length]); - sprite.setAnchor(0.5); - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 50, cx, cy); - context.render(sprite); - this.drawAnimControls(context, frames.length); + // Runs once audio is usable - at once when it already is, after the + // visitor's first gesture otherwise. Removed again by Stop and on switch, + // so a start that is still waiting never plays for another selection. + pendingStart = () => { + pendingStart = null; + if (!isCurrent()) { return; } - } - } - - private drawSvgPreview(context: RenderingContext): void { - const sprite = this.svgSprites.get(this.key ?? ''); - - if (!sprite) { - return; - } - - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); - } - - private drawAudioPreview(context: RenderingContext, musicMap: Map): void { - if (!this.audioG) { - this.audioG = new Graphics(); - } - - const music = musicMap.get(this.key ?? ''); - const isPlaying = music ? this.previewIsPlaying() : false; - const g = this.audioG; - const { cx, cy } = this.previewCenter(); - const bx = cx - 50; - const by = cy - 28; - g.clear(); - g.fillColor = isPlaying ? C.btnGreen : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 100, 56); - context.render(g); - - this.txtAudioIcon.text = isPlaying ? '⏸' : '▶'; - this.txtAudioIcon.setPosition(bx + (isPlaying ? 30 : 34), by + 10); - context.render(this.txtAudioIcon); - - this.txtMeta.text = this.typeLabel(); - this.txtMeta.setPosition(cx - 15, by + 68); - context.render(this.txtMeta); - } - private drawSoundSpritePreview(context: RenderingContext): void { - if (!this.audioG) { - this.audioG = new Graphics(); - } + voice = audio.play(target(), { volume: state.volume, loop: state.loop }); + analyser.source = voice; + console.log(`Playing ${item.name}${clips.length > 0 ? ` / ${state.clip}` : ''}`); + }; - const music = this.soundSpriteAudio.get(this.key ?? ''); - const data = this.soundSpriteData.get(this.key ?? ''); - const isPlaying = music ? this.previewIsPlaying() : false; - const sprites = data?.sprites ?? {}; - - const g = this.audioG; - const { cx } = this.previewCenter(); - const bx = cx - 50; - const by = PREVIEW_Y + 56; - - g.clear(); - g.fillColor = isPlaying ? C.btnGreen : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 100, 44); - context.render(g); - - this.txtAudioIcon.text = isPlaying ? '⏸' : '▶'; - this.txtAudioIcon.setPosition(bx + (isPlaying ? 30 : 34), by + 6); - context.render(this.txtAudioIcon); - - let y = by + 64; - - for (const [name, info] of Object.entries(sprites)) { - this.txtMeta.text = `${name} start:${info.start.toFixed(3)}s dur:${info.duration.toFixed(3)}s`; - this.txtMeta.setPosition(PREVIEW_X + 30, y); - context.render(this.txtMeta); - y += 20; - - if (y > H - 20) { - break; + audio.onUnlock.add(pendingStart); + + if (audio.locked) { + status?.('Waiting for the browser to allow audio...'); + } + }; + + const stop = (): void => { + if (pendingStart) { + audio.onUnlock.remove(pendingStart); + pendingStart = null; + } + + voice?.stop(); + voice = null; + analyser.source = null; + }; + + return { + world: [], + overlay: [waveform, playhead, live, caption, ...labels], + bounds: null, + facts: [ + ['Kind', music ? 'Music, streamed from an audio element' : 'Sound, decoded into memory'], + ['Duration', formatSeconds(playable.duration)], + ...(buffer ? [['Channels', channels === 1 ? 'Mono' : channels === 2 ? 'Stereo' : String(channels)] as const] : []), + ...(clips.length > 0 ? [['Clips', clips.join(', ')] as const] : []), + ], + mount(host) { + const panel = host({ + title: music ? 'Music' : 'Sound', + controls: { + ...(clips.length > 0 ? { clip: choice('Clip', clips) } : {}), + volume: slider('Volume', { min: 0, max: 1, step: 0.05, value: state.volume, format: volume => `${Math.round(volume * 100)}%` }), + loop: toggle('Loop', state.loop), + }, + actions: { + Play: play, + Pause: () => { + const current = pausable(voice); + + if (current && !current.ended) { + current.pause(); + } + }, + Stop: stop, + Code: () => scene.showCode(), + }, + onChange(key, value) { + if (key === 'clip') { + state.clip = value as string; + stop(); + drawWaveform(); + } else if (key === 'volume') { + state.volume = value as number; + + if (voice) { + voice.volume = state.volume; + } + } else if (key === 'loop') { + state.loop = value as boolean; + } + }, + }); + + status = text => panel.status(text); + panel.status('Press Play to listen.'); + }, + update() { + const current = pausable(voice); + const text = pendingStart + ? audio.locked + ? 'Waiting for the browser to allow audio...' + : 'Starting...' + : !voice || voice.ended + ? 'Stopped' + : current?.paused + ? 'Paused' + : 'Playing'; + + if (text !== reported) { + reported = text; + status?.(text); } - } - } - private drawFontPreview(context: RenderingContext): void { - const family = this.fontFamilies.get(this.key ?? ''); - - if (!family) { - this.txtMeta.text = `Font: ${this.key}\n${this.assetPath()}`; - this.txtMeta.setPosition(PREVIEW_X + 40, PREVIEW_Y + 80); - context.render(this.txtMeta); + const playing = text === 'Playing' || text === 'Paused'; + + caption.visible = !playing; + drawLive(playing ? analyser.getWaveformFloat() : null); + drawPlayhead(); + }, + snippet: () => audioSnippet(item, { ...state, music, clip: clips.length > 0 ? state.clip : null }), + dispose() { + stop(); + status = null; + analyser.destroy(); + waveform.destroy(); + playhead.destroy(); + live.destroy(); + caption.destroy(); + labels.forEach(text => text.destroy()); + }, + }; +}; +const openVideo = async ({ scene, item, scope, manifest, isCurrent }: PreviewContext): Promise => { + const video = await scope.load(assets[item.key] as typeof assets.videoDemoLoop); + const element = video.videoElement; + const meta = manifest?.media; + const audio = scene.app.audio; + const state = { muted: true, loop: true, rate: '1x' as '0.5x' | '1x' | '2x', zoom: 1 }; + let pendingStart: (() => void) | null = null; + let panel: { set(key: 'position', value: number): void; status(text: string): void } | null = null; + let syncing = false; + let mounted = false; + let since = 0; + + const play = (): void => { + if (pendingStart || video.playing) { return; } - let t = this.fontSampleTexts.get(this.key ?? ''); + // A muted video may start at once; one with sound waits for the gesture. + if (video.muted) { + video.play(); + console.log(`Playing ${item.name}`); - if (!t) { - t = new Text('AaBbCc 123 !?', { fontFamily: family, fontSize: 48, fillColor: C.white }); - this.fontSampleTexts.set(this.key ?? '', t); + return; } - t.style.fillColor = this.bgIdx === 0 ? C.black : C.white; - t.setPosition(PREVIEW_X + 40, PREVIEW_Y + 120); - context.render(t); + pendingStart = () => { + pendingStart = null; - let t2 = this.fontSampleTexts.get(`${this.key}_sm`); + if (isCurrent()) { + video.play(); + console.log(`Playing ${item.name} with sound`); + } + }; - if (!t2) { - t2 = new Text('The quick brown fox jumps over the lazy dog', { fontFamily: family, fontSize: 20, fillColor: C.dim }); - this.fontSampleTexts.set(`${this.key}_sm`, t2); - } + audio.onUnlock.add(pendingStart); + }; + + const cancelStart = (): void => { + if (pendingStart) { + audio.onUnlock.remove(pendingStart); + pendingStart = null; + } + }; + + const frameRate = meta?.video?.frameRate?.split('/').map(Number); + + return { + world: [video], + overlay: [], + bounds: { width: video.width, height: video.height }, + facts: [ + ['Resolution', `${element.videoWidth} x ${element.videoHeight}`], + ['Duration', formatSeconds(video.duration)], + ...(meta?.container ? [['Container', meta.container] as const] : []), + ...(meta?.video?.codec ? [['Video', [meta.video.codec, meta.video.profile].filter(Boolean).join(' ')] as const] : []), + ...(frameRate && frameRate.length === 2 + ? [['Frame rate', `${(frameRate[0]! / frameRate[1]!).toFixed(2).replace(/\.00$/, '')} fps`] as const] + : []), + ...(meta?.video?.pixelFormat + ? [['Pixels', `${meta.video.pixelFormat}${meta.video.bitDepth ? `, ${meta.video.bitDepth}-bit` : ''}`] as const] + : []), + ...(meta?.audio?.codec + ? [['Audio', `${meta.audio.codec}, ${meta.audio.channels ?? '?'} ch`] as const] + : ([['Audio', 'none']] as const)), + // The file's dynamic range is metadata; the preview renders it through + // the regular canvas pipeline either way. + ...(meta?.dynamicRange ? [['Dynamic range', `${meta.dynamicRange} (file metadata)`] as const] : []), + ], + mount(host) { + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1; + const handle = host({ + title: 'Video', + controls: { + zoom: zoomSlider(), + position: slider('Position', { min: 0, max: duration, step: duration / 200, value: 0, format: formatSeconds }), + muted: toggle('Muted', state.muted), + loop: toggle('Loop', state.loop), + rate: choice('Speed', ['0.5x', '1x', '2x'] as const, state.rate), + }, + actions: { + Play: play, + Pause: () => { + cancelStart(); + video.pause(); + }, + Stop: () => { + cancelStart(); + video.stop(); + }, + Fit: () => scene.fit(), + Code: () => scene.showCode(), + }, + onChange(key, value) { + if (key === 'zoom') { + state.zoom = value as number; + scene.setZoom(value as number); + } else if (key === 'position' && !syncing) { + video.currentTime = value as number; + } else if (key === 'muted') { + state.muted = value as boolean; + video.muted = state.muted; + } else if (key === 'loop') { + state.loop = value as boolean; + video.loop = state.loop; + } else if (key === 'rate') { + state.rate = value as typeof state.rate; + video.playbackRate = Number.parseFloat(state.rate); + } + }, + }); + + panel = handle; + mounted = true; + video.applyOptions({ loop: state.loop, muted: state.muted }); + video.pause(); + scene.bindZoom(handle); + handle.status('Press Play to start.'); + }, + update(delta) { + since += delta; + + if (panel && since >= 0.25) { + since = 0; + syncing = true; + panel.set('position', video.currentTime); + syncing = false; + panel.status(pendingStart ? 'Waiting for the browser to allow audio...' : video.playing ? 'Playing' : 'Paused'); + } + }, + snippet: () => videoSnippet(item, state), + dispose() { + cancelStart(); + panel = null; + + // The video belongs to the scope and may be shared with a newer preview + // of the same asset, so only a preview that was mounted pauses it. + if (mounted) { + video.pause(); + } + }, + }; +}; - t2.style.fillColor = this.bgIdx === 0 ? C.dimDark : C.dim; - t2.setPosition(PREVIEW_X + 40, PREVIEW_Y + 190); - context.render(t2); - } +const measureContext = document.createElement('canvas').getContext('2d'); - private drawVideoPreview(context: RenderingContext): void { - this.txtMeta.text = `VIDEO\n\nKey: assets.video.${this.key}\nURL: ${this.assetPath()}\n\nOpen the URL in your browser to preview.`; - this.txtMeta.setPosition(PREVIEW_X + 40, PREVIEW_Y + 80); - context.render(this.txtMeta); +const measureWidth = (text: string, font: string): number => { + if (!measureContext) { + return text.length * 0.6 * Number.parseFloat(font); } - private drawTechPreview(context: RenderingContext): void { - if (!this.key?.includes('.')) { - return; - } - - const sprite = this.techSprites.get(this.key); + measureContext.font = font; - if (!sprite) { - return; - } - - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 28, cx, cy - 14); - context.render(sprite); - - const subcat = this.key.split('.')[0]; - const purpose = TECH_PURPOSE[subcat] ?? ''; - - if (purpose) { - this.txtTechPurpose.text = purpose; - this.txtTechPurpose.setPosition(PREVIEW_X + 16, PREVIEW_Y + PREVIEW_H - 22); - context.render(this.txtTechPurpose); - } - } + return measureContext.measureText(text).width; +}; - private drawBgPreview(context: RenderingContext): void { - const sprite = this.bgSprites.get(this.key ?? ''); +const SAMPLE_TEXT = 'The quick brown fox jumps over the lazy dog'; + +const openFont = async ({ scene, item, scope }: PreviewContext): Promise => { + const descriptor = assets[item.key]; + const bitmap = descriptor.type === 'bmFont'; + const font: BmFont | null = bitmap ? await scope.load(descriptor as typeof assets.fontKenneyBlocksFnt) : null; + const face: FontFace | null = bitmap ? null : await scope.load(descriptor as typeof assets.fontKenneyFuture); + const family = face ? face.family.replaceAll('"', '') : ''; + const state = { text: SAMPLE_TEXT, size: 40, color: 'White' as 'White' | 'Gold' | 'Ink' }; + const colors = { White: Color.white, Gold: new Color(255, 214, 102), Ink: new Color(28, 32, 44) }; + const lines: Array = []; + const preview: Preview & { overlay: RenderNode[] } = { + world: [], + overlay: [], + bounds: null, + facts: bitmap + ? [['Kind', 'Bitmap font (.fnt with page textures)']] + : [ + ['Family', family], + ['Kind', 'Font face, registered with the document'], + ], + mount(host) { + const panel = host({ + title: 'Font', + controls: { + size: slider('Size', { min: 10, max: 120, step: 2, value: state.size, format: size => `${size}px` }), + color: choice('Colour', ['White', 'Gold', 'Ink'] as const), + }, + actions: { Code: () => scene.showCode() }, + onChange(key, value) { + if (key === 'size') { + state.size = value as number; + } else { + state.color = value as typeof state.color; + scene.setBackdrop(state.color === 'Ink' ? new Color(236, 239, 244) : new Color(32, 37, 50)); + } + + layout(); + }, + }); + const input = document.createElement('textarea'); + + input.value = state.text; + input.rows = 2; + input.setAttribute('aria-label', 'Preview text'); + Object.assign(input.style, { + width: '100%', + boxSizing: 'border-box', + marginTop: '8px', + padding: '6px 8px', + borderRadius: '8px', + border: '1px solid rgba(154, 193, 255, 0.32)', + background: 'rgba(154, 193, 255, 0.08)', + color: 'inherit', + font: `13px ${FONT}`, + resize: 'vertical', + }); + input.addEventListener('input', () => { + state.text = input.value; + layout(); + }); + input.addEventListener('change', () => console.log(`Preview text: "${input.value}"`)); + panel.element.append(input); + }, + snippet: () => fontSnippet(item, { ...state, bitmap }), + dispose() { + for (const line of lines) { + line.destroy(); + } + }, + }; - if (!sprite) { - return; + const layout = (): void => { + for (const line of lines.splice(0)) { + line.destroy(); } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH, cx, cy); - context.render(sprite); - } + const sizes = [state.size, Math.round(state.size * 0.6), Math.round(state.size * 0.4)]; + let y = 60; - private drawCursorPreview(context: RenderingContext): void { - const sprite = this.cursorSprites.get(this.key ?? ''); + for (const size of sizes) { + const line = font + ? new BitmapText(state.text, font).setScale(size / 32) + : new Text(state.text, { fontFamily: family, fontSize: size, fillColor: colors[state.color], maxWidth: PREVIEW.width - 64 }); + // Text lays out lazily, so the number of wrapped lines is measured with + // the same font on a scratch 2D context rather than read back. + const wrapped = font ? 1 : Math.ceil(measureWidth(state.text, `${size}px "${family}"`) / (PREVIEW.width - 64)) + 0.2; - if (!sprite) { - return; + line.setPosition(PREVIEW.x + 32, y); + lines.push(line); + y += wrapped * size * 1.3 + 28; } - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, Math.min(maxW, 256), Math.min(maxH, 256), cx, cy); - context.render(sprite); - } + preview.overlay.splice(0, preview.overlay.length, ...lines); + }; - private drawTilesetPreview(context: RenderingContext): void { - const sprite = this.tilesetSprites.get(this.key ?? ''); + layout(); - if (!sprite) { - return; - } + return preview; +}; - const entry: TilesetEntry | undefined = assets.demo.tilesets[(this.key ?? '') as keyof typeof assets.demo.tilesets]; - const { cx, cy, maxW, maxH } = this.previewCenter(); - this.fitSprite(sprite, maxW, maxH - 30, cx, cy - 15); - context.render(sprite); +const openData = async ({ scene, item, scope }: PreviewContext): Promise => { + const data: unknown = await scope.load(assets[item.key] as typeof assets.harborPlazaMap); + + return { + world: [], + overlay: [], + bounds: null, + facts: [['Kind', 'JSON document']], + mount(host) { + host({ title: 'Data', controls: {}, actions: { Data: () => void scene.showData(), Code: () => scene.showCode() } }); + void scene.showData(); + }, + snippet: () => dataSnippet(item), + data: () => Promise.resolve(data), + dispose() { + // Nothing to release beyond the scope. + }, + }; +}; - if (entry?.tileWidth && entry?.tileHeight) { - this.txtMeta.text = `tile: ${entry.tileWidth}×${entry.tileHeight}px`; - this.txtMeta.setPosition(PREVIEW_X + 16, PREVIEW_Y + PREVIEW_H - 22); - context.render(this.txtMeta); - } - } +// --- Snippets --------------------------------------------------------------- +// +// Each snippet is a complete module: imports, one scene and the application, +// using only public ExoJS APIs and the demo catalog. - private drawVendorPreview(context: RenderingContext): void { - const data = this.vendorData.get(this.key ?? ''); +const round = (value: number, digits = 2): number => Number(value.toFixed(digits)); - if (!data) { - this.txtMeta.text = `Loading: ${this.assetPath()}`; - this.txtMeta.setPosition(PREVIEW_X + 30, PREVIEW_Y + 80); - context.render(this.txtMeta); +const program = (imports: string[], body: string, options: { extensions?: string } = {}): string => + `${[...imports, "import { demoAssets as assets } from '@codexo/exojs-demo-assets';"].sort().join('\n')} - return; - } +class PreviewScene extends Scene { +${body} +} - const packs = data.packs ?? []; - const lines = [ - `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)`), - ]; - this.txtMeta.text = lines.join('\n'); - this.txtMeta.setPosition(PREVIEW_X + 30, PREVIEW_Y + 60); - context.render(this.txtMeta); - } +const app = new Application({ + scenes: { PreviewScene }, + canvas: { width: 1280, height: 720, mount: document.body },${options.extensions ? `\n extensions: [${options.extensions}],` : ''} +}); - private drawAnimControls(context: RenderingContext, frameCount: number): void { - if (!this.animG) { - this.animG = new Graphics(); - } +await app.start(PreviewScene); +`; + +const textureSnippet = (item: BrowserItem, state: { zoom: number; nearest: boolean; width: number; height: number }): string => { + const svg = assets[item.key].type === 'svg'; + const scale = round(state.zoom); + const names = [ + 'Application', + 'Scene', + 'Sprite', + ...(svg ? ['Texture'] : []), + ...(state.nearest ? (svg ? ['ScaleModes'] : ['Asset', 'ScaleModes']) : []), + ].sort(); + const load = svg + ? ` // An SVG loads as an image; the texture over it is yours to destroy. + const image = await this.loader.load(assets.${item.key}); + const texture = new Texture(image${state.nearest ? ', { scaleMode: ScaleModes.Nearest }' : ''});` + : state.nearest + ? ` // Sampling is part of the texture handle, chosen when it is loaded. + const texture = await this.loader.load( + Asset.type('texture', assets.${item.key}.source, { textureOptions: { scaleMode: ScaleModes.Nearest } }), + );` + : ` const texture = await this.loader.load(assets.${item.key});`; + + return program( + [`import { ${names.join(', ')} } from '@codexo/exojs';`], + ` override async load(): Promise { +${load} + const sprite = new Sprite(texture).setAnchor(0.5).setScale(${scale}).setPosition(640, 360); + + this.addChild(sprite); + }`, + ); +}; - const g = this.animG; - const bx = PREVIEW_X + 16; - const by = H - 48; - g.clear(); - g.fillColor = this.animPlaying ? C.active : C.btnDark; - g.lineWidth = 1; - g.lineColor = C.border; - g.drawRectangle(bx, by, 88, 30); - context.render(g); - - this.txtAnimPlay.text = this.animPlaying ? '⏸ PAUSE' : '▶ PLAY'; - this.txtAnimPlay.setPosition(bx + 14, by + 8); - context.render(this.txtAnimPlay); - - this.txtAnimFrame.text = `${(this.frameIdx % frameCount) + 1} / ${frameCount}`; - this.txtAnimFrame.setPosition(PREVIEW_X + 112, by + 10); - context.render(this.txtAnimFrame); - } +const spritesheetSnippet = ( + item: BrowserItem, + state: { animation: string; frames: string[]; fps: number; loop: boolean; scale: number }, +): string => { + const named = state.animation !== 'Frame range'; + const frames = named + ? `sheet.getAnimationFrameNames(${JSON.stringify(state.animation)}).map(name => sheet.getFrame(name))` + : `[${state.frames.map(name => JSON.stringify(name)).join(', ')}].map(name => sheet.getFrame(name))`; + + return program( + ["import { AnimatedSprite, Application, Scene, Spritesheet } from '@codexo/exojs';"], + ` override async load(): Promise { + const [texture, data] = await Promise.all([this.loader.load(assets.${item.key}), this.loader.load(assets.${item.atlasKey})]); + const sheet = new Spritesheet(texture, data); + const sprite = new AnimatedSprite(texture, { + preview: { frames: ${frames}, fps: ${state.fps}, repeat: ${state.loop ? -1 : 1} }, + }); + + sprite.setAnchor(0.5).setScale(${round(state.scale)}).setPosition(640, 360); + this.addChild(sprite); + sprite.play('preview'); + }`, + ); +}; + +const mapSnippet = (item: BrowserItem, state: { level: string; zoom: number; ldtk: boolean }): string => + state.ldtk + ? program( + [ + "import { Application, Asset, Scene } from '@codexo/exojs';", + "import { TileMapNode } from '@codexo/exojs/tilemap';", + "import { ldtkExtension } from '@codexo/exojs-ldtk';", + ], + ` override async load(): Promise { + // Tileset paths resolve against the world's URL, so load it from the catalog's absolute source. + const world = await this.loader.load(Asset.type('ldtkMap', assets.${item.key}.source)); + const level = world.getLevelByName(${JSON.stringify(state.level)}) ?? world.levels[0]!; + + this.addChild(new TileMapNode(level).setScale(${round(state.zoom)})); + }`, + { extensions: 'ldtkExtension' }, + ) + : program( + [ + "import { Application, Asset, Scene } from '@codexo/exojs';", + "import { TileMapNode } from '@codexo/exojs/tilemap';", + "import { tiledExtension } from '@codexo/exojs-tiled';", + ], + ` override async load(): Promise { + // Tileset images resolve against the map's URL, so load it from the catalog's absolute source. + const map = await this.loader.load(Asset.type('tileMap', assets.${item.key}.source)); + + this.addChild(new TileMapNode(map).setScale(${round(state.zoom)})); + }`, + { extensions: 'tiledExtension' }, + ); + +const audioSnippet = (item: BrowserItem, state: { music: boolean; clip: string | null; volume: number; loop: boolean }): string => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const ${state.music ? 'music' : 'sound'} = await this.loader.load(assets.${item.key}); + + // Browsers keep audio locked until the first click or key press; + // onUnlock runs the handler as soon as playback is allowed. + this.app.audio.onUnlock.add(() => { + this.app.audio.play(${state.music ? 'music' : state.clip ? `sound.sprite(${JSON.stringify(state.clip)})` : 'sound'}, { volume: ${round(state.volume)}, loop: ${state.loop} }); + }); + }`, + ); + +const videoSnippet = (item: BrowserItem, state: { muted: boolean; loop: boolean; rate: string; zoom: number }): string => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const video = await this.loader.load(assets.${item.key}); + + video.applyOptions({ loop: ${state.loop}, muted: ${state.muted}, playbackRate: ${Number.parseFloat(state.rate)} }); + video.setAnchor(0.5).setScale(${round(state.zoom)}).setPosition(640, 360); + this.addChild(video); +${ + state.muted + ? ' // A muted video may start without a user gesture.\n video.play();' + : ' // With sound, playback waits for the gesture that unlocks audio.\n this.app.audio.onUnlock.add(() => video.play());' } + }`, + ); + +const fontSnippet = (item: BrowserItem, state: { text: string; size: number; bitmap: boolean }): string => + state.bitmap + ? program( + ["import { Application, BitmapText, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const font = await this.loader.load(assets.${item.key}); + + this.addChild(new BitmapText(${JSON.stringify(state.text)}, font).setScale(${round(state.size / 32)}).setPosition(40, 60)); + }`, + ) + : program( + ["import { Application, Color, Scene, Text } from '@codexo/exojs';"], + ` override async load(): Promise { + // Loading a font registers its face with the document. + const face = await this.loader.load(assets.${item.key}); + const text = new Text(${JSON.stringify(state.text)}, { + fontFamily: face.family.replaceAll('"', ''), + fontSize: ${state.size}, + fillColor: Color.white, + }); + + this.addChild(text.setPosition(40, 60)); + }`, + ); + +const dataSnippet = (item: BrowserItem): string => + program( + ["import { Application, Scene } from '@codexo/exojs';"], + ` override async load(): Promise { + const data = await this.loader.load(assets.${item.key}); + + console.log(Object.keys(data as object)); + }`, + ); const app = new Application({ scenes: { AssetBrowserScene }, - canvas: { width: W, height: H, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, - clearColor: C.bg, + canvas: { width: STAGE.width, height: STAGE.height, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, + clearColor: new Color(32, 37, 50), + extensions: [tiledExtension, ldtkExtension], }); await app.start(AssetBrowserScene); diff --git a/examples/debug-layer/bounding-boxes.js b/examples/debug-layer/bounding-boxes.js index 800cd4062..0ac0f290a 100644 --- a/examples/debug-layer/bounding-boxes.js +++ b/examples/debug-layer/bounding-boxes.js @@ -1,6 +1,7 @@ // Auto-generated from bounding-boxes.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BoundingBoxesScene extends Scene { sprites; time = 0; @@ -11,7 +12,7 @@ class BoundingBoxesScene extends Scene { const margin = width * 0.12; const step = (width - 2 * margin) / (count - 1); this.sprites = Array.from({ length: count }, (_, i) => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.8); + const sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(0.8); sprite.setPosition(margin + i * step, height / 2 + Math.sin(i) * 80); // The boundingBoxes layer walks the SCENE GRAPH (scene.root), so // the sprites must be attached to it - nodes that are only passed @@ -39,9 +40,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); debug.layers.boundingBoxes.visible = true; diff --git a/examples/debug-layer/bounding-boxes.ts b/examples/debug-layer/bounding-boxes.ts index 045bff316..d1d31884c 100644 --- a/examples/debug-layer/bounding-boxes.ts +++ b/examples/debug-layer/bounding-boxes.ts @@ -1,5 +1,6 @@ import { Application, Color, FixedResolutionCanvasSizing, Scene, type Seconds, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BoundingBoxesScene extends Scene { private sprites!: Array<{ sprite: Sprite; speed: number }>; @@ -13,7 +14,7 @@ class BoundingBoxesScene extends Scene { const step = (width - 2 * margin) / (count - 1); this.sprites = Array.from({ length: count }, (_, i) => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.8); + const sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(0.8); sprite.setPosition(margin + i * step, height / 2 + Math.sin(i) * 80); // The boundingBoxes layer walks the SCENE GRAPH (scene.root), so // the sprites must be attached to it - nodes that are only passed @@ -46,9 +47,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); diff --git a/examples/debug-layer/performance-overlay.js b/examples/debug-layer/performance-overlay.js index 87809a5fc..b7a756e38 100644 --- a/examples/debug-layer/performance-overlay.js +++ b/examples/debug-layer/performance-overlay.js @@ -1,6 +1,7 @@ // Auto-generated from performance-overlay.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Time } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // The HUD counts every frame longer than this budget, paints those rows red, // and plots the budget as the guide line halfway up the sparkline. const budgets = [Time.seconds(1 / 60), Time.seconds(1 / 30), Time.seconds(1 / 144)]; @@ -17,7 +18,7 @@ class PerformanceOverlayScene extends Scene { // instead emit one draw call per sprite and tank the frame rate. this.layer = new Container(); this.sprites = Array.from({ length: 1600 }, () => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.25); + const sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(0.25); sprite.setPosition(Math.random() * width, Math.random() * height); this.layer.addChild(sprite); return { @@ -63,9 +64,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); debug.layers.performance.visible = true; diff --git a/examples/debug-layer/performance-overlay.ts b/examples/debug-layer/performance-overlay.ts index 36b6ccc7a..e73425c8a 100644 --- a/examples/debug-layer/performance-overlay.ts +++ b/examples/debug-layer/performance-overlay.ts @@ -11,6 +11,7 @@ import { Time, } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // The HUD counts every frame longer than this budget, paints those rows red, // and plots the budget as the guide line halfway up the sparkline. @@ -31,7 +32,7 @@ class PerformanceOverlayScene extends Scene { // instead emit one draw call per sprite and tank the frame rate. this.layer = new Container(); this.sprites = Array.from({ length: 1600 }, () => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.25); + const sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(0.25); sprite.setPosition(Math.random() * width, Math.random() * height); this.layer.addChild(sprite); @@ -84,9 +85,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); diff --git a/examples/debug-layer/pointer-and-hittest.js b/examples/debug-layer/pointer-and-hittest.js index 6b82b095f..c2bb2c02a 100644 --- a/examples/debug-layer/pointer-and-hittest.js +++ b/examples/debug-layer/pointer-and-hittest.js @@ -1,7 +1,9 @@ // Auto-generated from pointer-and-hittest.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; +const NAMES = ['red', 'green', 'blue']; class PointerAndHittestScene extends Scene { sprites; hud; @@ -10,7 +12,7 @@ class PointerAndHittestScene extends Scene { const { width, height } = app; this.sprites = []; for (let i = 0; i < 3; i++) { - const sprite = new Sprite(this.loader.get('image/ship-a.png')) + const sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2.4) .setPosition(width / 2 - 45 + i * 45, height / 2); @@ -18,20 +20,16 @@ class PointerAndHittestScene extends Scene { sprite.interactive = true; sprite.draggable = true; sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255)][i]); + // A press lands on the topmost interactive node under the pointer; the + // Output panel names which one the hit test picked. + sprite.onPointerTap.add(event => console.log(`Hit: ship ${i + 1} (${NAMES[i]}) at ${Math.round(event.x)}, ${Math.round(event.y)}`)); + sprite.onDragEnd.add(() => console.log(`Dropped ship ${i + 1} (${NAMES[i]}) at ${Math.round(sprite.x)}, ${Math.round(sprite.y)}`)); // 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.hud = mountControls({ title: 'Interaction Inspector', status: 'Front: 3 (blue)' }); this.inputs.onTrigger(Keyboard.One, () => this.setFront(0)); this.inputs.onTrigger(Keyboard.Two, () => this.setFront(1)); this.inputs.onTrigger(Keyboard.Three, () => this.setFront(2)); @@ -40,7 +38,12 @@ class PointerAndHittestScene extends Scene { this.sprites.forEach((sprite, i) => { sprite.zIndex = i === index ? 3 : i; }); - this.hud.setStatus(`Front: ${index + 1} (${['red', 'green', 'blue'][index]})`); + this.hud.setStatus(`Front: ${index + 1} (${NAMES[index]})`); + console.info(`Front: ship ${index + 1} (${NAMES[index]})`); + } + destroy() { + this.hud.dispose(); + super.destroy(); } } const app = new Application({ @@ -52,9 +55,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); debug.layers.boundingBoxes.visible = true; diff --git a/examples/debug-layer/pointer-and-hittest.ts b/examples/debug-layer/pointer-and-hittest.ts index 67fd90049..d5c7046f9 100644 --- a/examples/debug-layer/pointer-and-hittest.ts +++ b/examples/debug-layer/pointer-and-hittest.ts @@ -1,7 +1,10 @@ import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; +const NAMES = ['red', 'green', 'blue'] as const; + class PointerAndHittestScene extends Scene { private sprites!: Sprite[]; private hud!: ReturnType; @@ -13,7 +16,7 @@ class PointerAndHittestScene extends Scene { this.sprites = []; for (let i = 0; i < 3; i++) { - const sprite = new Sprite(this.loader.get('image/ship-a.png')) + const sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2.4) .setPosition(width / 2 - 45 + i * 45, height / 2); @@ -21,21 +24,17 @@ class PointerAndHittestScene extends Scene { sprite.interactive = true; sprite.draggable = true; sprite.setTint([new Color(255, 130, 130), new Color(130, 255, 170), new Color(140, 190, 255)][i]); + // A press lands on the topmost interactive node under the pointer; the + // Output panel names which one the hit test picked. + sprite.onPointerTap.add(event => console.log(`Hit: ship ${i + 1} (${NAMES[i]}) at ${Math.round(event.x)}, ${Math.round(event.y)}`)); + sprite.onDragEnd.add(() => console.log(`Dropped ship ${i + 1} (${NAMES[i]}) at ${Math.round(sprite.x)}, ${Math.round(sprite.y)}`)); // 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.hud = mountControls({ title: 'Interaction Inspector', status: 'Front: 3 (blue)' }); this.inputs.onTrigger(Keyboard.One, () => this.setFront(0)); this.inputs.onTrigger(Keyboard.Two, () => this.setFront(1)); this.inputs.onTrigger(Keyboard.Three, () => this.setFront(2)); @@ -45,7 +44,13 @@ class PointerAndHittestScene extends Scene { this.sprites.forEach((sprite, i) => { sprite.zIndex = i === index ? 3 : i; }); - this.hud.setStatus(`Front: ${index + 1} (${['red', 'green', 'blue'][index]})`); + this.hud.setStatus(`Front: ${index + 1} (${NAMES[index]})`); + console.info(`Front: ship ${index + 1} (${NAMES[index]})`); + } + + override destroy(): void { + this.hud.dispose(); + super.destroy(); } } @@ -58,9 +63,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); const debug = new DebugOverlay(app); diff --git a/examples/debug-layer/signal-bus-inspector.js b/examples/debug-layer/signal-bus-inspector.js index 3bd0bebb3..1a7bd64df 100644 --- a/examples/debug-layer/signal-bus-inspector.js +++ b/examples/debug-layer/signal-bus-inspector.js @@ -81,8 +81,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SignalBusInspectorScene); diff --git a/examples/debug-layer/signal-bus-inspector.ts b/examples/debug-layer/signal-bus-inspector.ts index 76de222f4..b2c89b5a8 100644 --- a/examples/debug-layer/signal-bus-inspector.ts +++ b/examples/debug-layer/signal-bus-inspector.ts @@ -113,9 +113,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SignalBusInspectorScene); diff --git a/examples/examples.json b/examples/examples.json index e376203f0..8ea890094 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -62,7 +62,7 @@ "path": "application-scenes/loading-screen.js", "language": "typescript", "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.", + "description": "Watch real asset-load progress, retry after a failed request with Space, and press Space in the game scene to revisit already cached assets during a second scene transition.", "backend": "core", "tags": ["scene", "loader", "assets"], "level": "intermediate", @@ -73,7 +73,7 @@ "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.", + "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. N swaps the ground texture for the derived night catalog, G loads a ground texture by a computed path, M plays the streamed theme.", "backend": "core", "tags": ["loader", "assets", "sprite"], "level": "intermediate", @@ -84,7 +84,7 @@ "path": "showcase/pause-blur.js", "language": "typescript", "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.", + "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 pause and resume.", "backend": "core", "tags": ["fx", "scene"], "capabilities": ["pointer", "keyboard"], @@ -106,7 +106,7 @@ "path": "tilemap/level-loading-and-ownership.js", "language": "typescript", "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.", + "description": "Load two Tiled rooms with one shared atlas, cancel a pending transition, and inspect the child scopes and atlas claims released on unload. The Rooms panel enters a room, cancels a pending load or unloads the active room.", "backend": "core", "tags": ["tilemap", "loader", "scopes", "cancellation"], "level": "advanced" @@ -116,10 +116,21 @@ "path": "application-scenes/cached-assets-offline.js", "language": "typescript", "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.", + "description": "Warm an IndexedDB asset source, switch the loader to offline policy, compare a cache hit and miss, then clear and retry. Offline mode changes loader policy only; it does not disable the browser connection.", "backend": "core", "tags": ["assets", "cache", "offline", "indexeddb"], "level": "advanced" + }, + { + "slug": "asset-browser", + "path": "debug-layer/asset-browser.js", + "language": "typescript", + "title": "Asset Browser", + "description": "Browse every standard demo asset by type and preview it with ExoJS: zoom and filter textures, animate spritesheet frames, pan rendered tile maps, play audio and video, and try fonts. Each preview loads into its own loader scope, and the Code button copies a snippet that reproduces the current settings.", + "backend": "core", + "tags": ["assets", "loader", "scopes", "spritesheet", "audio", "video", "tool"], + "capabilities": ["pointer"], + "level": "intermediate" } ], "input-ui": [ @@ -128,7 +139,7 @@ "path": "input/keyboard.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.", + "description": "Read key state per-frame and on-event with the Keyboard API. WASD or the arrow keys move the square (per-frame polling), Esc recentres it (on-event); click the canvas first so it has keyboard focus. Key presses, the recentre action and held-key changes appear in the Output panel.", "backend": "core", "featured": true, "tags": ["input", "keyboard"], @@ -140,7 +151,7 @@ "path": "input/mouse-and-pointer.js", "language": "typescript", "title": "Mouse and Pointer", - "description": "Track pointer position, button state, and movement delta; fire discrete click and drag events.", + "description": "Track pointer position, button state, and movement delta; fire discrete click and drag events. Move the mouse, click to count taps, and drag the ship sprite.", "backend": "core", "tags": ["input", "pointer", "mouse"], "capabilities": ["pointer"], @@ -151,7 +162,7 @@ "path": "input/action-mapping.js", "language": "typescript", "title": "Action Mapping", - "description": "Bind keyboard and gamepad controls to one move action and one pulse action, then read both through ActionMap.", + "description": "Bind keyboard and gamepad controls to one move action and one pulse action, then read both through ActionMap. WASD, arrows or the left stick move the ship; Space or the south gamepad button pulses it. The Output panel logs each resolved action as it changes.", "backend": "core", "tags": ["input", "keyboard", "gamepad"], "capabilities": ["keyboard", "gamepad"], @@ -162,7 +173,7 @@ "path": "input/gamepad.js", "language": "typescript", "title": "Controller Inspector", - "description": "Connect a controller to inspect live stick and button values; see a clear waiting state while disconnected.", + "description": "Connect a controller to inspect live stick and button values; see a clear waiting state while disconnected. Axes and buttons update live.", "backend": "core", "featured": true, "tags": ["input", "gamepad"], @@ -174,7 +185,7 @@ "path": "input/key-rebinding.js", "language": "typescript", "title": "Key Rebinding", - "description": "Let players reassign key bindings at runtime and persist the new mapping through a serialisable BindingProfile.", + "description": "Rebind the jump key at runtime: press J, then any key to assign it to jump (Space by default), and the choice is saved in localStorage as a stable input token and restored on reload.", "backend": "core", "tags": ["input", "keyboard"], "capabilities": ["keyboard"], @@ -185,7 +196,7 @@ "path": "input/multitouch.js", "language": "typescript", "title": "Multitouch", - "description": "Track up to ten simultaneous touch points and render each finger as a labelled circle.", + "description": "Track up to ten simultaneous touch points and render each finger as a labelled circle. Use a touchscreen with several fingers; a mouse provides a single point while a button is held.", "backend": "core", "tags": ["input", "touch", "pointer"], "capabilities": ["pointer", "touch"], @@ -206,7 +217,7 @@ "path": "ui/settings-menu.js", "language": "typescript", "title": "Settings Menu and Focus", - "description": "Adjust native sliders, toggles, and dropdowns with pointer, keyboard, or controller focus; save a small settings object.", + "description": "Adjust native sliders, toggles, and dropdowns with pointer, keyboard, or controller focus; save a small settings object. Toggle, theme and reset changes are logged to the Output panel.", "backend": "core", "tags": ["ui", "settings", "focus", "slider", "dropdown"], "capabilities": ["keyboard", "pointer"], @@ -228,7 +239,7 @@ "path": "showcase/dialog-system.js", "language": "typescript", "title": "Dialogue and Choices", - "description": "Reveal dialogue character by character, skip to the full line, and choose a reply.", + "description": "Reveal dialogue character by character: click anywhere to skip to the full line and click again to continue. After the last line, pick a reply with the on-canvas buttons.", "backend": "core", "tags": ["scene", "text", "audio"], "capabilities": ["pointer"], @@ -239,7 +250,7 @@ "path": "application-scenes/multi-view-split-screen.js", "language": "typescript", "title": "Local Split Screen", - "description": "Move two players with separate keyboards or optional controllers while each camera follows its own player.", + "description": "Move two players with separate controls while each camera follows its own player in its own half of the screen. P1 uses WASD or controller 1, P2 uses the arrow keys or controller 2; controllers are optional.", "backend": "core", "tags": ["camera", "view", "scene", "gamepad"], "capabilities": ["keyboard"], @@ -252,7 +263,7 @@ "path": "sprites-textures/sprite-basics.js", "language": "typescript", "title": "Sprite Basics", - "description": "Load a texture, create a Sprite, and control position, rotation, scale, and alpha inside a Scene.", + "description": "Load a texture, create a Sprite, and animate its position, rotation, scale and alpha inside a Scene.", "backend": "core", "featured": true, "tags": ["sprite", "rendering"], @@ -273,7 +284,7 @@ "path": "scene-graph/pivot-and-anchor.js", "language": "typescript", "title": "Pivot and Anchor", - "description": "Choose an origin preset and watch the rotating sprite's position, pivot, and bounds change together.", + "description": "Three copies of one rotating sprite side by side, turning about the top-left corner, the centre and an origin outside the texture; the red dot marks each position, the box its bounds.", "backend": "core", "tags": ["scene-graph", "sprite"], "level": "intro" @@ -283,7 +294,7 @@ "path": "sprites-textures/blendmodes.js", "language": "typescript", "title": "Blendmodes", - "description": "Click through all built-in blend modes applied to an overlapping sprite pair to see how they composite.", + "description": "Click the canvas, press Left/Right or use the panel to step through all 18 blend modes applied to two overlapping tinted ships; the panel shows the current mode and its position.", "backend": "core", "tags": ["sprite", "rendering", "fx"], "capabilities": ["pointer"], @@ -304,7 +315,7 @@ "path": "sprites-textures/texture-sampling.js", "language": "typescript", "title": "Texture Sampling and Pixel Snapping", - "description": "Compare nearest, linear and mipmapped sampling side by side on a magnified and a minified texture, and toggle render-only pixel snapping.", + "description": "Compare nearest, linear and mipmapped sampling side by side on a magnified top row and a minified bottom row. Scroll the wheel (or use the sliders) to magnify, P toggles render-only pixel snapping, Space pauses the motion; only the sampler differs between columns.", "backend": "core", "tags": ["sprite", "texture", "sampling", "pixel-snap"], "capabilities": ["keyboard", "pointer"], @@ -315,7 +326,7 @@ "path": "application-scenes/world-vs-screen-coords.js", "language": "typescript", "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.", + "description": "Pan with WASD or the arrow keys, zoom with the wheel, and click to place world-space markers while a screen-fixed readout shows pointer screen and world coordinates. Markers use world coordinates; the readout stays fixed on screen.", "backend": "core", "tags": ["camera", "view", "pointer", "coordinates"], "capabilities": ["keyboard", "pointer"], @@ -326,7 +337,7 @@ "path": "scene-graph/parallax-starfield.js", "language": "typescript", "title": "Parallax Layers", - "description": "Move the pointer to shift three star layers at different speeds and see depth emerge from their relative motion.", + "description": "Move the pointer to shift three star layers at different speeds; near stars travel farther than distant stars, so depth emerges from their relative motion.", "backend": "core", "tags": ["scene-graph", "rendering"], "capabilities": ["pointer"], @@ -347,7 +358,7 @@ "path": "render-targets/mini-map.js", "language": "typescript", "title": "Minimap", - "description": "Render the same world through a second, wider View into a texture and clip it to a round HUD minimap.", + "description": "Render the same world through a second, wider View into a texture and clip it to a round HUD minimap. The player wanders on its own; both views follow it through the same container.", "backend": "core", "tags": ["rendering", "camera", "advanced"], "level": "intermediate" @@ -359,7 +370,7 @@ "path": "tweens-animation/frame-animation.js", "language": "typescript", "title": "Sprite Animation", - "description": "Play named spritesheet clips with AnimatedSprite, press Right to switch characters, and press Space to pause or resume.", + "description": "Play named spritesheet clips with AnimatedSprite: all five characters walk side by side from one spritesheet. Press Space to pause or resume them.", "backend": "core", "tags": ["animation", "sprite"], "capabilities": ["keyboard"], @@ -370,7 +381,7 @@ "path": "tweens-animation/tween-basics.js", "language": "typescript", "title": "Interactive Tweens", - "description": "Click to move a Sprite, click again to interrupt its Tween, and press Space to toggle a yoyo round trip.", + "description": "Click to move a Sprite, click again to interrupt its Tween (a new tween starts from the current position), and press Space to toggle a yoyo round trip.", "backend": "core", "tags": ["animation", "tween"], "capabilities": ["keyboard", "pointer"], @@ -381,7 +392,7 @@ "path": "tweens-animation/tween-chains.js", "language": "typescript", "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.", + "description": "Build a looping route from an array of waypoints by chaining Tweens once and restarting the first step after the last completes. The status shows the current leg.", "backend": "core", "tags": ["animation", "tween"], "level": "intermediate" @@ -391,7 +402,7 @@ "path": "tweens-animation/easing-curves.js", "language": "typescript", "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.", + "description": "Select any built-in easing function with Left/Right or the panel buttons and watch a Sprite trace its large curve; overshoot stays visible above and below the 0-1 range.", "backend": "core", "tags": ["animation", "tween"], "level": "intermediate" @@ -401,7 +412,7 @@ "path": "showcase/screen-shake-on-explosion.js", "language": "typescript", "title": "Impact Feedback", - "description": "Click to flash a sprite, burst particles, and shake the camera as one impact response.", + "description": "Click anywhere to flash the ship, burst particles at the cursor and shake the camera as one impact response.", "backend": "core", "tags": ["camera", "fx", "particles"], "capabilities": ["pointer"], @@ -412,7 +423,7 @@ "path": "showcase/gamepad-spaceship.js", "language": "typescript", "title": "Fly a Spaceship", - "description": "Steer and fire with keyboard or gamepad; collect hits, sparks and optional controller rumble.", + "description": "Steer and fire with keyboard or gamepad: WASD, arrows or the left stick fly, Space or the right trigger fires; destroy asteroids for sparks and optional controller rumble.", "backend": "core", "featured": true, "tags": ["gamepad", "keyboard", "input", "audio", "scene"], @@ -424,7 +435,7 @@ "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.", + "description": "A tween-driven boss reveal: letterbox bars, a camera push-in, a typewriter title punched with a screen shake and a music swell. Press R or click to replay once it has played out.", "backend": "core", "tags": ["animation", "audio", "scene"], "capabilities": ["pointer", "keyboard", "audio"], @@ -510,7 +521,7 @@ "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.", + "description": "Discs get brighter from left to right and a white bar sweeps below them; Threshold sets the luminance that starts to glow, Intensity how much is added back, Strength how far it spreads and Levels how many halvings the blur uses.", "backend": "core", "tags": ["fx", "bloom", "filter"], "level": "intro" @@ -520,7 +531,7 @@ "path": "filters/blur-filter.js", "language": "typescript", "title": "Blur and Drop Shadow", - "description": "Compare an original sprite with a live blur or offset shadow, adjust each filter, and bypass it.", + "description": "Compare an original sprite with a live blur or offset drop shadow; choose the filter and drag the strength slider to change its blur radius or shadow offset.", "backend": "core", "tags": ["fx", "blur", "shadow", "filter"], "level": "intro" @@ -530,7 +541,7 @@ "path": "filters/color-matrix-filter.js", "language": "typescript", "title": "Color Transforms", - "description": "Compare the original image with grayscale or sepia matrices, independent 1D channel curves, and a cool 3D LUT.", + "description": "The same colour ramp and painted background through a grayscale matrix, a sepia matrix, per-channel 1D curves and a 3D LUT grade, side by side with the original.", "backend": "core", "tags": ["fx", "color", "filter", "lut"], "level": "intermediate" @@ -540,7 +551,7 @@ "path": "filters/crt-scanlines.js", "language": "typescript", "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.", + "description": "Tune a combined CRT filter with scanlines, grain, vignette, barrel distortion, and RGB separation using the effect-strength and separation sliders; switch CRT off to compare the clean scene.", "backend": "core", "tags": ["fx", "filter", "shader"], "level": "intermediate" @@ -550,7 +561,7 @@ "path": "filters/custom-fragment-shader.js", "language": "typescript", "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.", + "description": "Move the pointer over a UV grid to place a ripple while one typed uniform schema feeds matching GLSL and WGSL filter shaders for time and pointer position.", "backend": "core", "tags": ["fx", "filter", "shader", "pointer"], "capabilities": ["pointer"], @@ -561,7 +572,7 @@ "path": "filters/metaballs.js", "language": "typescript", "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.", + "description": "Drag the ringed blob into the moving circles, then pick blur-then-threshold, threshold-then-blur, or no filters and adjust blur strength to see how filter order changes the result.", "backend": "core", "tags": ["fx", "filter", "shader", "pointer"], "capabilities": ["pointer"], @@ -572,7 +583,7 @@ "path": "render-targets/render-to-texture.js", "language": "typescript", "title": "Capture a Texture", - "description": "Compare a live render texture with a frozen snapshot, then click to capture a new frame.", + "description": "Compare a live render texture of moving ships with a frozen snapshot; click anywhere to capture a new frame into the right-hand texture.", "backend": "core", "tags": ["rendering", "advanced"], "level": "intro" @@ -592,7 +603,7 @@ "path": "render-targets/color-pipeline.js", "language": "typescript", "title": "Colour Pipeline", - "description": "Compare colour and data textures, linear-light blending, alpha edges, raw and display readback, and an optional HDR value mapped to SDR.", + "description": "Compare colour and data textures, linear-light blending, alpha edges, raw and display readback, and an optional HDR value mapped to SDR; press Space or tap to switch the HDR panel between clamp and Reinhard.", "backend": "core", "tags": ["rendering", "color", "advanced"], "level": "intermediate" @@ -602,7 +613,7 @@ "path": "render-targets/water-mirror.js", "language": "typescript", "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.", + "description": "Capture and flip a live reflection, then drag horizontally across the water to change the strength of its scrolling displacement map (left is calm, right is strong).", "backend": "core", "tags": ["fx", "rendering", "displacement", "pointer"], "capabilities": ["pointer"], @@ -613,7 +624,7 @@ "path": "sprites-textures/video-drawable.js", "language": "typescript", "title": "Video Drawable", - "description": "Stream a video element as a live GPU texture, composite a sprite over it, and switch between clips at runtime.", + "description": "A video streams as a live GPU texture with a sprite composited over it. Choose the demo loop, the 4K 24 fps clip or the 1080p 60 fps clip with 1-3 or the panel; click to pause. All clips are SDR.", "backend": "core", "tags": ["sprite", "rendering"], "capabilities": ["pointer", "keyboard"], @@ -624,7 +635,7 @@ "path": "render-targets/render-pipeline.js", "language": "typescript", "title": "Composable Render Pipeline", - "description": "Compose named offscreen, optional blur, custom callback, and screen-space HUD passes; toggle blur to compare the live bypass.", + "description": "Compose named offscreen, blur, custom callback, and screen-space HUD passes; click or press Space to toggle the blur pass and compare the live bypass.", "backend": "core", "tags": ["rendering", "advanced", "fx"], "level": "advanced" @@ -647,7 +658,7 @@ "path": "lighting/many-lights.js", "language": "typescript", "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.", + "description": "Drag the large light marker across a normal-mapped floor, then vary the active light count from 1 to 48 with the slider and watch the draw-call readout stay flat; Resume first orbit releases the dragged light.", "backend": "core", "featured": false, "tags": ["lighting", "material", "shader", "performance"], @@ -659,7 +670,7 @@ "path": "lighting/shadow-casters.js", "language": "typescript", "title": "Shadow Casters", - "description": "Soft shadows nobody modelled: the walls hand over the rectangle they are drawn as, the cross hands over the outline traced from its alpha channel, and the lightmap renderer shadows every light against them in one draw. Toggle the occluder debug view to see exactly what was collected.", + "description": "Soft shadows nobody modelled: the walls hand over the rectangle they are drawn as, the cross hands over the outline traced from its alpha channel, and the lightmap renderer shadows every light against them in one draw. Adjust shadow softness and toggle the occluder debug view to see exactly what was collected.", "backend": "core", "featured": true, "tags": ["lighting", "shadows", "shader"], @@ -671,7 +682,7 @@ "path": "lighting/light-cookies.js", "language": "typescript", "title": "Light Cookies", - "description": "Four light shapes and two textures doing the work of geometry: window bars and leaf shade are cookies on the lights themselves, a neon tube pools in a capsule rather than a disc, and a sun with no position throws parallel shadows across the whole view.", + "description": "Four light shapes and two textures doing the work of geometry: window bars and leaf shade are cookies carried by the lights themselves (their bars drift with the lamp), a neon tube pools in a capsule, and a sun with no position throws parallel shadows. Use the time-of-day and sun sliders, and toggle cookies on and off.", "backend": "core", "featured": false, "tags": ["lighting", "shader", "texture"], @@ -683,7 +694,7 @@ "path": "lighting/lightmap-normals.js", "language": "typescript", "title": "Normals under the Lightmap Renderer", - "description": "Forty-five cobbles that nobody authored a normal map for: each one hands the lighting system its own silhouette, a prepass draws the derived normals where the stone sits, and the light field shades against them. Switching the normals off flattens the relief and leaves the stones slightly brighter: normals redistribute light across a surface, they never add any.", + "description": "Two lights move over one cobbled floor: the left half has normals derived from each stone's alpha, the right half has none, so the relief and its slightly darker read show side by side. Light height and a normal-buffer view are in the panel.", "backend": "core", "featured": false, "tags": ["lighting", "material", "shader"], @@ -695,7 +706,7 @@ "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.", + "description": "One lamp, one doorway: switch between the radiance renderer (transported light with an optional bounce) and the lightmap renderer (drawn light). Space pauses the lamp so both can be compared at the same position; Lamp and Softness tune the source.", "backend": "core", "featured": false, "tags": ["lighting", "shadows", "shader"], @@ -709,7 +720,7 @@ "path": "particles/emitter-basics.js", "language": "typescript", "title": "Emitter Basics", - "description": "Configure a particle emitter — set rate, lifetime, start/end size, colour gradient, and velocity spread.", + "description": "A particle fountain built from RateSpawn, ScaleOverLifetime and ColorOverLifetime: rate, lifetime, size curve, colour gradient and velocity spread in one emitter.", "backend": "core", "tags": ["particles"], "level": "intro" @@ -719,7 +730,7 @@ "path": "particles/cursor-attractor-particles.js", "language": "typescript", "title": "Cursor Attractor Particles", - "description": "Attract and repel particles toward the pointer position using a custom force field on the particle emitter.", + "description": "Move the pointer to steer a custom force field on the particle emitter, and click to switch it between attracting and repelling the particles.", "backend": "core", "tags": ["particles", "pointer"], "capabilities": ["pointer"], @@ -730,7 +741,7 @@ "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.", + "description": "Click anywhere to launch a firework rocket from the bottom at that x position; it bursts into flash, spark and ember layers at its apex, and rockets also launch on their own.", "backend": "core", "tags": ["particles", "pointer"], "capabilities": ["pointer"], @@ -741,7 +752,7 @@ "path": "particles/bonfire.js", "language": "typescript", "title": "Campfire", - "description": "Adjust flame intensity and wind while fire and smoke rise from a visible ring of logs and stones.", + "description": "Adjust flame intensity and wind with the sliders while fire and smoke rise from a visible ring of logs and stones.", "backend": "core", "tags": ["particles"], "level": "intermediate" @@ -751,7 +762,7 @@ "path": "showcase/audio-reactive-particles.js", "language": "typescript", "title": "Audio Reactive Particles", - "description": "Emit particles driven by real-time AudioAnalyser frequency data — bass controls density, treble controls spread.", + "description": "Particles driven by the music: low-band energy sets how many spawn and high-band energy how wide they spread, so silence stays still; detected beats recolour the stream.", "backend": "core", "featured": true, "tags": ["audio", "reactive", "particles"], @@ -763,7 +774,7 @@ "path": "particles/gpu-particles.js", "language": "typescript", "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.", + "description": "Adjust the spawn rate and watch live occupancy, frame time, and whether particles run on WebGPU compute or the smaller WebGL2 fallback.", "backend": "core", "tags": ["particles", "webgpu", "performance"], "level": "advanced" @@ -773,7 +784,7 @@ "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.", + "description": "SwayModule supplies apply(), glsl() and wgsl() with shared uniforms, so the same custom particle behaviour runs on the CPU, in WebGL2 transform feedback or as a WebGPU compute pass; the status shows which path runs.", "backend": "core", "tags": ["particles", "advanced", "webgpu"], "level": "advanced" @@ -785,7 +796,7 @@ "path": "audio-basics/play-sound.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.", + "description": "Click anywhere to play the next of five short UI and impact sounds from a small pool; the sound effects bus runs at half volume.", "backend": "core", "featured": true, "tags": ["audio", "sound"], @@ -797,7 +808,7 @@ "path": "audio-basics/sound-pool.js", "language": "typescript", "title": "Polyphonic Sound", - "description": "Hold Space to play overlapping impacts with varied pitch while a pool meter shows active voices and evictions.", + "description": "Hold Space, a mouse button or a finger to fire heavy impacts faster than they finish: the pool caps concurrent voices, each shot varies its pitch, and at capacity the oldest voice is evicted.", "backend": "core", "tags": ["audio", "sound", "pool"], "capabilities": ["keyboard", "audio"], @@ -808,7 +819,7 @@ "path": "audio-basics/music-loop.js", "language": "typescript", "title": "Music Loop", - "description": "Play a seamlessly looping music track with live controls for volume, playback rate, and looping.", + "description": "Stream a music track and control the live Voice: volume, playback rate and looping in the panel, Space or the panel to pause and resume, and a progress bar for the playback position.", "backend": "core", "tags": ["audio", "music"], "capabilities": ["audio"], @@ -819,7 +830,7 @@ "path": "audio-basics/audio-buses.js", "language": "typescript", "title": "Audio Mixer Buses", - "description": "Route a looping track and click effect to Music and Sound, then mix both through Master with live sliders.", + "description": "A looping track on the Music bus and click effects on the Sound bus under one Master: Master scales both, Music and Sound only their own voices. The mixer panel sets each bus volume and plays the effect.", "backend": "core", "tags": ["audio", "buses"], "capabilities": ["pointer", "audio"], @@ -830,7 +841,7 @@ "path": "audio-basics/crossfade-tracks.js", "language": "typescript", "title": "Crossfade Tracks", - "description": "Select either looping track and watch its gain indicator follow the scheduled two-second voice crossfade.", + "description": "Choose Track A or Track B in the panel and watch each gain meter follow the scheduled two-second voice crossfade between the two looping tracks.", "backend": "core", "tags": ["audio", "crossfade"], "capabilities": ["pointer", "audio"], @@ -841,7 +852,7 @@ "path": "audio-fx/reverb-and-delay.js", "language": "typescript", "title": "Reverb and Delay", - "description": "Play one impact through reverb and delay; compare small room, hall, echo, and dry bypass presets.", + "description": "Play one impact through reverb and delay; pick a small room, hall or echo preset and toggle bypass to compare the dry and wet paths while the room and echo settings change.", "backend": "core", "tags": ["audio", "fx", "reverb", "delay"], "capabilities": ["pointer", "audio"], @@ -852,7 +863,7 @@ "path": "audio-fx/pcm-stream.js", "language": "typescript", "title": "Streaming PCM", - "description": "Feed stereo PCM through the music bus, inspect the bounded queue, and compare producer starvation, clear and drain.", + "description": "Feed stereo PCM through the music bus and inspect the bounded queue; stop the producer to hear starvation, then clear the queue, drain it or start a new stream. Playback starts on the first click or key press.", "backend": "core", "tags": ["audio", "pcm", "streaming", "synthesis"], "capabilities": ["pointer", "audio"], @@ -863,7 +874,7 @@ "path": "audio-fx/gpu-pcm-stream.js", "language": "typescript", "title": "GPU Stereo PCM", - "description": "Generate stereo samples on the GPU, read bounded blocks as packed bytes or floats, and stream them through the music bus.", + "description": "Generate stereo samples on the GPU, read bounded blocks back as packed bytes or floats chosen in the panel, and stream them through the music bus. Playback starts on the first click or key press; changing the format creates a fresh stream.", "backend": "core", "tags": ["audio", "pcm", "gpu", "readback", "synthesis"], "capabilities": ["pointer", "audio"], @@ -874,7 +885,7 @@ "path": "audio-fx/compressor.js", "language": "typescript", "title": "Compression", - "description": "Switch one music source between quiet and loud, bypass the compressor, and read its live gain reduction.", + "description": "A quiet music loop feeds a CompressorEffect; switch to the loud input to push the peaks over the threshold and watch the live gain-reduction meter. Threshold, ratio and bypass are in the panel.", "backend": "core", "tags": ["audio", "fx", "compressor"], "capabilities": ["pointer", "audio"], @@ -885,7 +896,7 @@ "path": "audio-fx/ducking.js", "language": "typescript", "title": "Ducking", - "description": "Automatically lower music volume when a sound effect plays — classic game audio ducking via the DuckingEffect.", + "description": "Click to play a voice-over and watch the music meter duck while the voice meter spikes: the DuckingEffect lowers the music bus whenever its sidechain voice bus is loud.", "backend": "core", "tags": ["audio", "fx", "ducking"], "capabilities": ["pointer", "audio"], @@ -896,7 +907,7 @@ "path": "audio-fx/convolution-rooms.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.", + "description": "Strike four impact materials, each panned to its position, inside 30 measured rooms from a 1 ms tail to a cavern; step through the rooms with Left/Right or the panel.", "backend": "core", "tags": ["audio", "fx", "convolution", "reverb", "impulse-response"], "capabilities": ["pointer", "audio"], @@ -907,7 +918,7 @@ "path": "audio-fx/effect-chains.js", "language": "typescript", "title": "Audio Effect Rack", - "description": "Select one of four distinct effect chains, inspect its order and spectrum, and bypass it against dry output.", + "description": "Pick one of four distinct effect chains, read its order on the rack and the spectrum, play an impact, and toggle bypass to compare it against the level-matched dry output.", "backend": "core", "tags": ["audio", "fx", "chain", "spectrum", "analyser"], "capabilities": ["pointer", "audio"], @@ -918,7 +929,7 @@ "path": "audio-fx/signal-graph.js", "language": "typescript", "title": "Audio Signal Graph", - "description": "See the real routing from sources through bus effects to the master output, edit effect parameters, bypass stages and watch each bus level.", + "description": "See the real routing from sources through bus effects to the master output: every box is a real source, effect or bus, every wire a real connection. Click a node to edit its parameters or bypass it and watch each bus level.", "backend": "core", "tags": ["audio", "bus", "effects", "graphics"], "capabilities": ["pointer", "audio"], @@ -929,7 +940,7 @@ "path": "audio-fx/vocoder.js", "language": "typescript", "title": "Vocoder", - "description": "Choose a spoken phrase and compare its dry voice with a vocoder carrier; unlock feedback keeps the first selection visible.", + "description": "Pick a spoken phrase and play it, then switch Processed off to compare the dry voice with the robot voice made by modulating a sustained saw carrier. Audio starts on the first click or key press.", "backend": "core", "tags": ["audio", "fx", "vocoder"], "capabilities": ["pointer", "audio"], @@ -940,7 +951,7 @@ "path": "showcase/audio-visualisation.js", "language": "typescript", "title": "Spectrum and Waveform", - "description": "Compare live spectrum bars, a waveform, eight frequency bands, and a beat pulse while playing music.", + "description": "A live spectrum, waveform, eight frequency bands and a beat pulse drawn from the music bus; click to pause and resume the track.", "backend": "core", "tags": ["audio", "reactive", "visualiser"], "capabilities": ["pointer", "audio"], @@ -951,7 +962,7 @@ "path": "beat-detection/beat-sync-pulse.js", "language": "typescript", "title": "Beat and Tempo", - "description": "Pulse a sprite and particles on detected beats while showing the live BPM estimate, confidence, and onset strength.", + "description": "A BeatDetector on the music bus pulses the ship and bursts particles on every detected beat while the tempo estimate and its confidence update continuously.", "backend": "core", "tags": ["audio", "beat", "reactive"], "capabilities": ["audio"], @@ -962,7 +973,7 @@ "path": "spatial-audio/listener-and-source.js", "language": "typescript", "title": "Spatial Audio", - "description": "Drag or orbit a sound source around a listener, switch distance models, and compare the live pan and attenuation.", + "description": "Best with headphones: drag or orbit a sound source around the listener, switch the falloff model, toggle the sound, and compare the live pan and attenuation in the readout.", "backend": "core", "tags": ["audio", "spatial", "panning", "falloff"], "capabilities": ["pointer", "audio"], @@ -975,7 +986,7 @@ "path": "tilemap/tiled-isometric.js", "language": "typescript", "title": "Isometric Landscape", - "description": "Explore a Kenney landscape loaded from Tiled: pick diamond cells, move a physics actor through the bridge, and inspect projected collision outlines.", + "description": "Explore a Kenney Isometric Tiles Landscape (CC0) loaded from Tiled: click a diamond cell to send a physics actor toward it, and cross the canal through the central bridge because the cyan outlines mark solid banks. The actor collides in logical space and its position is projected onto the map.", "backend": "core", "tags": ["tilemap", "tiled", "isometric", "physics", "pointer"], "level": "intermediate" @@ -985,7 +996,7 @@ "path": "tilemap/hexagonal-grid.js", "language": "typescript", "title": "Hexagonal & Staggered Grids", - "description": "Lay out hexagonal and staggered tile maps the way Tiled does, pick cells under the pointer, and draw every tile from its own image with trees overhanging the row above.", + "description": "Lay out hexagonal and staggered tile maps the way Tiled does, pick the cell under the pointer, and switch layouts with the Grid panel. Every tile is its own canvas texture in an image-collection TileSet, and trees overhang the row above.", "backend": "core", "tags": ["tilemap", "hexagonal", "pointer"], "capabilities": ["pointer"], @@ -996,7 +1007,7 @@ "path": "tilemap/tile-chunks-and-bands.js", "language": "typescript", "title": "Tilemap Chunks & Layer Bands", - "description": "Build a procedural TileMap in code and explore the core @codexo/exojs-tilemap runtime: chunked TileLayerNode rendering, a camera that follows an actor interleaved between TileMapView bands, and the PixelSnapMode cascade.", + "description": "WASD or the arrow keys move an explorer over a TileMap built in code: chunked TileLayerNode rendering, a following camera, the explorer drawn between the ground and canopy TileMapView bands, and the PixelSnapMode choices in the panel.", "backend": "core", "tags": ["tilemap", "camera", "pixel-snap"], "capabilities": ["keyboard"], @@ -1007,7 +1018,7 @@ "path": "tilemap/tiled-map-import.js", "language": "typescript", "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.", + "description": "Load and render a .tmj room with the common tileMap asset type, then click highlighted objects to inspect their editor-authored names, types, and properties.", "backend": "core", "tags": ["tilemap", "tiled", "objects"], "level": "intermediate" @@ -1017,7 +1028,7 @@ "path": "tilemap/ldtk-world-import.js", "language": "typescript", "title": "LDtk World Import", - "description": "Load a multi-level LDtk world, switch between its runtime TileMap levels, resolve IntGrid cells to their named/coloured definitions with getLdtkIntGridValueAt(), and read entity fields from the converted ObjectLayer.", + "description": "Load a multi-level LDtk world, switch between its runtime TileMap levels and toggle the IntGrid overlay in the LDtk panel. Coloured squares come from getLdtkIntGridValueAt(), which resolves the raw Walls cells to their LDtk-authored name and colour, and entity fields are read from the converted ObjectLayer.", "backend": "core", "tags": ["tilemap", "ldtk", "intgrid"], "level": "advanced" @@ -1027,7 +1038,7 @@ "path": "tilemap/editor-objects-gameplay.js", "language": "typescript", "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.", + "description": "Click the authored switch to open its door, then click the gem to collect it. The three coloured objects come from the Tiled object layer and MapObjectSpawner creates their runtime counterparts.", "backend": "core", "tags": ["tilemap", "tiled", "objects", "spawner"], "capabilities": ["pointer"], @@ -1038,7 +1049,7 @@ "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.", + "description": "Click to set the goal, or switch Click paints to wall or mud to edit the grid; a weighted GridSpace and Pathfinder answer every change. Toggle jump-point pruning, path smoothing, a 2x2 agent and the diagonal policy and watch the expanded-node counter; painting mud makes the grid non-uniform, which turns pruning off by itself.", "backend": "core", "featured": true, "tags": ["pathfinding", "grid", "pointer"], @@ -1050,7 +1061,7 @@ "path": "pathfinding/tilemap-navigation.js", "language": "typescript", "title": "Editable Map Navigation", - "description": "Move through a tile map while navigation agents hunt you around its walls; click a tile to open or close it and watch every route adapt.", + "description": "Move the white player with WASD or the arrow keys while orange navigation agents hunt you around the walls, and click a tile to open or close it to watch every route adapt. Hunters re-route when you change tile or edit the map; the blue patroller follows a hand-placed waypoint graph.", "backend": "core", "tags": ["pathfinding", "tilemap", "physics", "keyboard", "pointer"], "capabilities": ["keyboard", "pointer"], @@ -1061,7 +1072,7 @@ "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.", + "description": "Drag to paint grass, or switch on Erase in the Brush panel (touch has no right-click) to remove it; WangSet maps four-neighbor masks to tile variants in the existing map atlas, and each edit refreshes only the cell and its neighbors.", "backend": "core", "tags": ["tilemap", "wang", "autotile", "pointer"], "capabilities": ["pointer"], @@ -1072,7 +1083,7 @@ "path": "tilemap/tiled-infinite-map.js", "language": "typescript", "title": "Tiled Infinite Map Streaming", - "description": "Stream a hand-authored Tiled infinite .tmj map: TiledMap.toTileMap() converts each chunked tile layer to an unbounded TileLayer, and a ChunkStreamer per layer pulls chunks from TiledMap.getChunkSource() around a free-flying WASD camera.", + "description": "Stream a hand-authored Tiled infinite .tmj map with a free-flying WASD camera: TiledMap.toTileMap() converts each chunked tile layer to an unbounded TileLayer, and a ChunkStreamer per layer pulls chunks from TiledMap.getChunkSource(). The status counts resident chunks per layer.", "backend": "core", "tags": ["tilemap", "tiled", "streaming"], "level": "advanced", @@ -1083,7 +1094,7 @@ "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.", + "description": "Fly across a deterministic unbounded world with WASD and compare synchronous and worker-backed terrain sampling: pick the provider and sample cost, or roll a new seed, in the Provider panel. Raise the cost, then switch to worker to compare frame time while moving; the spinning character is a jank indicator.", "backend": "core", "tags": ["tilemap", "procedural", "worker"], "level": "advanced", @@ -1096,7 +1107,7 @@ "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.", + "description": "Drag either rectangle over the other and detect AABB overlap in the update loop: both tint red and the overlap region lights up while the status shows its size. Foundational collision recipe.", "backend": "core", "tags": ["scene", "rendering"], "level": "intro" @@ -1106,7 +1117,7 @@ "path": "physics/sprite-follows-body.js", "language": "typescript", "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.", + "description": "Drag the character with the mouse (a MouseJoint pulls its body) and release it onto the floor; R resets it. world.attach binds the sprite to the body, so the sprite follows the simulated position and rotation. Grabs, throws and floor contacts are logged to the Output panel.", "backend": "core", "tags": ["physics", "binding", "pointer", "joints"], "capabilities": ["pointer", "keyboard"], @@ -1117,7 +1128,7 @@ "path": "physics/kinematic-movement.js", "language": "typescript", "title": "Kinematic Movement", - "description": "Steer a kinematic body with the keyboard; it stops at walls, slides along them and comes to rest in tight corners.", + "description": "Steer a kinematic body with WASD or the arrow keys; it stops at walls, slides along them when you push in at an angle, and comes to rest in tight corners. The status shows blocked or free with the surface normal.", "backend": "core", "tags": ["physics", "movement", "keyboard"], "capabilities": ["keyboard"], @@ -1128,7 +1139,7 @@ "path": "physics/character-movement.js", "language": "typescript", "title": "Character Movement", - "description": "Run, jump and double-jump a capsule character over a slope, steps, a one-way platform and moving platforms, with live ground and velocity readouts.", + "description": "Run with A/D or the arrows, jump with Space, W or Up (once more in the air), and press S or Down to drop through the one-way platform. Climb the 30 degree slope and the low step, get stopped by the steep face and the tall block, and ride the moving platforms, with live ground and velocity readouts.", "backend": "core", "tags": ["physics", "movement", "platformer", "keyboard"], "capabilities": ["keyboard"], @@ -1139,7 +1150,7 @@ "path": "physics/tiled-map-physics-actor.js", "language": "typescript", "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.", + "description": "Load an authored .tmj room, build static bodies from its Collision object layer with buildObjectLayerColliders(), and toggle the authored collision outlines while a dynamic actor lands on them.", "backend": "core", "tags": ["physics", "tilemap", "collision"], "level": "intermediate" @@ -1149,7 +1160,7 @@ "path": "physics/joint-driven-shadows.js", "language": "typescript", "title": "Joint-Driven Shadows", - "description": "Drag a hinged physics arm through a live light and watch its collider reshape the shadow it casts.", + "description": "Drag the hinged physics arm through a live light and watch its collider reshape the shadow it casts. PhysicsOccluder includes dynamic bodies here; its static-only default would omit the moving arm.", "backend": "core", "tags": ["physics", "joints", "lighting", "shadows"], "capabilities": ["pointer"], @@ -1163,7 +1174,7 @@ "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.", + "description": "Drag overlapping sprites and press 1, 2 or 3 to bring that sprite to the front while the debug overlays show pointer targets, hit tests, and scene-graph bounds. The Output panel names the sprite each tap hit and where each drag ended.", "backend": "core", "tags": ["debug", "pointer", "input", "scene-graph"], "capabilities": ["pointer", "keyboard"], @@ -1174,7 +1185,7 @@ "path": "performance/backend-comparison.js", "language": "typescript", "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.", + "description": "One seeded sprite workload with the performance overlay: change the sprite count (Up/Down), share one texture or spread over four (T) and switch between WebGL2 and WebGPU when available (B), or use the panel.", "backend": "core", "featured": true, "tags": ["performance", "batching", "rendering", "webgpu"], @@ -1186,7 +1197,7 @@ "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.", + "description": "A static field of sprites drifts as one group like a camera pan. With the retained tier on, the field replays as recorded batches with one group-matrix update; off, a plain Container walks every child each frame. The panel shows submitted nodes and frame time.", "backend": "core", "tags": ["scene-graph", "rendering", "performance"], "level": "advanced" @@ -1196,7 +1207,7 @@ "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.", + "description": "Draw procedural geometry without a scene graph: gears via drawGeometry and a 2,400-spark field as one instanced RenderBatch. Turn batching off to draw each spark separately and watch the draw-call count.", "backend": "core", "tags": ["rendering", "geometry", "instancing", "performance"], "level": "advanced" diff --git a/examples/filters/bloom-filter.js b/examples/filters/bloom-filter.js index c7c2b9535..819df7aba 100644 --- a/examples/filters/bloom-filter.js +++ b/examples/filters/bloom-filter.js @@ -1,6 +1,6 @@ // Auto-generated from bloom-filter.ts - edit the .ts source, not this file. import { Application, BloomFilter, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, slider } from '@examples/runtime'; /** * Six discs of rising luminance on a dark backdrop. Only the ones above the * threshold glow, so dragging Threshold walks the glow along the row and shows @@ -28,8 +28,6 @@ class BloomFilterScene extends Scene { discs; beam; elapsed = 0; - hud; - panel; init() { const { width, height } = this.app; this.bloom = new BloomFilter({ threshold: 0.6, intensity: 1.6, strength: 10, levels: 3 }); @@ -47,73 +45,22 @@ class BloomFilterScene extends Scene { this.discs.fillColor = DISCS[index]; this.discs.drawCircle(spacing * (index + 1), height * 0.38, DISC_RADIUS); } - this.hud = mountControls({ - title: 'Bloom Filter', - controls: [ - { keys: 'Threshold', action: 'linear luminance to glow (1 = white)' }, - { keys: 'Intensity', action: 'how much of the extracted highlight is added back' }, - { keys: 'Strength', action: 'how far the glow spreads, in logical units' }, - { keys: 'Levels', action: 'halvings before the blur - wider and cheaper, or tighter' }, - ], - status: this.statusText(), - hint: 'The discs get brighter from left to right. Raise Threshold and watch the glow retreat along the row.', - }); - this.panel = mountControlPanel({ title: 'Bloom' }); - this.panel.addSlider({ - label: 'Threshold', - min: 0, - max: 1, - step: 0.05, - value: this.bloom.threshold, - onChange: value => { - this.bloom.threshold = value; - this.refresh(); - }, - }); - this.panel.addSlider({ - label: 'Intensity', - min: 0, - max: 4, - step: 0.1, - value: this.bloom.intensity, - onChange: value => { - this.bloom.intensity = value; - this.refresh(); - }, - }); - this.panel.addSlider({ - label: 'Strength', - min: 0, - max: 32, - step: 1, - value: this.bloom.strength, - onChange: value => { - this.bloom.strength = value; - this.refresh(); + demoPanel({ + title: 'Bloom', + controls: { + threshold: slider('Threshold', { min: 0, max: 1, step: 0.05, value: this.bloom.threshold }), + intensity: slider('Intensity', { min: 0, max: 4, step: 0.1, value: this.bloom.intensity }), + strength: slider('Strength', { min: 0, max: 32, step: 1, value: this.bloom.strength, format: v => v.toFixed(0) }), + levels: slider('Levels', { min: 1, max: 5, step: 1, value: this.bloom.levels, format: v => v.toFixed(0) }), }, - }); - this.panel.addSlider({ - label: 'Levels', - min: 1, - max: 5, - step: 1, - value: this.bloom.levels, - onChange: value => { - this.bloom.levels = value; - this.refresh(); + onChange: (key, value) => { + this.bloom[key] = value; }, }); } update(delta) { this.elapsed += delta; } - statusText() { - const { threshold, intensity, strength, levels } = this.bloom; - return `threshold ${threshold.toFixed(2)} · intensity ${intensity.toFixed(1)} · strength ${strength.toFixed(0)} · levels ${levels}`; - } - refresh() { - this.hud.setStatus(this.statusText()); - } draw(context) { const { width, height } = this.app; // A sweeping white bar: a moving highlight shows the glow tracking it, diff --git a/examples/filters/bloom-filter.ts b/examples/filters/bloom-filter.ts index 96c785d99..c9c8e31f2 100644 --- a/examples/filters/bloom-filter.ts +++ b/examples/filters/bloom-filter.ts @@ -9,7 +9,7 @@ import { Scene, type Seconds, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, slider } from '@examples/runtime'; /** * Six discs of rising luminance on a dark backdrop. Only the ones above the @@ -40,8 +40,6 @@ class BloomFilterScene extends Scene { private discs!: Graphics; private beam!: Graphics; private elapsed = 0; - private hud!: ReturnType; - private panel!: ReturnType; override init(): void { const { width, height } = this.app; @@ -64,61 +62,16 @@ class BloomFilterScene extends Scene { this.discs.drawCircle(spacing * (index + 1), height * 0.38, DISC_RADIUS); } - this.hud = mountControls({ - title: 'Bloom Filter', - controls: [ - { keys: 'Threshold', action: 'linear luminance to glow (1 = white)' }, - { keys: 'Intensity', action: 'how much of the extracted highlight is added back' }, - { keys: 'Strength', action: 'how far the glow spreads, in logical units' }, - { keys: 'Levels', action: 'halvings before the blur - wider and cheaper, or tighter' }, - ], - status: this.statusText(), - hint: 'The discs get brighter from left to right. Raise Threshold and watch the glow retreat along the row.', - }); - - this.panel = mountControlPanel({ title: 'Bloom' }); - this.panel.addSlider({ - label: 'Threshold', - min: 0, - max: 1, - step: 0.05, - value: this.bloom.threshold, - onChange: value => { - this.bloom.threshold = value; - this.refresh(); - }, - }); - this.panel.addSlider({ - label: 'Intensity', - min: 0, - max: 4, - step: 0.1, - value: this.bloom.intensity, - onChange: value => { - this.bloom.intensity = value; - this.refresh(); - }, - }); - this.panel.addSlider({ - label: 'Strength', - min: 0, - max: 32, - step: 1, - value: this.bloom.strength, - onChange: value => { - this.bloom.strength = value; - this.refresh(); + demoPanel({ + title: 'Bloom', + controls: { + threshold: slider('Threshold', { min: 0, max: 1, step: 0.05, value: this.bloom.threshold }), + intensity: slider('Intensity', { min: 0, max: 4, step: 0.1, value: this.bloom.intensity }), + strength: slider('Strength', { min: 0, max: 32, step: 1, value: this.bloom.strength, format: v => v.toFixed(0) }), + levels: slider('Levels', { min: 1, max: 5, step: 1, value: this.bloom.levels, format: v => v.toFixed(0) }), }, - }); - this.panel.addSlider({ - label: 'Levels', - min: 1, - max: 5, - step: 1, - value: this.bloom.levels, - onChange: value => { - this.bloom.levels = value; - this.refresh(); + onChange: (key, value) => { + this.bloom[key] = value; }, }); } @@ -127,16 +80,6 @@ class BloomFilterScene extends Scene { this.elapsed += delta; } - private statusText(): string { - const { threshold, intensity, strength, levels } = this.bloom; - - return `threshold ${threshold.toFixed(2)} · intensity ${intensity.toFixed(1)} · strength ${strength.toFixed(0)} · levels ${levels}`; - } - - private refresh(): void { - this.hud.setStatus(this.statusText()); - } - override draw(context: RenderingContext): void { const { width, height } = this.app; // A sweeping white bar: a moving highlight shows the glow tracking it, diff --git a/examples/filters/blur-filter.js b/examples/filters/blur-filter.js index 559d936c6..489932f67 100644 --- a/examples/filters/blur-filter.js +++ b/examples/filters/blur-filter.js @@ -1,15 +1,14 @@ // Auto-generated from blur-filter.ts - edit the .ts source, not this file. import { Application, BlurFilter, Color, DropShadowFilter, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, slider } from '@examples/runtime'; +const SHIP = assets.ship; 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; + shadow = new DropShadowFilter({ offsetX: 15, offsetY: 18, blur: 8, quality: 2, color: new Color(0, 0, 0, 0.8) }); + panel; labels; init() { const { width, height } = this.app; @@ -26,66 +25,20 @@ class BlurAndShadowScene extends Scene { 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({ + this.panel = demoPanel({ 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(); - }, - }); - panel.addSlider({ - label: 'Blur strength', - min: 0, - max: 8, - step: 0.1, - value: this.blur.strength, - onChange: value => { - this.blur.strength = value; - this.refresh(); + controls: { + filter: choice('Filter', ['Blur', 'Drop shadow']), + strength: slider('Strength', { min: 0, max: 8, value: 3, step: 0.1, format: value => value.toFixed(1) }), }, - }); - 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: value => { - this.enabled = value; - this.refresh(); + onChange: (_key, _value, values) => { + this.blur.strength = values.strength; + this.shadow.offsetX = values.strength * 5; + this.shadow.offsetY = values.strength * 6; + this.filtered.filters = [values.filter === 'Blur' ? this.blur : this.shadow]; + this.labels[1].text = values.filter; }, }); - this.refresh(); - } - refresh() { - 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.reference); @@ -94,6 +47,10 @@ class BlurAndShadowScene extends Scene { context.render(label); } } + destroy() { + this.panel.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { BlurAndShadowScene }, diff --git a/examples/filters/blur-filter.ts b/examples/filters/blur-filter.ts index 5b687685d..7255dd8a1 100644 --- a/examples/filters/blur-filter.ts +++ b/examples/filters/blur-filter.ts @@ -9,18 +9,17 @@ import { Sprite, Text, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +const SHIP = assets.ship; 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 shadow = new DropShadowFilter({ offsetX: 15, offsetY: 18, blur: 8, quality: 2, color: new Color(0, 0, 0, 0.8) }); + private panel!: DemoPanel<{ filter: ChoiceSpec<'Blur' | 'Drop shadow'>; strength: SliderSpec }>; private labels!: Text[]; override init(): void { @@ -39,67 +38,20 @@ class BlurAndShadowScene extends Scene { new Text('Blur', { fillColor: Color.white, fontSize: 25 }).setAnchor(0.5).setPosition(width * 0.7, height * 0.78), ]; - this.hud = mountControls({ + this.panel = demoPanel({ 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(); - }, - }); - panel.addSlider({ - label: 'Blur strength', - min: 0, - max: 8, - step: 0.1, - value: this.blur.strength, - onChange: value => { - this.blur.strength = value; - this.refresh(); - }, - }); - 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(); + controls: { + filter: choice('Filter', ['Blur', 'Drop shadow']), + strength: slider('Strength', { min: 0, max: 8, value: 3, step: 0.1, format: value => value.toFixed(1) }), }, - }); - panel.addToggle({ - label: 'Filter', - value: true, - onChange: value => { - this.enabled = value; - this.refresh(); + onChange: (_key, _value, values) => { + this.blur.strength = values.strength; + this.shadow.offsetX = values.strength * 5; + this.shadow.offsetY = values.strength * 6; + this.filtered.filters = [values.filter === 'Blur' ? this.blur : this.shadow]; + this.labels[1].text = values.filter; }, }); - this.refresh(); - } - - private refresh(): void { - 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 { @@ -110,6 +62,11 @@ class BlurAndShadowScene extends Scene { context.render(label); } } + + override destroy(): void { + this.panel.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/filters/chromatic-aberration.js b/examples/filters/chromatic-aberration.js index 471f59bde..b00af5d06 100644 --- a/examples/filters/chromatic-aberration.js +++ b/examples/filters/chromatic-aberration.js @@ -1,8 +1,9 @@ // Auto-generated from chromatic-aberration.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // A dense checkerboard makes the per-channel RGB split read clearly along edges. -const CHECKER = assets.technical.filtering.checker256; +const CHECKER = assets.checker256; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; uniform float uOffset; in vec2 vUv; out vec4 fragColor; void main(){ vec2 o=vec2(uOffset,0.0); float r=texture(uTexture,vUv+o).r; float g=texture(uTexture,vUv).g; float b=texture(uTexture,vUv-o).b; float a=texture(uTexture,vUv).a; fragColor=vec4(r,g,b,a);} `; diff --git a/examples/filters/chromatic-aberration.ts b/examples/filters/chromatic-aberration.ts index 744f44b1b..93674bee0 100644 --- a/examples/filters/chromatic-aberration.ts +++ b/examples/filters/chromatic-aberration.ts @@ -1,8 +1,9 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // A dense checkerboard makes the per-channel RGB split read clearly along edges. -const CHECKER = assets.technical.filtering.checker256; +const CHECKER = assets.checker256; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; uniform float uOffset; in vec2 vUv; out vec4 fragColor; diff --git a/examples/filters/color-matrix-filter.js b/examples/filters/color-matrix-filter.js index 0aa54d5fa..ea7f6ac5d 100644 --- a/examples/filters/color-matrix-filter.js +++ b/examples/filters/color-matrix-filter.js @@ -1,9 +1,8 @@ // Auto-generated from color-matrix-filter.ts - edit the .ts source, not this file. import { Application, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, LutFilter, Scene, Sprite, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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; @@ -49,87 +48,56 @@ const makeCoolLut = () => { context.putImageData(image, 0, 0); return canvas; }; +const TILE = 220; class ColorTransformsScene extends Scene { - reference; - processed; - referenceLabel; - processedLabel; - matrix; - ramp; - rampFilter; - cube; - cubeFilter; - hud; - init() { + sprites = []; + labels = []; + owned = []; + async load() { + const [ramp, art] = await Promise.all([this.loader.load(assets.primaryRamp), this.loader.load(assets.backgroundForest)]); 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(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); - // Grading is display-referred: the matrix and both lookups run on encoded + // Grading is display-referred: the matrices and both lookups run on encoded // sRGB values, which is what the numbers below were written against, and // the result returns to linear premultiplied colour. `'linear-srgb'` would // apply the same numbers to linear light instead. The LUT images are - // numeric tables (`fromImage` never decodes them), and a neutral matrix or - // identity table leaves colour and alpha untouched. - this.matrix = new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }); - this.ramp = LutFilter.fromImage(makeRamp()); - this.rampFilter = new LutFilter({ mode: 'rgb1d', colorSpace: 'srgb' }).setLut(this.ramp); - this.cube = LutFilter.fromImage(makeCoolLut()); - this.cubeFilter = new LutFilter({ mode: '3d', size: LUT_SIZE, colorSpace: 'srgb' }).setLut(this.cube); - this.hud = mountControls({ - 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.', - }); - mountControlPanel({ title: 'Transform' }).addCycle({ - label: 'Method', - options: [...MODES], - index: 0, - onChange: index => this.setMode(index), + // numeric tables (`fromImage` never decodes them). + const curves = LutFilter.fromImage(makeRamp()); + const cube = LutFilter.fromImage(makeCoolLut()); + const columns = [ + { label: 'Original', filter: null }, + { label: 'Matrix: grayscale', filter: new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }).grayscale() }, + { label: 'Matrix: sepia', filter: new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }).sepia() }, + { label: '1D ramp: curves', filter: new LutFilter({ mode: 'rgb1d', colorSpace: 'srgb' }).setLut(curves) }, + { label: '3D LUT: cool grade', filter: new LutFilter({ mode: '3d', size: LUT_SIZE, colorSpace: 'srgb' }).setLut(cube) }, + ]; + const step = width / columns.length; + this.owned.push(curves, cube); + columns.forEach(({ label, filter }, index) => { + const x = step * (index + 0.5); + if (filter) { + this.owned.push(filter); + } + [ramp, art].forEach((texture, row) => { + const sprite = new Sprite(texture).setAnchor(0.5).setPosition(x, height / 2 + (row - 0.5) * (TILE + 24)); + // The colour ramp is a short strip; stretching it to a square tile + // keeps every hue large enough to compare. + sprite.width = TILE; + sprite.height = texture === ramp ? TILE * 0.6 : (TILE * texture.height) / texture.width; + sprite.filters = filter ? [filter] : []; + this.sprites.push(sprite); + }); + this.labels.push(new Text(label, { fontSize: 17, fillColor: Color.white }).setAnchor(0.5).setPosition(x, 60)); }); - this.setMode(0); - } - 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.reference); - context.render(this.processed); - context.render(this.referenceLabel); - context.render(this.processedLabel); + for (const drawable of [...this.sprites, ...this.labels]) { + context.render(drawable); + } } 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(); + for (const resource of this.owned) { + resource.destroy(); + } super.destroy(); } } diff --git a/examples/filters/color-matrix-filter.ts b/examples/filters/color-matrix-filter.ts index 5b7816a11..6134358bc 100644 --- a/examples/filters/color-matrix-filter.ts +++ b/examples/filters/color-matrix-filter.ts @@ -8,13 +8,11 @@ import { Scene, Sprite, Text, - type Texture, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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; const makeRamp = (): HTMLCanvasElement => { const canvas = document.createElement('canvas'); @@ -73,93 +71,68 @@ const makeCoolLut = (): HTMLCanvasElement => { return canvas; }; +const TILE = 220; + 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 { + private readonly sprites: Sprite[] = []; + private readonly labels: Text[] = []; + private readonly owned: Array<{ destroy(): void }> = []; + + override async load(): Promise { + const [ramp, art] = await Promise.all([this.loader.load(assets.primaryRamp), this.loader.load(assets.backgroundForest)]); 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(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); - // Grading is display-referred: the matrix and both lookups run on encoded + + // Grading is display-referred: the matrices and both lookups run on encoded // sRGB values, which is what the numbers below were written against, and // the result returns to linear premultiplied colour. `'linear-srgb'` would // apply the same numbers to linear light instead. The LUT images are - // numeric tables (`fromImage` never decodes them), and a neutral matrix or - // identity table leaves colour and alpha untouched. - this.matrix = new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }); - this.ramp = LutFilter.fromImage(makeRamp()); - this.rampFilter = new LutFilter({ mode: 'rgb1d', colorSpace: 'srgb' }).setLut(this.ramp); - this.cube = LutFilter.fromImage(makeCoolLut()); - this.cubeFilter = new LutFilter({ mode: '3d', size: LUT_SIZE, colorSpace: 'srgb' }).setLut(this.cube); - - this.hud = mountControls({ - 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.', - }); - mountControlPanel({ title: 'Transform' }).addCycle({ - label: 'Method', - options: [...MODES], - index: 0, - onChange: index => this.setMode(index), - }); - this.setMode(0); - } + // numeric tables (`fromImage` never decodes them). + const curves = LutFilter.fromImage(makeRamp()); + const cube = LutFilter.fromImage(makeCoolLut()); + const columns = [ + { label: 'Original', filter: null }, + { label: 'Matrix: grayscale', filter: new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }).grayscale() }, + { label: 'Matrix: sepia', filter: new ColorMatrixFilter(undefined, { colorSpace: 'srgb' }).sepia() }, + { label: '1D ramp: curves', filter: new LutFilter({ mode: 'rgb1d', colorSpace: 'srgb' }).setLut(curves) }, + { label: '3D LUT: cool grade', filter: new LutFilter({ mode: '3d', size: LUT_SIZE, colorSpace: 'srgb' }).setLut(cube) }, + ]; + const step = width / columns.length; + + this.owned.push(curves, cube); + + columns.forEach(({ label, filter }, index) => { + const x = step * (index + 0.5); + + if (filter) { + this.owned.push(filter); + } - 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 = []; - } + [ramp, art].forEach((texture, row) => { + const sprite = new Sprite(texture).setAnchor(0.5).setPosition(x, height / 2 + (row - 0.5) * (TILE + 24)); + + // The colour ramp is a short strip; stretching it to a square tile + // keeps every hue large enough to compare. + sprite.width = TILE; + sprite.height = texture === ramp ? TILE * 0.6 : (TILE * texture.height) / texture.width; + sprite.filters = filter ? [filter] : []; + this.sprites.push(sprite); + }); - this.hud.setStatus(MODES[index] ?? MODES[4]); + this.labels.push(new Text(label, { fontSize: 17, fillColor: Color.white }).setAnchor(0.5).setPosition(x, 60)); + }); } override draw(context: RenderingContext): void { - context.render(this.reference); - context.render(this.processed); - context.render(this.referenceLabel); - context.render(this.processedLabel); + for (const drawable of [...this.sprites, ...this.labels]) { + context.render(drawable); + } } 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(); + for (const resource of this.owned) { + resource.destroy(); + } + super.destroy(); } } diff --git a/examples/filters/crt-scanlines.js b/examples/filters/crt-scanlines.js index f17ea01ef..c2a5108ee 100644 --- a/examples/filters/crt-scanlines.js +++ b/examples/filters/crt-scanlines.js @@ -1,6 +1,7 @@ // Auto-generated from crt-scanlines.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider, toggle } from '@examples/runtime'; const glsl = `#version 300 es precision highp float; uniform sampler2D uTexture; @@ -48,64 +49,36 @@ fn hash(point: vec2) -> f32 { class RetroDisplayScene extends Scene { stage; filter; - hud; - enabled = true; - amount = 0.8; - split = 0.006; + panel; time = 0; init() { const { width, height } = this.app; - const background = new Sprite(this.loader.get(assets.technical.filtering.uvGrid256)).setAnchor(0.5).setPosition(width / 2, height / 2); + const background = new Sprite(this.loader.get(assets.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.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0, uAmount: 0.8, uSplit: 0.006 } }); this.stage.filters = [this.filter]; - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Retro Display', - status: this.statusText(), - hint: 'One display filter combines barrel distortion, scanlines, animated grain, vignette, and RGB separation. Bypass it to compare the clean scene.', - }); - const panel = mountControlPanel({ title: 'Display' }); - panel.addToggle({ - label: 'CRT', - value: true, - onChange: enabled => { - this.enabled = enabled; - this.stage.filters = enabled ? [this.filter] : []; - this.hud.setStatus(this.statusText()); + controls: { + crt: toggle('CRT', true), + strength: slider('Effect strength', { min: 0, max: 1, value: 0.8, step: 0.05, format: value => `${Math.round(value * 100)}%` }), + split: slider('RGB separation', { min: 0, max: 0.02, value: 0.006, step: 0.001, format: value => value.toFixed(3) }), }, - }); - 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()); + onChange: (key, _value, values) => { + if (key === 'crt') { + this.stage.filters = values.crt ? [this.filter] : []; + } else if (key === 'strength') { + this.filter.setUniform('uAmount', values.strength); + } else if (key === 'split') { + this.filter.setUniform('uSplit', values.split); + } }, }); - 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 ${Math.round(this.amount * 100)}% · RGB split ${this.split.toFixed(3)}` : 'CRT off · clean scene'; } update(delta) { this.time += delta; @@ -115,7 +88,7 @@ class RetroDisplayScene extends Scene { context.render(this.stage); } destroy() { - this.hud.dispose(); + this.panel.dispose(); this.filter.destroy(); super.destroy(); } diff --git a/examples/filters/crt-scanlines.ts b/examples/filters/crt-scanlines.ts index 35daf6526..ce1208e66 100644 --- a/examples/filters/crt-scanlines.ts +++ b/examples/filters/crt-scanlines.ts @@ -10,7 +10,8 @@ import { Sprite, Text, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { type DemoPanel, demoPanel, slider, type SliderSpec, toggle, type ToggleSpec } from '@examples/runtime'; const glsl = `#version 300 es precision highp float; @@ -61,15 +62,12 @@ fn hash(point: vec2) -> f32 { class RetroDisplayScene extends Scene { private stage!: Container; private filter!: ShaderFilter; - private hud!: ReturnType; - private enabled = true; - private amount = 0.8; - private split = 0.006; + private panel!: DemoPanel<{ crt: ToggleSpec; strength: SliderSpec; split: SliderSpec }>; private time = 0; override init(): void { const { width, height } = this.app; - const background = new Sprite(this.loader.get(assets.technical.filtering.uvGrid256)).setAnchor(0.5).setPosition(width / 2, height / 2); + const background = new Sprite(this.loader.get(assets.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 }); @@ -77,54 +75,28 @@ class RetroDisplayScene extends Scene { 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.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uTime: 0, uAmount: 0.8, uSplit: 0.006 } }); this.stage.filters = [this.filter]; - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Retro Display', - status: this.statusText(), - hint: 'One display filter combines barrel distortion, scanlines, animated grain, vignette, and RGB separation. Bypass it to compare the clean scene.', - }); - const panel = mountControlPanel({ title: 'Display' }); - panel.addToggle({ - label: 'CRT', - value: true, - 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()); + controls: { + crt: toggle('CRT', true), + strength: slider('Effect strength', { min: 0, max: 1, value: 0.8, step: 0.05, format: value => `${Math.round(value * 100)}%` }), + split: slider('RGB separation', { min: 0, max: 0.02, value: 0.006, step: 0.001, format: value => value.toFixed(3) }), }, - }); - 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()); + onChange: (key, _value, values) => { + if (key === 'crt') { + this.stage.filters = values.crt ? [this.filter] : []; + } else if (key === 'strength') { + this.filter.setUniform('uAmount', values.strength); + } else if (key === 'split') { + this.filter.setUniform('uSplit', values.split); + } }, }); } - private statusText(): string { - 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); @@ -135,7 +107,7 @@ class RetroDisplayScene extends Scene { } override destroy(): void { - this.hud.dispose(); + this.panel.dispose(); this.filter.destroy(); super.destroy(); } diff --git a/examples/filters/custom-fragment-shader.js b/examples/filters/custom-fragment-shader.js index 1ddcb957a..54b6dc226 100644 --- a/examples/filters/custom-fragment-shader.js +++ b/examples/filters/custom-fragment-shader.js @@ -9,8 +9,8 @@ import { Sprite, UniformType, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; -const UV_GRID = assets.technical.filtering.uvGrid256; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +const UV_GRID = assets.uvGrid256; // The filter input is linear, premultiplied colour, and the fragment returns // colour in that same representation, so a shader that only moves samples // (as this one does) needs no colour conversion. Only the numeric inputs - the @@ -58,7 +58,6 @@ class CustomFragmentShaderScene extends Scene { time = 0; filter; sprite; - hud; init() { const app = this.app; const { width, height } = app; @@ -68,12 +67,6 @@ class CustomFragmentShaderScene extends Scene { .setScale(4) .setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; - this.hud = mountControls({ - 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); } @@ -93,7 +86,6 @@ class CustomFragmentShaderScene extends Scene { } destroy() { this.app.input.onPointerMove.remove(this.onMove); - this.hud?.dispose(); this.sprite?.destroy(); super.destroy(); } diff --git a/examples/filters/custom-fragment-shader.ts b/examples/filters/custom-fragment-shader.ts index 596f3f02e..427f20591 100644 --- a/examples/filters/custom-fragment-shader.ts +++ b/examples/filters/custom-fragment-shader.ts @@ -10,9 +10,9 @@ import { Sprite, UniformType, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; // The filter input is linear, premultiplied colour, and the fragment returns // colour in that same representation, so a shader that only moves samples @@ -63,7 +63,6 @@ class CustomFragmentShaderScene extends Scene { private time = 0; private filter!: ShaderFilter; private sprite!: Sprite; - private hud!: ReturnType; override init(): void { const app = this.app; @@ -76,12 +75,6 @@ class CustomFragmentShaderScene extends Scene { .setPosition(width / 2, height / 2); this.sprite.filters = [this.filter]; - this.hud = mountControls({ - 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); } @@ -106,7 +99,6 @@ class CustomFragmentShaderScene extends Scene { override destroy(): void { this.app.input.onPointerMove.remove(this.onMove); - this.hud?.dispose(); this.sprite?.destroy(); super.destroy(); } diff --git a/examples/filters/displacement-filter.js b/examples/filters/displacement-filter.js index 2932445b6..4a30dc45e 100644 --- a/examples/filters/displacement-filter.js +++ b/examples/filters/displacement-filter.js @@ -11,8 +11,9 @@ import { TextureFormat, WrapModes, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +const SHIP = assets.ship; const MAP_SIZE = 256; // The displacement map: red is the horizontal direction, green the vertical, // both decoded from [0, 1] to [-1, 1], so flat (0.5, 0.5) grey means "stay @@ -110,8 +111,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 30, 46), - loader: { - basePath: 'assets/', - }, }); await app.start(DisplacementFilterScene); diff --git a/examples/filters/displacement-filter.ts b/examples/filters/displacement-filter.ts index fc9a0d06a..6ea49e8b6 100644 --- a/examples/filters/displacement-filter.ts +++ b/examples/filters/displacement-filter.ts @@ -13,9 +13,10 @@ import { TextureFormat, WrapModes, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +const SHIP = assets.ship; const MAP_SIZE = 256; // The displacement map: red is the horizontal direction, green the vertical, @@ -128,9 +129,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 30, 46), - loader: { - basePath: 'assets/', - }, }); await app.start(DisplacementFilterScene); diff --git a/examples/filters/drop-shadow-filter.js b/examples/filters/drop-shadow-filter.js index 08cdf2382..fd8703bc7 100644 --- a/examples/filters/drop-shadow-filter.js +++ b/examples/filters/drop-shadow-filter.js @@ -1,7 +1,8 @@ // Auto-generated from drop-shadow-filter.ts - edit the .ts source, not this file. import { Application, Color, DropShadowFilter, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +const SHIP = assets.ship; class DropShadowFilterScene extends Scene { shadow; sprite; @@ -101,8 +102,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(30, 34, 44), - loader: { - basePath: 'assets/', - }, }); await app.start(DropShadowFilterScene); diff --git a/examples/filters/drop-shadow-filter.ts b/examples/filters/drop-shadow-filter.ts index ab32edc4a..ca6344a9b 100644 --- a/examples/filters/drop-shadow-filter.ts +++ b/examples/filters/drop-shadow-filter.ts @@ -8,9 +8,10 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const SHIP = assets.demo.textures.shipA; +const SHIP = assets.ship; class DropShadowFilterScene extends Scene { private shadow!: DropShadowFilter; @@ -121,9 +122,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(30, 34, 44), - loader: { - basePath: 'assets/', - }, }); await app.start(DropShadowFilterScene); diff --git a/examples/filters/filter-stack.js b/examples/filters/filter-stack.js index 5c182e143..ddb80f64b 100644 --- a/examples/filters/filter-stack.js +++ b/examples/filters/filter-stack.js @@ -1,7 +1,8 @@ // Auto-generated from filter-stack.ts - edit the .ts source, not this file. import { Application, BlurFilter, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; void main(){ vec4 c=texture(uTexture,vUv); fragColor=vec4(c.rgb*vec3(1.0,0.9,1.2),c.a);} `; diff --git a/examples/filters/filter-stack.ts b/examples/filters/filter-stack.ts index 782d967c8..9bedda516 100644 --- a/examples/filters/filter-stack.ts +++ b/examples/filters/filter-stack.ts @@ -9,9 +9,10 @@ import { ShaderFilter, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor; diff --git a/examples/filters/metaballs.js b/examples/filters/metaballs.js index 7a449614b..6c151c4a9 100644 --- a/examples/filters/metaballs.js +++ b/examples/filters/metaballs.js @@ -1,6 +1,6 @@ // Auto-generated from metaballs.ts - edit the .ts source, not this file. import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, Graphics, Scene, ShaderFilter } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, demoPanel, slider } from '@examples/runtime'; // Threshold pass: render solid cyan where the (blurred) red field is dense // 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. @@ -21,6 +21,7 @@ const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; let shape = smoothstep(0.28, 0.5, field); return vec4(vec3(0.2, 0.9, 1.0) * shape, shape); }`; +const PASSES = ['Blur then threshold', 'Threshold then blur', 'No filters']; class MetaballsScene extends Scene { balls; points; @@ -29,7 +30,7 @@ class MetaballsScene extends Scene { handle; dragPoint = { x: 0, y: 0 }; dragging = false; - hud; + panel; onPointerDown = (_pointer, x, y) => { this.dragging = Math.hypot(x - this.dragPoint.x, y - this.dragPoint.y) < 58; }; @@ -48,28 +49,16 @@ class MetaballsScene extends Scene { 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 }); - 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.', - }); - 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, - step: 0.25, - value: 6, - onChange: value => { - this.blur.strength = value; + this.panel = demoPanel({ + title: 'Filter order', + controls: { + passes: choice('Passes', PASSES), + blur: slider('Blur strength', { min: 1, max: 12, value: 6, step: 0.25, format: value => value.toFixed(2) }), + }, + onChange: (_key, _value, values) => { + this.blur.strength = values.blur; + this.balls.filters = + values.passes === PASSES[0] ? [this.blur, this.threshold] : values.passes === PASSES[1] ? [this.threshold, this.blur] : []; }, }); this.app.input.onPointerDown.add(this.onPointerDown); @@ -77,16 +66,6 @@ class MetaballsScene extends Scene { 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; const { width, height } = app; @@ -114,7 +93,7 @@ class MetaballsScene extends Scene { 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.panel.dispose(); super.destroy(); } } diff --git a/examples/filters/metaballs.ts b/examples/filters/metaballs.ts index d39bbeb70..7206e16e6 100644 --- a/examples/filters/metaballs.ts +++ b/examples/filters/metaballs.ts @@ -9,7 +9,7 @@ import { type Seconds, ShaderFilter, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; // Threshold pass: render solid cyan where the (blurred) red field is dense // enough, with a smooth edge. The blur in front of this builds the scalar field @@ -32,6 +32,8 @@ const wgsl = `@group(0) @binding(1) var uTexture: texture_2d; return vec4(vec3(0.2, 0.9, 1.0) * shape, shape); }`; +const PASSES = ['Blur then threshold', 'Threshold then blur', 'No filters'] as const; + class MetaballsScene extends Scene { private balls!: Graphics; private points!: Array<{ a: number; r: number }>; @@ -40,7 +42,7 @@ class MetaballsScene extends Scene { private handle!: Graphics; private dragPoint = { x: 0, y: 0 }; private dragging = false; - private hud!: ReturnType; + private panel!: DemoPanel<{ passes: ChoiceSpec<(typeof PASSES)[number]>; blur: SliderSpec }>; private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { this.dragging = Math.hypot(x - this.dragPoint.x, y - this.dragPoint.y) < 58; }; @@ -62,29 +64,16 @@ class MetaballsScene extends Scene { this.blur = new BlurFilter({ strength: 6 }); this.threshold = new ShaderFilter({ glsl: { fragment: glsl }, wgsl }); - 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.', - }); - 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, - step: 0.25, - value: 6, - onChange: value => { - this.blur.strength = value; + this.panel = demoPanel({ + title: 'Filter order', + controls: { + passes: choice('Passes', PASSES), + blur: slider('Blur strength', { min: 1, max: 12, value: 6, step: 0.25, format: value => value.toFixed(2) }), + }, + onChange: (_key, _value, values) => { + this.blur.strength = values.blur; + this.balls.filters = + values.passes === PASSES[0] ? [this.blur, this.threshold] : values.passes === PASSES[1] ? [this.threshold, this.blur] : []; }, }); this.app.input.onPointerDown.add(this.onPointerDown); @@ -93,17 +82,6 @@ class MetaballsScene extends Scene { 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 { const app = this.app; const { width, height } = app; @@ -138,7 +116,7 @@ class MetaballsScene extends Scene { 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.panel.dispose(); super.destroy(); } } diff --git a/examples/filters/noise-vignette.js b/examples/filters/noise-vignette.js index 15d5eb967..ac51b27e1 100644 --- a/examples/filters/noise-vignette.js +++ b/examples/filters/noise-vignette.js @@ -1,9 +1,10 @@ // Auto-generated from noise-vignette.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // A detailed full-frame texture so grain + vignette read as a screen-wide post // effect rather than decorating one small sprite. -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; uniform float uTime; uniform float uIntensity; in vec2 vUv; out vec4 fragColor; float hash(vec2 p){ return fract(sin(dot(p,vec2(12.9898,78.233)))*43758.5453); } diff --git a/examples/filters/noise-vignette.ts b/examples/filters/noise-vignette.ts index bbc6485ba..f31bb8d27 100644 --- a/examples/filters/noise-vignette.ts +++ b/examples/filters/noise-vignette.ts @@ -8,11 +8,12 @@ import { ShaderFilter, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // A detailed full-frame texture so grain + vignette read as a screen-wide post // effect rather than decorating one small sprite. -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; const glsl = `#version 300 es precision mediump float; uniform sampler2D uTexture; uniform float uTime; uniform float uIntensity; in vec2 vUv; out vec4 fragColor; diff --git a/examples/filters/palette-cycling.js b/examples/filters/palette-cycling.js index 923634697..53d15d7e9 100644 --- a/examples/filters/palette-cycling.js +++ b/examples/filters/palette-cycling.js @@ -1,7 +1,8 @@ // Auto-generated from palette-cycling.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, LutFilter, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; const RAMP_SIZE = 256; // One sine curve per channel, each a third of a cycle out of phase. A 1D LUT // grades every channel through its OWN curve, so shifting all three by the same diff --git a/examples/filters/palette-cycling.ts b/examples/filters/palette-cycling.ts index 6167fc7de..7c013b354 100644 --- a/examples/filters/palette-cycling.ts +++ b/examples/filters/palette-cycling.ts @@ -9,9 +9,10 @@ import { Sprite, type Texture, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; const RAMP_SIZE = 256; diff --git a/examples/geometry-graphics/immediate-mode-rendering.js b/examples/geometry-graphics/immediate-mode-rendering.js index aebacc704..d15e0b245 100644 --- a/examples/geometry-graphics/immediate-mode-rendering.js +++ b/examples/geometry-graphics/immediate-mode-rendering.js @@ -1,6 +1,6 @@ // Auto-generated from immediate-mode-rendering.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Geometry, Matrix, RenderBatch, Scene } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, toggle } from '@examples/runtime'; // Number of instances drawn in the batched field. The whole field is one // instanced draw call no matter how large this is. const FIELD_COUNT = 2400; @@ -66,7 +66,6 @@ class ImmediateModeScene extends Scene { scratch = new Matrix(); elapsed = 0; batched = true; - hud; panel; init() { const app = this.app; @@ -113,20 +112,11 @@ class ImmediateModeScene extends Scene { tint: new Color(tint.r, tint.g, tint.b, 1), }); } - this.hud = mountControls({ - title: 'Immediate-Mode Rendering', - controls: [ - { keys: ['Batched'], action: `${FIELD_COUNT} sparks via RenderBatch + drawBatch (1 draw call)` }, - { keys: ['Per-shape'], action: 'each gear via drawGeometry (1 draw call each)' }, - ], - hint: 'Toggle the spark field between one instanced draw and one-draw-per-spark to compare draw calls.', - }); - this.panel = mountControlPanel({ title: 'Render path' }); - this.panel.addToggle({ - label: 'Batch sparks', - value: this.batched, - onChange: value => { - this.batched = value; + this.panel = demoPanel({ + title: 'Render path', + controls: { batched: toggle('Batch sparks', this.batched) }, + onChange: (_key, batched) => { + this.batched = batched; }, }); } @@ -167,16 +157,14 @@ class ImmediateModeScene extends Scene { composeTransform(this.scratch, gear.x, gear.y, time * gear.spin, scale); context.drawGeometry(gear.geometry, this.scratch, { tint: gear.tint }); } - // drawCalls is the proof: batched → gears + 1, per-shape → gears + sparks. - const drawCalls = context.stats.drawCalls; - const path = this.batched ? 'RenderBatch (instanced)' : 'drawGeometry per spark'; - this.hud.setStatus(`${path} · ${FIELD_COUNT} sparks · ${GEAR_COUNT} gears · drawCalls: ${drawCalls}`); + // The draw-call count is the proof: batched it is gears + 1, per shape it + // is gears + sparks. + this.panel.status(`${context.stats.drawCalls} draw calls`); } destroy() { this.dispose(); } dispose() { - this.hud?.dispose(); this.panel?.dispose(); this.sparkBatch?.destroy(); this.sparkGeometry?.destroy(); diff --git a/examples/geometry-graphics/immediate-mode-rendering.ts b/examples/geometry-graphics/immediate-mode-rendering.ts index b35584203..d9f33cd03 100644 --- a/examples/geometry-graphics/immediate-mode-rendering.ts +++ b/examples/geometry-graphics/immediate-mode-rendering.ts @@ -9,7 +9,7 @@ import { Scene, type Seconds, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; // Number of instances drawn in the batched field. The whole field is one // instanced draw call no matter how large this is. @@ -103,8 +103,7 @@ class ImmediateModeScene extends Scene { private elapsed = 0; private batched = true; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: DemoPanel<{ batched: ToggleSpec }>; override init(): void { const app = this.app; @@ -161,21 +160,11 @@ class ImmediateModeScene extends Scene { }); } - this.hud = mountControls({ - title: 'Immediate-Mode Rendering', - controls: [ - { keys: ['Batched'], action: `${FIELD_COUNT} sparks via RenderBatch + drawBatch (1 draw call)` }, - { keys: ['Per-shape'], action: 'each gear via drawGeometry (1 draw call each)' }, - ], - hint: 'Toggle the spark field between one instanced draw and one-draw-per-spark to compare draw calls.', - }); - - this.panel = mountControlPanel({ title: 'Render path' }); - this.panel.addToggle({ - label: 'Batch sparks', - value: this.batched, - onChange: value => { - this.batched = value; + this.panel = demoPanel({ + title: 'Render path', + controls: { batched: toggle('Batch sparks', this.batched) }, + onChange: (_key, batched) => { + this.batched = batched; }, }); } @@ -226,11 +215,9 @@ class ImmediateModeScene extends Scene { context.drawGeometry(gear.geometry, this.scratch, { tint: gear.tint }); } - // drawCalls is the proof: batched → gears + 1, per-shape → gears + sparks. - const drawCalls = context.stats.drawCalls; - const path = this.batched ? 'RenderBatch (instanced)' : 'drawGeometry per spark'; - - this.hud.setStatus(`${path} · ${FIELD_COUNT} sparks · ${GEAR_COUNT} gears · drawCalls: ${drawCalls}`); + // The draw-call count is the proof: batched it is gears + 1, per shape it + // is gears + sparks. + this.panel.status(`${context.stats.drawCalls} draw calls`); } override destroy(): void { @@ -238,7 +225,6 @@ class ImmediateModeScene extends Scene { } private dispose(): void { - this.hud?.dispose(); this.panel?.dispose(); this.sparkBatch?.destroy(); this.sparkGeometry?.destroy(); diff --git a/examples/geometry-graphics/infinite-grid.js b/examples/geometry-graphics/infinite-grid.js index 8454a4b13..a9a1a9bb6 100644 --- a/examples/geometry-graphics/infinite-grid.js +++ b/examples/geometry-graphics/infinite-grid.js @@ -1,5 +1,6 @@ // Auto-generated from infinite-grid.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, ShaderFilter, Sprite, View } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const glsl = `#version 300 es precision mediump float; uniform vec2 uCenter; @@ -50,7 +51,7 @@ class InfiniteGridScene extends Scene { const app = this.app; const { width, height } = app; this.view = new View(0, 0, width, height); - this.sprite = new Sprite(this.loader.get('image/uv-grid-256.png')); + this.sprite = new Sprite(this.loader.get(assets.uvGrid256)); this.sprite.width = width; this.sprite.height = height; this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uCenter: [0, 0], uViewSize: [width, height] } }); @@ -123,8 +124,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(InfiniteGridScene); diff --git a/examples/geometry-graphics/infinite-grid.ts b/examples/geometry-graphics/infinite-grid.ts index 498e8d8e7..f2d134645 100644 --- a/examples/geometry-graphics/infinite-grid.ts +++ b/examples/geometry-graphics/infinite-grid.ts @@ -10,6 +10,7 @@ import { Sprite, View, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const glsl = `#version 300 es precision mediump float; @@ -65,7 +66,7 @@ class InfiniteGridScene extends Scene { const { width, height } = app; this.view = new View(0, 0, width, height); - this.sprite = new Sprite(this.loader.get('image/uv-grid-256.png')); + this.sprite = new Sprite(this.loader.get(assets.uvGrid256)); this.sprite.width = width; this.sprite.height = height; this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uCenter: [0, 0], uViewSize: [width, height] } }); @@ -141,9 +142,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(InfiniteGridScene); diff --git a/examples/geometry-graphics/mesh-deformed-grid.js b/examples/geometry-graphics/mesh-deformed-grid.js index f05953d52..0de41bdb1 100644 --- a/examples/geometry-graphics/mesh-deformed-grid.js +++ b/examples/geometry-graphics/mesh-deformed-grid.js @@ -1,5 +1,6 @@ // Auto-generated from mesh-deformed-grid.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Mesh, Scene } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const COLS = 16; const ROWS = 16; const SIZE = 460; @@ -37,7 +38,7 @@ const buildGrid = () => { } return { vertices, uvs, indices }; }; -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; class MeshDeformedGridScene extends Scene { restVertices; mesh; diff --git a/examples/geometry-graphics/mesh-deformed-grid.ts b/examples/geometry-graphics/mesh-deformed-grid.ts index 06a0318b5..65443cd60 100644 --- a/examples/geometry-graphics/mesh-deformed-grid.ts +++ b/examples/geometry-graphics/mesh-deformed-grid.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, Mesh, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const COLS = 16; const ROWS = 16; @@ -44,7 +45,7 @@ const buildGrid = (): { vertices: Float32Array; uvs: Float32Array; indices: Uint return { vertices, uvs, indices }; }; -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; class MeshDeformedGridScene extends Scene { private restVertices!: Float32Array; diff --git a/examples/geometry-graphics/mesh-textured-quad.js b/examples/geometry-graphics/mesh-textured-quad.js index 30d0b2887..825c882e0 100644 --- a/examples/geometry-graphics/mesh-textured-quad.js +++ b/examples/geometry-graphics/mesh-textured-quad.js @@ -1,6 +1,7 @@ // Auto-generated from mesh-textured-quad.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Mesh, Scene, Text } from '@codexo/exojs'; -const UV_GRID = assets.technical.filtering.uvGrid256; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +const UV_GRID = assets.uvGrid256; const HALF = 160; class MeshVerticesScene extends Scene { triangle; diff --git a/examples/geometry-graphics/mesh-textured-quad.ts b/examples/geometry-graphics/mesh-textured-quad.ts index df8168aa6..fe7e2c2f7 100644 --- a/examples/geometry-graphics/mesh-textured-quad.ts +++ b/examples/geometry-graphics/mesh-textured-quad.ts @@ -1,6 +1,7 @@ import { Application, Color, FixedResolutionCanvasSizing, Mesh, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; const HALF = 160; class MeshVerticesScene extends Scene { diff --git a/examples/geometry-graphics/mesh-typed-uniforms.js b/examples/geometry-graphics/mesh-typed-uniforms.js index 806109983..9fdb828e4 100644 --- a/examples/geometry-graphics/mesh-typed-uniforms.js +++ b/examples/geometry-graphics/mesh-typed-uniforms.js @@ -11,7 +11,8 @@ import { UniformStruct, UniformType, } from '@codexo/exojs'; -const UV_GRID = assets.technical.filtering.uvGrid256; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +const UV_GRID = assets.uvGrid256; const SIZE = 420; // Uniforms declared here are numeric: `time`, `warp` and `uvTransform` reach // the shader exactly as written, with no colour conversion. Colour comes from diff --git a/examples/geometry-graphics/mesh-typed-uniforms.ts b/examples/geometry-graphics/mesh-typed-uniforms.ts index 1d385ae3a..5fa5ec575 100644 --- a/examples/geometry-graphics/mesh-typed-uniforms.ts +++ b/examples/geometry-graphics/mesh-typed-uniforms.ts @@ -12,8 +12,9 @@ import { UniformStruct, UniformType, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; -const UV_GRID = assets.technical.filtering.uvGrid256; +const UV_GRID = assets.uvGrid256; const SIZE = 420; // Uniforms declared here are numeric: `time`, `warp` and `uvTransform` reach diff --git a/examples/getting-started/game-loop.js b/examples/getting-started/game-loop.js index 69a053fc9..c77750d50 100644 --- a/examples/getting-started/game-loop.js +++ b/examples/getting-started/game-loop.js @@ -1,11 +1,12 @@ // Auto-generated from game-loop.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class GameLoopScene extends Scene { sprite; init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } @@ -25,8 +26,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(GameLoopScene); diff --git a/examples/getting-started/game-loop.ts b/examples/getting-started/game-loop.ts index 7db0f84e6..a1605446f 100644 --- a/examples/getting-started/game-loop.ts +++ b/examples/getting-started/game-loop.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class GameLoopScene extends Scene { private sprite!: Sprite; @@ -7,7 +8,7 @@ class GameLoopScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setPosition(width / 2, height / 2); } @@ -30,9 +31,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(GameLoopScene); diff --git a/examples/getting-started/hello-world.js b/examples/getting-started/hello-world.js index cddff32b7..cf6cf6d41 100644 --- a/examples/getting-started/hello-world.js +++ b/examples/getting-started/hello-world.js @@ -1,5 +1,6 @@ // Auto-generated from hello-world.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // #region guide:first-scene class HelloWorldScene extends Scene { sprite; @@ -8,7 +9,7 @@ class HelloWorldScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setScale(1.5); this.sprite.setPosition(width / 2, height / 2); @@ -39,8 +40,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(12, 16, 30), - loader: { - basePath: 'assets/', - }, }); await app.start(HelloWorldScene); diff --git a/examples/getting-started/hello-world.ts b/examples/getting-started/hello-world.ts index 6a0e31b2f..07f978068 100644 --- a/examples/getting-started/hello-world.ts +++ b/examples/getting-started/hello-world.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, Scene, type Seconds, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // #region guide:first-scene class HelloWorldScene extends Scene { @@ -10,7 +11,7 @@ class HelloWorldScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.sprite.setScale(1.5); this.sprite.setPosition(width / 2, height / 2); @@ -45,9 +46,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(12, 16, 30), - loader: { - basePath: 'assets/', - }, }); await app.start(HelloWorldScene); diff --git a/examples/getting-started/resize-and-dpr.js b/examples/getting-started/resize-and-dpr.js index 43e0da23b..9cd0c1595 100644 --- a/examples/getting-started/resize-and-dpr.js +++ b/examples/getting-started/resize-and-dpr.js @@ -1,10 +1,11 @@ // Auto-generated from resize-and-dpr.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ResizeScene extends Scene { sprite; info; init() { - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.info = new Text('', { fillColor: Color.white, fontSize: 16 }); this.info.setAnchor(0.5, 0); @@ -36,9 +37,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); // #endregion guide:app-setup document.body.style.margin = '0'; diff --git a/examples/getting-started/resize-and-dpr.ts b/examples/getting-started/resize-and-dpr.ts index 2c0e1a6ff..a02b9cad0 100644 --- a/examples/getting-started/resize-and-dpr.ts +++ b/examples/getting-started/resize-and-dpr.ts @@ -1,11 +1,12 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ResizeScene extends Scene { private sprite!: Sprite; private info!: Text; override init(): void { - this.sprite = new Sprite(this.loader.get('image/ship-a.png')); + this.sprite = new Sprite(this.loader.get(assets.ship)); this.sprite.setAnchor(0.5); this.info = new Text('', { fillColor: Color.white, fontSize: 16 }); @@ -45,9 +46,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); // #endregion guide:app-setup diff --git a/examples/guides/audio-effects/compressor-meter.ts b/examples/guides/audio-effects/compressor-meter.ts index 0eb3ab2f0..4d224cb01 100644 --- a/examples/guides/audio-effects/compressor-meter.ts +++ b/examples/guides/audio-effects/compressor-meter.ts @@ -1,5 +1,5 @@ import { Graphics, Scene, type Seconds } from '@codexo/exojs'; -import { CompressorEffect } from '@codexo/exojs-audio-fx'; +import { CompressorEffect } from '@codexo/exojs/audio-fx'; class MeterScene extends Scene { private compressor = new CompressorEffect(); diff --git a/examples/guides/audio-reactive-scene/beat-hooks.ts b/examples/guides/audio-reactive-scene/beat-hooks.ts index 0f335e878..5746e50ac 100644 --- a/examples/guides/audio-reactive-scene/beat-hooks.ts +++ b/examples/guides/audio-reactive-scene/beat-hooks.ts @@ -1,6 +1,6 @@ import { Color, Ease, Scene, type Seconds, Sprite, View } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector, type BeatInfo } from '@codexo/exojs-audio-fx'; -import { BurstSpawn, Constant } from '@codexo/exojs-particles'; +import { AudioAnalyser, BeatDetector, type BeatInfo } from '@codexo/exojs/audio-fx'; +import { BurstSpawn, Constant } from '@codexo/exojs/particles'; class BeatHookScene extends Scene { private analyser = new AudioAnalyser({ fftSize: 512 }); diff --git a/examples/guides/audio-reactive-scene/reactive-scene.ts b/examples/guides/audio-reactive-scene/reactive-scene.ts index 3925bd812..e5322cc9f 100644 --- a/examples/guides/audio-reactive-scene/reactive-scene.ts +++ b/examples/guides/audio-reactive-scene/reactive-scene.ts @@ -10,8 +10,8 @@ import { Vector, View, } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; -import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; +import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; // #region guide:reactive-scene const app = new Application({ extensions: [particlesExtension] }); diff --git a/examples/guides/audio-reactive-visualization/beat-reactive.ts b/examples/guides/audio-reactive-visualization/beat-reactive.ts index cdb5a2949..a002e8406 100644 --- a/examples/guides/audio-reactive-visualization/beat-reactive.ts +++ b/examples/guides/audio-reactive-visualization/beat-reactive.ts @@ -1,5 +1,5 @@ import { Scene } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; // #region guide:beat-reactive class BeatReactiveScene extends Scene { diff --git a/examples/guides/audio-reactive-visualization/beat-visuals.ts b/examples/guides/audio-reactive-visualization/beat-visuals.ts index 7ed736c15..5e54c823e 100644 --- a/examples/guides/audio-reactive-visualization/beat-visuals.ts +++ b/examples/guides/audio-reactive-visualization/beat-visuals.ts @@ -1,5 +1,5 @@ import { Color, Scene, Sprite } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; class BeatVisualScene extends Scene { private detector = new BeatDetector(); diff --git a/examples/guides/audio-reactive-visualization/full-visualizer.ts b/examples/guides/audio-reactive-visualization/full-visualizer.ts index 3f8d6d45c..8e9a24a66 100644 --- a/examples/guides/audio-reactive-visualization/full-visualizer.ts +++ b/examples/guides/audio-reactive-visualization/full-visualizer.ts @@ -1,6 +1,6 @@ import type { RenderingContext, Seconds } from '@codexo/exojs'; import { Application, Asset, type AudioStream, Color, Graphics, Scene } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; // #region guide:full-visualizer diff --git a/examples/guides/audio-reactive-visualization/spectrogram.ts b/examples/guides/audio-reactive-visualization/spectrogram.ts index 1d3d06837..a7f0c7040 100644 --- a/examples/guides/audio-reactive-visualization/spectrogram.ts +++ b/examples/guides/audio-reactive-visualization/spectrogram.ts @@ -1,5 +1,5 @@ import { DataTexture, Scene, type Seconds, Sprite, TextureFormat } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; class SpectrogramScene extends Scene { private analyser!: AudioAnalyser; diff --git a/examples/guides/audio-reactive-visualization/spectrum-bands.ts b/examples/guides/audio-reactive-visualization/spectrum-bands.ts index b73fea7b3..fd5f49b70 100644 --- a/examples/guides/audio-reactive-visualization/spectrum-bands.ts +++ b/examples/guides/audio-reactive-visualization/spectrum-bands.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; class BandScene extends Scene { private analyser = new AudioAnalyser({ fftSize: 1024 }); diff --git a/examples/guides/audio-reactive-visualization/spectrum-scene.ts b/examples/guides/audio-reactive-visualization/spectrum-scene.ts index 61de56f01..3998bb929 100644 --- a/examples/guides/audio-reactive-visualization/spectrum-scene.ts +++ b/examples/guides/audio-reactive-visualization/spectrum-scene.ts @@ -1,6 +1,6 @@ // #region guide:spectrum-scene import { Color, Graphics, Scene } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; export class SpectrumScene extends Scene { private analyser!: AudioAnalyser; diff --git a/examples/guides/audio-reactive-visualization/visualizer-scene.ts b/examples/guides/audio-reactive-visualization/visualizer-scene.ts index e026d1c98..432c955b8 100644 --- a/examples/guides/audio-reactive-visualization/visualizer-scene.ts +++ b/examples/guides/audio-reactive-visualization/visualizer-scene.ts @@ -1,6 +1,6 @@ import type { Seconds } from '@codexo/exojs'; import { Asset, type AudioStream, Scene } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; // #region guide:visualizer-scene class VisualizerScene extends Scene { diff --git a/examples/guides/beat-detection/detector-scene.ts b/examples/guides/beat-detection/detector-scene.ts index b874c6be2..29492d094 100644 --- a/examples/guides/beat-detection/detector-scene.ts +++ b/examples/guides/beat-detection/detector-scene.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds } from '@codexo/exojs'; -import { BeatDetector } from '@codexo/exojs-audio-fx'; +import { BeatDetector } from '@codexo/exojs/audio-fx'; class DetectorScene extends Scene { private detector = new BeatDetector(); diff --git a/examples/guides/character-movement/character-movement.ts b/examples/guides/character-movement/character-movement.ts index 6daad5adf..b4e112c3f 100644 --- a/examples/guides/character-movement/character-movement.ts +++ b/examples/guides/character-movement/character-movement.ts @@ -9,7 +9,7 @@ import { PhysicsBody, PhysicsBodyComponent, type PhysicsWorld, -} from '@codexo/exojs-physics'; +} from '@codexo/exojs/physics'; declare const world: PhysicsWorld; declare const scene: Scene; diff --git a/examples/guides/game-feel/screen-shake.ts b/examples/guides/game-feel/screen-shake.ts index 0d4e7c3f5..be9f3239c 100644 --- a/examples/guides/game-feel/screen-shake.ts +++ b/examples/guides/game-feel/screen-shake.ts @@ -1,5 +1,5 @@ import { Scene, Time, Vector, View } from '@codexo/exojs'; -import { BurstSpawn, Constant, type ParticleSystem } from '@codexo/exojs-particles'; +import { BurstSpawn, Constant, type ParticleSystem } from '@codexo/exojs/particles'; class ShakeScene extends Scene { private view = new View(0, 0, 800, 600); diff --git a/examples/guides/game-feel/thrust.ts b/examples/guides/game-feel/thrust.ts index 80f127cc5..22b502144 100644 --- a/examples/guides/game-feel/thrust.ts +++ b/examples/guides/game-feel/thrust.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds, Sprite, type Voice } from '@codexo/exojs'; -import { Constant, type ParticleSystem } from '@codexo/exojs-particles'; +import { Constant, type ParticleSystem } from '@codexo/exojs/particles'; class ThrustScene extends Scene { private thrust = { x: 0, y: 0 }; diff --git a/examples/guides/infinite-maps/chunk-streaming.ts b/examples/guides/infinite-maps/chunk-streaming.ts index 4650b71c0..229846975 100644 --- a/examples/guides/infinite-maps/chunk-streaming.ts +++ b/examples/guides/infinite-maps/chunk-streaming.ts @@ -1,5 +1,5 @@ import { View } from '@codexo/exojs'; -import { ChunkStreamer, createSampledChunkSource, TileLayer } from '@codexo/exojs-tilemap'; +import { ChunkStreamer, createSampledChunkSource, TileLayer } from '@codexo/exojs/tilemap'; // #region guide:chunk-streaming const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [] }); diff --git a/examples/guides/joints-and-dynamics/bullet-bodies.ts b/examples/guides/joints-and-dynamics/bullet-bodies.ts index c60f9a480..0caf803a4 100644 --- a/examples/guides/joints-and-dynamics/bullet-bodies.ts +++ b/examples/guides/joints-and-dynamics/bullet-bodies.ts @@ -1,4 +1,4 @@ -import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:bullet-bodies const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); diff --git a/examples/guides/joints-and-dynamics/contact-modifier.ts b/examples/guides/joints-and-dynamics/contact-modifier.ts index ce631f7bd..82186d3b6 100644 --- a/examples/guides/joints-and-dynamics/contact-modifier.ts +++ b/examples/guides/joints-and-dynamics/contact-modifier.ts @@ -1,4 +1,4 @@ -import { PhysicsWorld } from '@codexo/exojs-physics'; +import { PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:contact-modifier const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/distance-joint.ts b/examples/guides/joints-and-dynamics/distance-joint.ts index b3d618091..739684e95 100644 --- a/examples/guides/joints-and-dynamics/distance-joint.ts +++ b/examples/guides/joints-and-dynamics/distance-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:distance-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/distance-limits.ts b/examples/guides/joints-and-dynamics/distance-limits.ts index 5722078e7..19dae8b88 100644 --- a/examples/guides/joints-and-dynamics/distance-limits.ts +++ b/examples/guides/joints-and-dynamics/distance-limits.ts @@ -1,4 +1,4 @@ -import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:distance-limits const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/joint-lifecycle.ts b/examples/guides/joints-and-dynamics/joint-lifecycle.ts index 18653e04a..7abb13518 100644 --- a/examples/guides/joints-and-dynamics/joint-lifecycle.ts +++ b/examples/guides/joints-and-dynamics/joint-lifecycle.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs/physics'; const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); const bodyA = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); diff --git a/examples/guides/joints-and-dynamics/mouse-joint.ts b/examples/guides/joints-and-dynamics/mouse-joint.ts index 96e2d90f2..a58fc5357 100644 --- a/examples/guides/joints-and-dynamics/mouse-joint.ts +++ b/examples/guides/joints-and-dynamics/mouse-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, MouseJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, MouseJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:mouse-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/one-way-platform.ts b/examples/guides/joints-and-dynamics/one-way-platform.ts index c0c80e518..74ad6eca4 100644 --- a/examples/guides/joints-and-dynamics/one-way-platform.ts +++ b/examples/guides/joints-and-dynamics/one-way-platform.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:one-way-platform const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/prismatic-joint.ts b/examples/guides/joints-and-dynamics/prismatic-joint.ts index 80986b362..7f060cad7 100644 --- a/examples/guides/joints-and-dynamics/prismatic-joint.ts +++ b/examples/guides/joints-and-dynamics/prismatic-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld, PrismaticJoint } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld, PrismaticJoint } from '@codexo/exojs/physics'; // #region guide:prismatic-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/revolute-joint.ts b/examples/guides/joints-and-dynamics/revolute-joint.ts index 83bdd1860..6f5a98ae5 100644 --- a/examples/guides/joints-and-dynamics/revolute-joint.ts +++ b/examples/guides/joints-and-dynamics/revolute-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs/physics'; // #region guide:revolute-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/sleeping.ts b/examples/guides/joints-and-dynamics/sleeping.ts index b9e0ee11c..66390bbed 100644 --- a/examples/guides/joints-and-dynamics/sleeping.ts +++ b/examples/guides/joints-and-dynamics/sleeping.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:sleeping const world = new PhysicsWorld({ diff --git a/examples/guides/joints-and-dynamics/weld-joint.ts b/examples/guides/joints-and-dynamics/weld-joint.ts index c983c45e0..ffac77d7a 100644 --- a/examples/guides/joints-and-dynamics/weld-joint.ts +++ b/examples/guides/joints-and-dynamics/weld-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld, WeldJoint } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld, WeldJoint } from '@codexo/exojs/physics'; // #region guide:weld-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/joints-and-dynamics/wheel-joint.ts b/examples/guides/joints-and-dynamics/wheel-joint.ts index 9f756a2c5..83c75c297 100644 --- a/examples/guides/joints-and-dynamics/wheel-joint.ts +++ b/examples/guides/joints-and-dynamics/wheel-joint.ts @@ -1,4 +1,4 @@ -import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, WheelJoint } from '@codexo/exojs-physics'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, WheelJoint } from '@codexo/exojs/physics'; // #region guide:wheel-joint const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/lighting/basic-lightmap.ts b/examples/guides/lighting/basic-lightmap.ts index 77072f31e..a03da0c86 100644 --- a/examples/guides/lighting/basic-lightmap.ts +++ b/examples/guides/lighting/basic-lightmap.ts @@ -1,6 +1,6 @@ // #region guide:basic-lightmap import { Application, Color, Graphics, Scene } from '@codexo/exojs'; -import { LightmapLighting, PointLight } from '@codexo/exojs-lighting'; +import { LightmapLighting, PointLight } from '@codexo/exojs/lighting'; class LightingScene extends Scene { override init(): void { diff --git a/examples/guides/particles/basic-emitter.ts b/examples/guides/particles/basic-emitter.ts index d4299ee08..6371a8e9e 100644 --- a/examples/guides/particles/basic-emitter.ts +++ b/examples/guides/particles/basic-emitter.ts @@ -9,7 +9,7 @@ import { ParticleSystem, RateSpawn, ScaleOverLifetime, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; class FountainScene extends Scene { private particles!: ParticleSystem; diff --git a/examples/guides/particles/death-module.ts b/examples/guides/particles/death-module.ts index 1df3b57da..b0e1e5918 100644 --- a/examples/guides/particles/death-module.ts +++ b/examples/guides/particles/death-module.ts @@ -1,4 +1,4 @@ -import { DeathModule, type ParticleDeathContext, type ParticleSystem } from '@codexo/exojs-particles'; +import { DeathModule, type ParticleDeathContext, type ParticleSystem } from '@codexo/exojs/particles'; // #region guide:splash-on-death class SplashOnDeath extends DeathModule { diff --git a/examples/guides/particles/particle-scene.ts b/examples/guides/particles/particle-scene.ts index a24ce3267..e8628f906 100644 --- a/examples/guides/particles/particle-scene.ts +++ b/examples/guides/particles/particle-scene.ts @@ -1,5 +1,5 @@ import { type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; -import { type ParticleSystem } from '@codexo/exojs-particles'; +import { type ParticleSystem } from '@codexo/exojs/particles'; // #region guide:particle-scene class ParticleScene extends Scene { diff --git a/examples/guides/particles/spawn-on-death.ts b/examples/guides/particles/spawn-on-death.ts index 106d475d8..b3a938051 100644 --- a/examples/guides/particles/spawn-on-death.ts +++ b/examples/guides/particles/spawn-on-death.ts @@ -1,4 +1,4 @@ -import { type ParticleSystem, type SpawnModule, SpawnOnDeath } from '@codexo/exojs-particles'; +import { type ParticleSystem, type SpawnModule, SpawnOnDeath } from '@codexo/exojs/particles'; declare const system: ParticleSystem; declare const childSystem: ParticleSystem; diff --git a/examples/guides/particles/update-module.ts b/examples/guides/particles/update-module.ts index d6f4df341..826d60ee6 100644 --- a/examples/guides/particles/update-module.ts +++ b/examples/guides/particles/update-module.ts @@ -1,4 +1,4 @@ -import { type ParticleBatch, UpdateModule } from '@codexo/exojs-particles'; +import { type ParticleBatch, UpdateModule } from '@codexo/exojs/particles'; // #region guide:sway-module class Sway extends UpdateModule { diff --git a/examples/guides/pathfinding/custom-space.ts b/examples/guides/pathfinding/custom-space.ts index 87991ff1f..e7249750c 100644 --- a/examples/guides/pathfinding/custom-space.ts +++ b/examples/guides/pathfinding/custom-space.ts @@ -1,5 +1,5 @@ import type { Vector } from '@codexo/exojs'; -import type { NavigationSpace } from '@codexo/exojs-pathfinding'; +import type { NavigationSpace } from '@codexo/exojs/pathfinding'; declare const rooms: ReadonlyArray<{ x: number; y: number; exits: readonly number[]; travelTime: number }>; diff --git a/examples/guides/pathfinding/grid-setup.ts b/examples/guides/pathfinding/grid-setup.ts index 5bc1305e2..1acef0be3 100644 --- a/examples/guides/pathfinding/grid-setup.ts +++ b/examples/guides/pathfinding/grid-setup.ts @@ -1,4 +1,4 @@ -import { GridSpace } from '@codexo/exojs-pathfinding'; +import { GridSpace } from '@codexo/exojs/pathfinding'; declare const isWall: (x: number, y: number) => boolean; declare const isMud: (x: number, y: number) => boolean; diff --git a/examples/guides/pathfinding/navigation-agents.ts b/examples/guides/pathfinding/navigation-agents.ts index 4066698c4..b39eabac2 100644 --- a/examples/guides/pathfinding/navigation-agents.ts +++ b/examples/guides/pathfinding/navigation-agents.ts @@ -1,7 +1,7 @@ import { BehaviorComponent, type Container, type PointLike, type Seconds, type VectorAction } from '@codexo/exojs'; -import { NavigationAgentComponent, type NavigationMovement, TileNavigationSpace, WaypointGraph } from '@codexo/exojs-pathfinding'; -import { CircleShape, KinematicMovementComponent, PhysicsBodyComponent, type PhysicsWorld } from '@codexo/exojs-physics'; -import type { ResolvedTile, TileLayer } from '@codexo/exojs-tilemap'; +import { NavigationAgentComponent, type NavigationMovement, TileNavigationSpace, WaypointGraph } from '@codexo/exojs/pathfinding'; +import { CircleShape, KinematicMovementComponent, PhysicsBodyComponent, type PhysicsWorld } from '@codexo/exojs/physics'; +import type { ResolvedTile, TileLayer } from '@codexo/exojs/tilemap'; declare const world: PhysicsWorld; declare const ground: TileLayer; diff --git a/examples/guides/pathfinding/queries.ts b/examples/guides/pathfinding/queries.ts index 98cbb9efc..c2e1dfb90 100644 --- a/examples/guides/pathfinding/queries.ts +++ b/examples/guides/pathfinding/queries.ts @@ -1,5 +1,5 @@ import type { Vector } from '@codexo/exojs'; -import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; +import { GridSpace, Pathfinder } from '@codexo/exojs/pathfinding'; declare const hero: { x: number; y: number; follow: (points: readonly Vector[]) => void }; declare const target: { x: number; y: number }; diff --git a/examples/guides/pathfinding/reachable-area.ts b/examples/guides/pathfinding/reachable-area.ts index 1e8a5b960..633ec56a6 100644 --- a/examples/guides/pathfinding/reachable-area.ts +++ b/examples/guides/pathfinding/reachable-area.ts @@ -1,4 +1,4 @@ -import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; +import { GridSpace, Pathfinder } from '@codexo/exojs/pathfinding'; declare const unit: { tileX: number; tileY: number; movement: number }; declare const highlight: (x: number, y: number, cost: number) => void; diff --git a/examples/guides/pathfinding/tilemap-bridge.ts b/examples/guides/pathfinding/tilemap-bridge.ts index 2ff27976b..d330eb62c 100644 --- a/examples/guides/pathfinding/tilemap-bridge.ts +++ b/examples/guides/pathfinding/tilemap-bridge.ts @@ -1,5 +1,5 @@ -import { GridSpace } from '@codexo/exojs-pathfinding'; -import type { ResolvedTile, TileLayer } from '@codexo/exojs-tilemap'; +import { GridSpace } from '@codexo/exojs/pathfinding'; +import type { ResolvedTile, TileLayer } from '@codexo/exojs/tilemap'; declare const ground: TileLayer; declare const loaded: { x: number; y: number; width: number; height: number }; diff --git a/examples/guides/pathfinding/waypoint-graph.ts b/examples/guides/pathfinding/waypoint-graph.ts index b366d13ee..186d6ce49 100644 --- a/examples/guides/pathfinding/waypoint-graph.ts +++ b/examples/guides/pathfinding/waypoint-graph.ts @@ -1,4 +1,4 @@ -import { Pathfinder, WaypointGraph } from '@codexo/exojs-pathfinding'; +import { Pathfinder, WaypointGraph } from '@codexo/exojs/pathfinding'; declare const controller: { walkTo: (x: number, y: number) => void; jump: (impulse: number) => void }; diff --git a/examples/guides/physics-basics/binding-options.ts b/examples/guides/physics-basics/binding-options.ts index 73294b977..5eb6e964b 100644 --- a/examples/guides/physics-basics/binding-options.ts +++ b/examples/guides/physics-basics/binding-options.ts @@ -1,5 +1,5 @@ import { Sprite } from '@codexo/exojs'; -import { CircleShape, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:binding-options const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/body-component.ts b/examples/guides/physics-basics/body-component.ts index 4ab00a33d..3d4a2e383 100644 --- a/examples/guides/physics-basics/body-component.ts +++ b/examples/guides/physics-basics/body-component.ts @@ -1,5 +1,5 @@ import { Color, Graphics, Scene, SystemOrder } from '@codexo/exojs'; -import { BoxShape, CircleShape, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, CircleShape, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:body-component class ArenaScene extends Scene { diff --git a/examples/guides/physics-basics/box-body.ts b/examples/guides/physics-basics/box-body.ts index e43495cda..812d9e9e3 100644 --- a/examples/guides/physics-basics/box-body.ts +++ b/examples/guides/physics-basics/box-body.ts @@ -1,4 +1,4 @@ -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:box-body const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/capsule-and-chain.ts b/examples/guides/physics-basics/capsule-and-chain.ts index db85a8bf3..d0e2802da 100644 --- a/examples/guides/physics-basics/capsule-and-chain.ts +++ b/examples/guides/physics-basics/capsule-and-chain.ts @@ -1,4 +1,4 @@ -import { CapsuleShape, ChainShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { CapsuleShape, ChainShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:capsule-and-chain const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/circle-body.ts b/examples/guides/physics-basics/circle-body.ts index 39260b3bf..602e13007 100644 --- a/examples/guides/physics-basics/circle-body.ts +++ b/examples/guides/physics-basics/circle-body.ts @@ -1,4 +1,4 @@ -import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:circle-body const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/drop-scene.ts b/examples/guides/physics-basics/drop-scene.ts index 4c03d2498..db0299a11 100644 --- a/examples/guides/physics-basics/drop-scene.ts +++ b/examples/guides/physics-basics/drop-scene.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds, Sprite } from '@codexo/exojs'; -import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:drop-scene class DropScene extends Scene { diff --git a/examples/guides/physics-basics/falling-box.ts b/examples/guides/physics-basics/falling-box.ts index d0591cfa0..b996cabd2 100644 --- a/examples/guides/physics-basics/falling-box.ts +++ b/examples/guides/physics-basics/falling-box.ts @@ -1,6 +1,6 @@ // #region guide:falling-box import { Application, Color, Graphics, Scene, SystemOrder } from '@codexo/exojs'; -import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsWorld } from '@codexo/exojs/physics'; class FallingBoxScene extends Scene { override init(): void { diff --git a/examples/guides/physics-basics/manual-sync.ts b/examples/guides/physics-basics/manual-sync.ts index 1be659198..be2d5e823 100644 --- a/examples/guides/physics-basics/manual-sync.ts +++ b/examples/guides/physics-basics/manual-sync.ts @@ -1,5 +1,5 @@ import { radiansToDegrees, Sprite, Texture } from '@codexo/exojs'; -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 100, y: 0 }, colliders: [{ shape: new BoxShape(32, 32) }] })); diff --git a/examples/guides/physics-basics/physics-scene.ts b/examples/guides/physics-basics/physics-scene.ts index 81548f310..ca37dcb74 100644 --- a/examples/guides/physics-basics/physics-scene.ts +++ b/examples/guides/physics-basics/physics-scene.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds } from '@codexo/exojs'; -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:physics-scene class GameScene extends Scene { diff --git a/examples/guides/physics-basics/solver-tuning.ts b/examples/guides/physics-basics/solver-tuning.ts index ab1ffdf38..a25e12edf 100644 --- a/examples/guides/physics-basics/solver-tuning.ts +++ b/examples/guides/physics-basics/solver-tuning.ts @@ -1,4 +1,4 @@ -import { PhysicsWorld } from '@codexo/exojs-physics'; +import { PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:solver-tuning const world = new PhysicsWorld({ diff --git a/examples/guides/physics-basics/sprite-binding.ts b/examples/guides/physics-basics/sprite-binding.ts index 7c36b8776..72f26d03f 100644 --- a/examples/guides/physics-basics/sprite-binding.ts +++ b/examples/guides/physics-basics/sprite-binding.ts @@ -1,5 +1,5 @@ import { Sprite } from '@codexo/exojs'; -import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:sprite-binding const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/variable-step.ts b/examples/guides/physics-basics/variable-step.ts index 4e7e1cd4a..9caebee5d 100644 --- a/examples/guides/physics-basics/variable-step.ts +++ b/examples/guides/physics-basics/variable-step.ts @@ -1,5 +1,5 @@ import { Scene, type Seconds } from '@codexo/exojs'; -import { PhysicsWorld } from '@codexo/exojs-physics'; +import { PhysicsWorld } from '@codexo/exojs/physics'; class VariableStepScene extends Scene { private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/physics-basics/world-setup.ts b/examples/guides/physics-basics/world-setup.ts index 3a57d90dc..d1cc23b29 100644 --- a/examples/guides/physics-basics/world-setup.ts +++ b/examples/guides/physics-basics/world-setup.ts @@ -1,4 +1,4 @@ -import { PhysicsWorld } from '@codexo/exojs-physics'; +import { PhysicsWorld } from '@codexo/exojs/physics'; // #region guide:world-setup const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); diff --git a/examples/guides/scenes-and-lifecycle/frame-alpha.ts b/examples/guides/scenes-and-lifecycle/frame-alpha.ts index 5f68b5a84..d75a5301e 100644 --- a/examples/guides/scenes-and-lifecycle/frame-alpha.ts +++ b/examples/guides/scenes-and-lifecycle/frame-alpha.ts @@ -1,5 +1,5 @@ import { Color, Graphics, Scene, SystemOrder } from '@codexo/exojs'; -import { CircleShape, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsWorld } from '@codexo/exojs/physics'; class GameScene extends Scene { // #region guide:frame-alpha diff --git a/examples/guides/scenes-and-lifecycle/game-scene.ts b/examples/guides/scenes-and-lifecycle/game-scene.ts index 042ad2bd7..3f356b473 100644 --- a/examples/guides/scenes-and-lifecycle/game-scene.ts +++ b/examples/guides/scenes-and-lifecycle/game-scene.ts @@ -1,5 +1,5 @@ import { Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Text } from '@codexo/exojs'; -import { PhysicsWorld } from '@codexo/exojs-physics'; +import { PhysicsWorld } from '@codexo/exojs/physics'; interface Player { jump(): void; diff --git a/examples/guides/tiled-maps/wang-sets.ts b/examples/guides/tiled-maps/wang-sets.ts index 3c2cce6e3..85b8ccefd 100644 --- a/examples/guides/tiled-maps/wang-sets.ts +++ b/examples/guides/tiled-maps/wang-sets.ts @@ -1,6 +1,6 @@ import { Asset, Scene } from '@codexo/exojs'; +import { autoTile, refreshCell } from '@codexo/exojs/tilemap'; import { tiledWangSetToWangSet } from '@codexo/exojs-tiled'; -import { autoTile, refreshCell } from '@codexo/exojs-tilemap'; class WangSetScene extends Scene { public override async load(): Promise { diff --git a/examples/guides/worlds-and-spawning/spawn-session.ts b/examples/guides/worlds-and-spawning/spawn-session.ts index 75d8efb35..d22df60ac 100644 --- a/examples/guides/worlds-and-spawning/spawn-session.ts +++ b/examples/guides/worlds-and-spawning/spawn-session.ts @@ -1,5 +1,5 @@ import type { Container } from '@codexo/exojs'; -import type { MapSpawnSession } from '@codexo/exojs-tilemap'; +import type { MapSpawnSession } from '@codexo/exojs/tilemap'; // #region guide:spawn-session export const restore = (session: MapSpawnSession, saved: Map): void => { diff --git a/examples/guides/worlds-and-spawning/spawner-identify.ts b/examples/guides/worlds-and-spawning/spawner-identify.ts index 5e0bab37e..2e6be0f22 100644 --- a/examples/guides/worlds-and-spawning/spawner-identify.ts +++ b/examples/guides/worlds-and-spawning/spawner-identify.ts @@ -1,5 +1,5 @@ import { Container } from '@codexo/exojs'; -import { MapObjectSpawner } from '@codexo/exojs-tilemap'; +import { MapObjectSpawner } from '@codexo/exojs/tilemap'; // #region guide:custom-identify class Enemy extends Container {} diff --git a/examples/guides/worlds-and-spawning/spawner-strict.ts b/examples/guides/worlds-and-spawning/spawner-strict.ts index b04375744..98b11bf3f 100644 --- a/examples/guides/worlds-and-spawning/spawner-strict.ts +++ b/examples/guides/worlds-and-spawning/spawner-strict.ts @@ -1,5 +1,5 @@ import { Container } from '@codexo/exojs'; -import { MapObjectSpawner } from '@codexo/exojs-tilemap'; +import { MapObjectSpawner } from '@codexo/exojs/tilemap'; // #region guide:unknown-error class Enemy extends Container {} diff --git a/examples/guides/worlds-and-spawning/spawner.ts b/examples/guides/worlds-and-spawning/spawner.ts index 5ba72d0df..efcfccf34 100644 --- a/examples/guides/worlds-and-spawning/spawner.ts +++ b/examples/guides/worlds-and-spawning/spawner.ts @@ -1,5 +1,5 @@ import { Container } from '@codexo/exojs'; -import { MapObjectSpawner } from '@codexo/exojs-tilemap'; +import { MapObjectSpawner } from '@codexo/exojs/tilemap'; // #region guide:object-spawner class Enemy extends Container {} diff --git a/examples/guides/worlds-and-spawning/streaming.ts b/examples/guides/worlds-and-spawning/streaming.ts index dd76cd5c4..1b0144b2f 100644 --- a/examples/guides/worlds-and-spawning/streaming.ts +++ b/examples/guides/worlds-and-spawning/streaming.ts @@ -1,4 +1,4 @@ -import type { MapBounds, MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; +import type { MapBounds, MapWorld, MapWorldRuntime } from '@codexo/exojs/tilemap'; // #region guide:level-streaming export const updateStreaming = (runtime: MapWorldRuntime, world: MapWorld, cameraBounds: MapBounds): void => { diff --git a/examples/guides/worlds-and-spawning/tiled-spawn.ts b/examples/guides/worlds-and-spawning/tiled-spawn.ts index 1b379e46b..5d4aa4d30 100644 --- a/examples/guides/worlds-and-spawning/tiled-spawn.ts +++ b/examples/guides/worlds-and-spawning/tiled-spawn.ts @@ -1,5 +1,5 @@ import { Asset, type Container, type LoaderScope } from '@codexo/exojs'; -import type { MapObjectSpawner } from '@codexo/exojs-tilemap'; +import type { MapObjectSpawner } from '@codexo/exojs/tilemap'; // #region guide:tiled-spawn export const spawnTiledLevel = async (scope: LoaderScope, spawner: MapObjectSpawner): Promise => { diff --git a/examples/guides/worlds-and-spawning/tiled-world.ts b/examples/guides/worlds-and-spawning/tiled-world.ts index 7bbc1f6a6..b6090e373 100644 --- a/examples/guides/worlds-and-spawning/tiled-world.ts +++ b/examples/guides/worlds-and-spawning/tiled-world.ts @@ -1,5 +1,5 @@ import { Asset, type LoaderScope } from '@codexo/exojs'; -import { MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; +import { MapWorld, MapWorldRuntime } from '@codexo/exojs/tilemap'; // #region guide:tiled-world export const createTiledWorld = (scope: LoaderScope): MapWorldRuntime => { diff --git a/examples/input/action-mapping.js b/examples/input/action-mapping.js index ebb88e82b..b08e4d30a 100644 --- a/examples/input/action-mapping.js +++ b/examples/input/action-mapping.js @@ -12,6 +12,7 @@ import { Sprite, VectorAction, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const PULSE_COLOR = new Color(255, 190, 90); class ActionMappingScene extends Scene { @@ -29,20 +30,13 @@ class ActionMappingScene extends Scene { pulse: new ButtonAction([Keyboard.Space, GamepadButton.South]), }); pulseRemaining = 0; + direction = 'none'; hud; init() { 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); this.inputs.attach(this.actions); - this.hud = mountControls({ - title: 'Input Actions', - controls: [ - { keys: 'WASD / Arrows / Left stick', action: 'move' }, - { keys: 'Space / South button', action: 'pulse' }, - ], - status: '', - hint: 'Keyboard and controller feed the same move and pulse actions.', - }); + this.hud = mountControls(); } update(delta) { const { x, y } = this.actions.move.value; @@ -51,8 +45,16 @@ class ActionMappingScene extends Scene { 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)), ); + // Logged on transitions only - the Output panel shows which binding the + // action map resolved, whether keyboard or gamepad produced it. + const direction = [y < -0.5 && 'up', y > 0.5 && 'down', x < -0.5 && 'left', x > 0.5 && 'right'].filter(Boolean).join('-') || 'none'; + if (direction !== this.direction) { + this.direction = direction; + console.log(`Action: move ${direction}`); + } if (this.actions.pulse.pressed) { this.pulseRemaining = 0.35; + console.info('Action: pulse'); } this.pulseRemaining = Math.max(0, this.pulseRemaining - delta); this.sprite.setTint(this.pulseRemaining > 0 ? PULSE_COLOR : Color.white); @@ -75,8 +77,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(ActionMappingScene); diff --git a/examples/input/action-mapping.ts b/examples/input/action-mapping.ts index 82e003bbc..692693625 100644 --- a/examples/input/action-mapping.ts +++ b/examples/input/action-mapping.ts @@ -13,6 +13,7 @@ import { Sprite, VectorAction, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const PULSE_COLOR = new Color(255, 190, 90); @@ -32,22 +33,15 @@ class ActionMappingScene extends Scene { pulse: new ButtonAction([Keyboard.Space, GamepadButton.South]), }); private pulseRemaining = 0; + private direction = 'none'; private hud!: ReturnType; override init(): void { 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); this.inputs.attach(this.actions); - this.hud = mountControls({ - title: 'Input Actions', - controls: [ - { keys: 'WASD / Arrows / Left stick', action: 'move' }, - { keys: 'Space / South button', action: 'pulse' }, - ], - status: '', - hint: 'Keyboard and controller feed the same move and pulse actions.', - }); + this.hud = mountControls(); } override update(delta: Seconds): void { @@ -59,8 +53,18 @@ class ActionMappingScene extends Scene { Math.max(24, Math.min(height - 24, this.sprite.y + y * 260 * delta)), ); + // Logged on transitions only - the Output panel shows which binding the + // action map resolved, whether keyboard or gamepad produced it. + const direction = [y < -0.5 && 'up', y > 0.5 && 'down', x < -0.5 && 'left', x > 0.5 && 'right'].filter(Boolean).join('-') || 'none'; + + if (direction !== this.direction) { + this.direction = direction; + console.log(`Action: move ${direction}`); + } + if (this.actions.pulse.pressed) { this.pulseRemaining = 0.35; + console.info('Action: pulse'); } this.pulseRemaining = Math.max(0, this.pulseRemaining - delta); @@ -87,9 +91,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(ActionMappingScene); diff --git a/examples/input/gamepad.js b/examples/input/gamepad.js index b4a6f059e..3ec1029f3 100644 --- a/examples/input/gamepad.js +++ b/examples/input/gamepad.js @@ -1,6 +1,5 @@ // Auto-generated from gamepad.ts - edit the .ts source, not this file. 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 }, @@ -27,7 +26,6 @@ class GamepadScene extends Scene { 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) })); @@ -35,11 +33,6 @@ class GamepadScene extends Scene { 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 @@ -65,7 +58,6 @@ class GamepadScene extends Scene { for (const entry of [...this.axes, ...this.buttons]) { entry.binding.unbind(); } - this.hud?.dispose(); this.status?.destroy(); this.axisText?.destroy(); this.buttonText?.destroy(); diff --git a/examples/input/gamepad.ts b/examples/input/gamepad.ts index 741217491..c34db4baf 100644 --- a/examples/input/gamepad.ts +++ b/examples/input/gamepad.ts @@ -10,7 +10,6 @@ import { Scene, Text, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; const AXES = [ { name: 'Left X', channel: GamepadAxis.LeftStickX }, @@ -40,7 +39,6 @@ class GamepadScene extends Scene { private axisText!: Text; private buttonText!: Text; private graphics = new Graphics(); - private hud!: ReturnType; override init(): void { this.pad = this.app.input.getGamepad(0); @@ -49,11 +47,6 @@ class GamepadScene extends Scene { 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.', - }); } override update(): void { @@ -83,7 +76,6 @@ class GamepadScene extends Scene { entry.binding.unbind(); } - this.hud?.dispose(); this.status?.destroy(); this.axisText?.destroy(); this.buttonText?.destroy(); diff --git a/examples/input/key-rebinding.js b/examples/input/key-rebinding.js index 4e8ec43fa..d44c0c1c2 100644 --- a/examples/input/key-rebinding.js +++ b/examples/input/key-rebinding.js @@ -74,27 +74,14 @@ class KeyRebindingScene extends Scene { saveProfile(this.profile); this.refreshHud(); }); - this.hud = mountControls({ - title: 'Key Rebinding', - controls: this.hudControls(), - status: '', - hint: '', - }); + this.hud = mountControls(); this.refreshHud(); } jumpToken() { return this.controls.jump.channels.map(inputToken)[0]; } - hudControls() { - return [ - { keys: keyName(this.jumpToken()), action: 'jump' }, - { keys: 'J', action: 'rebind jump' }, - ]; - } refreshHud() { - this.hud.setControls(this.hudControls()); - this.hud.setStatus(`Jump key: ${keyName(this.jumpToken())} (saved)`); - this.hud.setHint(this.rebindRequested ? 'Press any key to assign jump…' : 'Binding restored from localStorage on reload.'); + this.hud.setStatus(this.rebindRequested ? 'Press any key to assign jump...' : `Jump: ${keyName(this.jumpToken())} (J to rebind)`); } update(delta) { // Arm on the RELEASE of J, so the J keydown itself is not captured as @@ -136,8 +123,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(KeyRebindingScene); diff --git a/examples/input/key-rebinding.ts b/examples/input/key-rebinding.ts index e78094310..80071fc25 100644 --- a/examples/input/key-rebinding.ts +++ b/examples/input/key-rebinding.ts @@ -90,12 +90,7 @@ class KeyRebindingScene extends Scene { this.refreshHud(); }); - this.hud = mountControls({ - title: 'Key Rebinding', - controls: this.hudControls(), - status: '', - hint: '', - }); + this.hud = mountControls(); this.refreshHud(); } @@ -104,17 +99,8 @@ class KeyRebindingScene extends Scene { return this.controls.jump.channels.map(inputToken)[0]; } - private hudControls(): Array<{ keys: string; action: string }> { - return [ - { keys: keyName(this.jumpToken()), action: 'jump' }, - { keys: 'J', action: 'rebind jump' }, - ]; - } - private refreshHud(): void { - this.hud.setControls(this.hudControls()); - this.hud.setStatus(`Jump key: ${keyName(this.jumpToken())} (saved)`); - this.hud.setHint(this.rebindRequested ? 'Press any key to assign jump…' : 'Binding restored from localStorage on reload.'); + this.hud.setStatus(this.rebindRequested ? 'Press any key to assign jump...' : `Jump: ${keyName(this.jumpToken())} (J to rebind)`); } override update(delta: Seconds): void { @@ -166,9 +152,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(KeyRebindingScene); diff --git a/examples/input/keyboard.js b/examples/input/keyboard.js index 3f46cae88..5930a7701 100644 --- a/examples/input/keyboard.js +++ b/examples/input/keyboard.js @@ -11,6 +11,11 @@ import { mountControls } from '@examples/runtime'; // // Both WASD and the arrow keys drive the same square via a single binding per // direction (each binding watches two channels at once). +// +// The Output panel under the preview shows the transitions: every key press +// from `app.input.onKeyDown`, the recentre action, and changes in the held +// set - logged when they happen, never once per frame. +const keyName = channel => Keyboard[channel] ?? `Channel ${channel}`; class KeyboardScene extends Scene { square; position = { x: 400, y: 300 }; @@ -19,6 +24,8 @@ class KeyboardScene extends Scene { left; right; hud; + held = 'none'; + onKeyDown = channel => console.log(`KeyDown: ${keyName(channel)}`); init() { const app = this.app; const { width, height } = app; @@ -35,17 +42,10 @@ class KeyboardScene extends Scene { this.inputs.onStart(Keyboard.Escape, () => { this.position.x = width / 2; this.position.y = height / 2; + console.info('Action: Recentre'); }); - this.hud = mountControls({ - title: 'Keyboard', - controls: [ - { keys: ['W', 'A', 'S', 'D'], action: 'move (per-frame polling)' }, - { keys: ['↑', '↓', '←', '→'], action: 'move (same bindings)' }, - { keys: 'Esc', action: 'recentre (on-event)' }, - ], - status: 'Held: none', - hint: 'Click the canvas first so it has keyboard focus.', - }); + app.input.onKeyDown.add(this.onKeyDown); + this.hud = mountControls({ status: 'Held: none' }); } update(delta) { const app = this.app; @@ -58,7 +58,12 @@ class KeyboardScene extends Scene { const held = [this.up.active && 'Up', this.down.active && 'Down', this.left.active && 'Left', this.right.active && 'Right'].filter( Boolean, ); - this.hud.setStatus(`Held: ${held.length ? held.join(' + ') : 'none'}`); + const label = held.length ? held.join(' + ') : 'none'; + if (label !== this.held) { + this.held = label; + this.hud.setStatus(`Held: ${label}`); + console.log(`Held: ${label}`); + } } draw(context) { this.square.clear(); @@ -66,6 +71,11 @@ class KeyboardScene extends Scene { this.square.drawRectangle(this.position.x - 20, this.position.y - 20, 40, 40); context.render(this.square); } + destroy() { + this.app.input.onKeyDown.remove(this.onKeyDown); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { KeyboardScene }, @@ -76,8 +86,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(KeyboardScene); diff --git a/examples/input/keyboard.ts b/examples/input/keyboard.ts index 014d204b4..0b67e46ff 100644 --- a/examples/input/keyboard.ts +++ b/examples/input/keyboard.ts @@ -21,6 +21,12 @@ import { mountControls } from '@examples/runtime'; // // Both WASD and the arrow keys drive the same square via a single binding per // direction (each binding watches two channels at once). +// +// The Output panel under the preview shows the transitions: every key press +// from `app.input.onKeyDown`, the recentre action, and changes in the held +// set - logged when they happen, never once per frame. +const keyName = (channel: number): string => Keyboard[channel as Keyboard] ?? `Channel ${channel}`; + class KeyboardScene extends Scene { private square!: Graphics; private position = { x: 400, y: 300 }; @@ -29,6 +35,8 @@ class KeyboardScene extends Scene { private left!: InputBinding; private right!: InputBinding; private hud!: ReturnType; + private held = 'none'; + private readonly onKeyDown = (channel: number): void => console.log(`KeyDown: ${keyName(channel)}`); override init(): void { const app = this.app; @@ -49,18 +57,11 @@ class KeyboardScene extends Scene { this.inputs.onStart(Keyboard.Escape, () => { this.position.x = width / 2; this.position.y = height / 2; + console.info('Action: Recentre'); }); - this.hud = mountControls({ - title: 'Keyboard', - controls: [ - { keys: ['W', 'A', 'S', 'D'], action: 'move (per-frame polling)' }, - { keys: ['↑', '↓', '←', '→'], action: 'move (same bindings)' }, - { keys: 'Esc', action: 'recentre (on-event)' }, - ], - status: 'Held: none', - hint: 'Click the canvas first so it has keyboard focus.', - }); + app.input.onKeyDown.add(this.onKeyDown); + this.hud = mountControls({ status: 'Held: none' }); } override update(delta: Seconds): void { @@ -77,7 +78,13 @@ class KeyboardScene extends Scene { Boolean, ); - this.hud.setStatus(`Held: ${held.length ? held.join(' + ') : 'none'}`); + const label = held.length ? held.join(' + ') : 'none'; + + if (label !== this.held) { + this.held = label; + this.hud.setStatus(`Held: ${label}`); + console.log(`Held: ${label}`); + } } override draw(context: RenderingContext): void { @@ -86,6 +93,12 @@ class KeyboardScene extends Scene { this.square.drawRectangle(this.position.x - 20, this.position.y - 20, 40, 40); context.render(this.square); } + + override destroy(): void { + this.app.input.onKeyDown.remove(this.onKeyDown); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ @@ -97,9 +110,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(KeyboardScene); diff --git a/examples/input/mouse-and-pointer.js b/examples/input/mouse-and-pointer.js index 5b39cf615..25d724096 100644 --- a/examples/input/mouse-and-pointer.js +++ b/examples/input/mouse-and-pointer.js @@ -1,5 +1,6 @@ // Auto-generated from mouse-and-pointer.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Everything the pointer pipeline reports, surfaced at once: // - live position (onPointerMove) @@ -22,7 +23,7 @@ class MouseAndPointerScene extends Scene { const { width, height } = app; this.pointer = { x: width / 2, y: height / 2 }; this.previous = { x: width / 2, y: height / 2 }; - this.ship = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.ship = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); this.ship.interactive = true; this.ship.draggable = true; this.root.addChild(this.ship); @@ -41,16 +42,7 @@ class MouseAndPointerScene extends Scene { app.input.onPointerTap.add(() => { this.clicks++; }); - this.hud = mountControls({ - title: 'Mouse and Pointer', - controls: [ - { keys: 'Move', action: 'track position + delta' }, - { keys: 'Click', action: 'count taps' }, - { keys: 'Drag', action: 'move the ship sprite' }, - ], - status: '', - hint: 'Drag the ship to move it; the crosshair follows the cursor.', - }); + this.hud = mountControls(); } buttonLabel() { const held = [this.buttons & 1 && 'Left', this.buttons & 2 && 'Right', this.buttons & 4 && 'Middle'].filter(Boolean); @@ -84,8 +76,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MouseAndPointerScene); diff --git a/examples/input/mouse-and-pointer.ts b/examples/input/mouse-and-pointer.ts index 92ae6f0b1..625030d49 100644 --- a/examples/input/mouse-and-pointer.ts +++ b/examples/input/mouse-and-pointer.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Everything the pointer pipeline reports, surfaced at once: @@ -25,7 +26,7 @@ class MouseAndPointerScene extends Scene { this.pointer = { x: width / 2, y: height / 2 }; this.previous = { x: width / 2, y: height / 2 }; - this.ship = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.ship = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); this.ship.interactive = true; this.ship.draggable = true; this.root.addChild(this.ship); @@ -46,16 +47,7 @@ class MouseAndPointerScene extends Scene { this.clicks++; }); - this.hud = mountControls({ - title: 'Mouse and Pointer', - controls: [ - { keys: 'Move', action: 'track position + delta' }, - { keys: 'Click', action: 'count taps' }, - { keys: 'Drag', action: 'move the ship sprite' }, - ], - status: '', - hint: 'Drag the ship to move it; the crosshair follows the cursor.', - }); + this.hud = mountControls(); } private buttonLabel(): string { @@ -96,9 +88,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MouseAndPointerScene); diff --git a/examples/input/multi-gamepad.js b/examples/input/multi-gamepad.js index 76d3855ce..272e1cd19 100644 --- a/examples/input/multi-gamepad.js +++ b/examples/input/multi-gamepad.js @@ -1,5 +1,6 @@ // Auto-generated from multi-gamepad.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, GamepadAxis, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const tints = [new Color(255, 140, 140), new Color(140, 255, 170), new Color(150, 180, 255), new Color(255, 230, 140)]; // Each of the four stable gamepad slots gets its own ship and its own left-stick @@ -15,7 +16,7 @@ class MultiGamepadScene extends Scene { const app = this.app; const { width, height } = app; this.players = app.input.gamepads.map((pad, index) => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')) + const sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.6) .setPosition(width * (0.2 + index * 0.2), height / 2) @@ -80,8 +81,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MultiGamepadScene); diff --git a/examples/input/multi-gamepad.ts b/examples/input/multi-gamepad.ts index 106f01de6..379c0c07f 100644 --- a/examples/input/multi-gamepad.ts +++ b/examples/input/multi-gamepad.ts @@ -10,6 +10,7 @@ import { Sprite, Text, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const tints = [new Color(255, 140, 140), new Color(140, 255, 170), new Color(150, 180, 255), new Color(255, 230, 140)]; @@ -35,7 +36,7 @@ class MultiGamepadScene extends Scene { const { width, height } = app; this.players = app.input.gamepads.map((pad, index) => { - const sprite = new Sprite(this.loader.get('image/ship-a.png')) + const sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.6) .setPosition(width * (0.2 + index * 0.2), height / 2) @@ -113,9 +114,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MultiGamepadScene); diff --git a/examples/input/multitouch.js b/examples/input/multitouch.js index 14140dd28..c04676554 100644 --- a/examples/input/multitouch.js +++ b/examples/input/multitouch.js @@ -49,12 +49,7 @@ class MultitouchScene extends Scene { this.pointers.delete(pointer.id); this.refreshHud(); }); - this.hud = mountControls({ - title: 'Multitouch', - controls: [{ keys: 'Touch', action: 'each finger draws a circle' }], - status: 'Active touches: 0 / 10', - hint: 'Touchscreen required — use multiple fingers (mouse gives one point).', - }); + this.hud = mountControls({ status: 'Active touches: 0 / 10' }); } refreshHud() { this.hud.setStatus(`Active touches: ${this.pointers.size} / ${MAX_TOUCHES}`); @@ -87,8 +82,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MultitouchScene); diff --git a/examples/input/multitouch.ts b/examples/input/multitouch.ts index d19bbeb5b..2137dcf54 100644 --- a/examples/input/multitouch.ts +++ b/examples/input/multitouch.ts @@ -63,12 +63,7 @@ class MultitouchScene extends Scene { this.refreshHud(); }); - this.hud = mountControls({ - title: 'Multitouch', - controls: [{ keys: 'Touch', action: 'each finger draws a circle' }], - status: 'Active touches: 0 / 10', - hint: 'Touchscreen required — use multiple fingers (mouse gives one point).', - }); + this.hud = mountControls({ status: 'Active touches: 0 / 10' }); } private refreshHud(): void { @@ -112,9 +107,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(MultitouchScene); diff --git a/examples/input/pointer-to-world.js b/examples/input/pointer-to-world.js index d2314d0c1..de54c47e4 100644 --- a/examples/input/pointer-to-world.js +++ b/examples/input/pointer-to-world.js @@ -98,8 +98,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(PointerToWorldScene); diff --git a/examples/input/pointer-to-world.ts b/examples/input/pointer-to-world.ts index 2c208befb..72af9b4ef 100644 --- a/examples/input/pointer-to-world.ts +++ b/examples/input/pointer-to-world.ts @@ -120,9 +120,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(10, 12, 20), - loader: { - basePath: 'assets/', - }, }); await app.start(PointerToWorldScene); diff --git a/examples/lighting/light-cookies.js b/examples/lighting/light-cookies.js index 0a3caaec9..8dbb57cda 100644 --- a/examples/lighting/light-cookies.js +++ b/examples/lighting/light-cookies.js @@ -1,7 +1,7 @@ // Auto-generated from light-cookies.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite, Texture, WrapModes } from '@codexo/exojs'; -import { AlphaOccluder, LightmapLighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, LightmapLighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs/lighting'; +import { demoPanel, slider, toggle } from '@examples/runtime'; // Four light shapes, one scene, and the shape of the light doing the work that // a texture would otherwise have to do. // @@ -74,7 +74,7 @@ class LightCookiesScene extends Scene { canopy; tube; elapsed = 0; - hud; + panel; init() { const { width, height } = this.app; this.world = new Container(); @@ -132,38 +132,19 @@ class LightCookiesScene extends Scene { new LineLight({ length: 260, radius: 64, intensity: 2.6, softness: 0.05, color: new Color(120, 190, 255) }), ); this.tube.setPosition(300, 640); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Light Cookies', - hint: 'Every pattern here is one texture on ONE light, carried by that light - it turns and scales with the lamp rather than being projected onto the world. Watch the window drift: its bars travel with the lamp instead of staying put on the floor, which is exactly the difference between a cookie and a world projection.', - status: '', - }); - const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); - panel.addSlider({ - label: 'Time of day', - min: -60, - max: 60, - step: 1, - value: this.sun.rotation, - onChange: value => { - this.sun.rotation = value; + corner: 'top-right', + controls: { + time: slider('Time of day', { min: -60, max: 60, value: this.sun.rotation, step: 1, format: value => `${value}` }), + sun: slider('Sun intensity', { min: 0, max: 1.2, value: this.sun.intensity, step: 0.05, format: value => value.toFixed(2) }), + cookies: toggle('Cookies', true), }, - }); - panel.addSlider({ - label: 'Sun', - min: 0, - max: 1.2, - step: 0.05, - value: this.sun.intensity, - onChange: value => { - this.sun.intensity = value; - }, - }); - panel.addToggle({ - label: 'Cookies', - value: true, - onChange: value => { - this.window.cookie = value ? windowCookie : null; - this.canopy.cookie = value ? canopyCookie : null; + onChange: (_key, _value, values) => { + this.sun.rotation = values.time; + this.sun.intensity = values.sun; + this.window.cookie = values.cookies ? windowCookie : null; + this.canopy.cookie = values.cookies ? canopyCookie : null; }, }); } @@ -179,7 +160,11 @@ class LightCookiesScene extends Scene { } draw(context) { context.render(this.world); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } + destroy() { + this.panel.dispose(); + super.destroy(); } } const app = new Application({ diff --git a/examples/lighting/light-cookies.ts b/examples/lighting/light-cookies.ts index 0919afd15..98923cd35 100644 --- a/examples/lighting/light-cookies.ts +++ b/examples/lighting/light-cookies.ts @@ -11,8 +11,8 @@ import { Texture, WrapModes, } from '@codexo/exojs'; -import { AlphaOccluder, type Lighting, LightmapLighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, type Lighting, LightmapLighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs/lighting'; +import { type DemoPanel, demoPanel, slider, type SliderSpec, toggle, type ToggleSpec } from '@examples/runtime'; // Four light shapes, one scene, and the shape of the light doing the work that // a texture would otherwise have to do. @@ -100,7 +100,7 @@ class LightCookiesScene extends Scene { private canopy!: SpotLight; private tube!: LineLight; private elapsed = 0; - private hud!: ReturnType; + private panel!: DemoPanel<{ time: SliderSpec; sun: SliderSpec; cookies: ToggleSpec }>; override init(): void { const { width, height } = this.app; @@ -169,42 +169,19 @@ class LightCookiesScene extends Scene { ); this.tube.setPosition(300, 640); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Light Cookies', - hint: 'Every pattern here is one texture on ONE light, carried by that light - it turns and scales with the lamp rather than being projected onto the world. Watch the window drift: its bars travel with the lamp instead of staying put on the floor, which is exactly the difference between a cookie and a world projection.', - status: '', - }); - - const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); - - panel.addSlider({ - label: 'Time of day', - min: -60, - max: 60, - step: 1, - value: this.sun.rotation, - onChange: value => { - this.sun.rotation = value; - }, - }); - - panel.addSlider({ - label: 'Sun', - min: 0, - max: 1.2, - step: 0.05, - value: this.sun.intensity, - onChange: value => { - this.sun.intensity = value; + corner: 'top-right', + controls: { + time: slider('Time of day', { min: -60, max: 60, value: this.sun.rotation, step: 1, format: value => `${value}` }), + sun: slider('Sun intensity', { min: 0, max: 1.2, value: this.sun.intensity, step: 0.05, format: value => value.toFixed(2) }), + cookies: toggle('Cookies', true), }, - }); - - panel.addToggle({ - label: 'Cookies', - value: true, - onChange: value => { - this.window.cookie = value ? windowCookie : null; - this.canopy.cookie = value ? canopyCookie : null; + onChange: (_key, _value, values) => { + this.sun.rotation = values.time; + this.sun.intensity = values.sun; + this.window.cookie = values.cookies ? windowCookie : null; + this.canopy.cookie = values.cookies ? canopyCookie : null; }, }); } @@ -223,7 +200,12 @@ class LightCookiesScene extends Scene { override draw(context: RenderingContext): void { context.render(this.world); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } + + override destroy(): void { + this.panel.dispose(); + super.destroy(); } } diff --git a/examples/lighting/lightmap-normals.js b/examples/lighting/lightmap-normals.js index 6a05ab675..896648b29 100644 --- a/examples/lighting/lightmap-normals.js +++ b/examples/lighting/lightmap-normals.js @@ -1,7 +1,7 @@ // Auto-generated from lightmap-normals.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; -import { AlphaNormals, LightmapLighting, PointLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaNormals, LightmapLighting, PointLight } from '@codexo/exojs/lighting'; +import { demoPanel, mountControls, slider, toggle } from '@examples/runtime'; // The lightmap renderer multiplies a frame that was already drawn, so by the // time the light field is composited there is no surface normal anywhere. A // normal prepass puts one back: register a drawable and the renderer draws its @@ -10,9 +10,10 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // // Nothing is required of a drawable that is not registered - the attachment's // alpha is coverage, and where it is zero the light lands with no `N dot L` -// term at all. That is what the "Normals" toggle below shows: without them -// every fragment takes the light head-on, so the relief flattens and the -// stones read slightly BRIGHTER rather than darker. Normals redistribute +// term at all. The two halves of the floor show it side by side: the left +// cobbles are registered, the right ones are not, so there every fragment takes +// the light head-on, the relief flattens and the stones read slightly BRIGHTER +// rather than darker. Normals redistribute // light across a surface; they never add any. // // Nobody authored a normal map here either. `AlphaNormals` reads the @@ -59,12 +60,10 @@ const cobbleTexture = canvasTexture(cobbleSize, cobbleSize, context => { class LightmapNormalsScene extends Scene { world; lighting; - cobbles = []; normals; torch; lantern; elapsed = 0; - hud; init() { const { width, height } = this.app; this.world = new Container(); @@ -90,45 +89,29 @@ class LightmapNormalsScene extends Scene { // a stone lit from the left must stay lit from the left however it sits. cobble.rotation = (row * 9 + column * 23) % 360; this.world.addChild(cobble); - this.cobbles.push(this.lighting.normalsFrom(cobble, this.normals)); + if (cobble.x < width / 2) { + this.lighting.normalsFrom(cobble, this.normals); + } } } this.torch = this.lighting.add(new PointLight({ radius: 480, intensity: 2.4, height: 34, color: new Color(255, 190, 130) })); this.lantern = this.lighting.add(new PointLight({ radius: 360, intensity: 1.8, height: 90, color: new Color(150, 200, 255) })); - this.hud = mountControls({ - title: 'Normals under the lightmap renderer', - hint: 'Nobody authored a normal map: the cobbles are round because their own alpha says so. Switch the normals off and every fragment takes the light head-on - the relief flattens and the stones read a little brighter, because a surface normal moves light around rather than adding any.', - status: '', - }); - const panel = mountControlPanel({ title: 'Surfaces', corner: 'top-right' }); - panel.addToggle({ - label: 'Normals', - value: true, - onChange: value => { - for (const cobble of this.cobbles) { - if (value) { - this.lighting.normalsFrom(cobble, this.normals); - } else { - this.lighting.stopNormals(cobble); - } - } + // DOM labels: canvas text would sit under the lightmap and go dark with + // the floor. + mountControls({ status: 'Left half: with normals', corner: 'top-left' }); + mountControls({ status: 'Right half: without normals', corner: 'bottom-right' }); + demoPanel({ + corner: 'top-right', + controls: { + height: slider('Light height', { min: 8, max: 160, step: 2, value: this.torch.height, format: v => v.toFixed(0) }), + debug: toggle('Show normals'), }, - }); - panel.addToggle({ - label: 'Show normals', - value: false, - onChange: value => { - this.lighting.debug = value ? 'normals' : null; - }, - }); - panel.addSlider({ - label: 'Light height', - min: 8, - max: 160, - step: 2, - value: this.torch.height, - onChange: value => { - this.torch.height = value; + onChange: (key, value) => { + if (key === 'height') { + this.torch.height = value; + } else { + this.lighting.debug = value ? 'normals' : null; + } }, }); } @@ -139,9 +122,6 @@ class LightmapNormalsScene extends Scene { } draw(context) { context.render(this.world); - this.hud.setStatus( - `${this.lighting.activeSurfaceCount} lit surfaces - ${this.lighting.quality} renderer - draw calls ${context.stats.drawCalls}`, - ); } } const app = new Application({ diff --git a/examples/lighting/lightmap-normals.ts b/examples/lighting/lightmap-normals.ts index e7b5506cf..83dbc48fb 100644 --- a/examples/lighting/lightmap-normals.ts +++ b/examples/lighting/lightmap-normals.ts @@ -10,8 +10,8 @@ import { Sprite, Texture, } from '@codexo/exojs'; -import { AlphaNormals, type Lighting, LightmapLighting, type NormalSource, PointLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaNormals, type Lighting, LightmapLighting, type NormalSource, PointLight } from '@codexo/exojs/lighting'; +import { demoPanel, mountControls, slider, toggle } from '@examples/runtime'; // The lightmap renderer multiplies a frame that was already drawn, so by the // time the light field is composited there is no surface normal anywhere. A @@ -21,9 +21,10 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // // Nothing is required of a drawable that is not registered - the attachment's // alpha is coverage, and where it is zero the light lands with no `N dot L` -// term at all. That is what the "Normals" toggle below shows: without them -// every fragment takes the light head-on, so the relief flattens and the -// stones read slightly BRIGHTER rather than darker. Normals redistribute +// term at all. The two halves of the floor show it side by side: the left +// cobbles are registered, the right ones are not, so there every fragment takes +// the light head-on, the relief flattens and the stones read slightly BRIGHTER +// rather than darker. Normals redistribute // light across a surface; they never add any. // // Nobody authored a normal map here either. `AlphaNormals` reads the @@ -78,12 +79,10 @@ const cobbleTexture = canvasTexture(cobbleSize, cobbleSize, context => { class LightmapNormalsScene extends Scene { private world!: Container; private lighting!: Lighting; - private cobbles: Sprite[] = []; private normals!: NormalSource; private torch!: PointLight; private lantern!: PointLight; private elapsed = 0; - private hud!: ReturnType; override init(): void { const { width, height } = this.app; @@ -116,51 +115,33 @@ class LightmapNormalsScene extends Scene { // a stone lit from the left must stay lit from the left however it sits. cobble.rotation = (row * 9 + column * 23) % 360; this.world.addChild(cobble); - this.cobbles.push(this.lighting.normalsFrom(cobble, this.normals)); + + if (cobble.x < width / 2) { + this.lighting.normalsFrom(cobble, this.normals); + } } } this.torch = this.lighting.add(new PointLight({ radius: 480, intensity: 2.4, height: 34, color: new Color(255, 190, 130) })); this.lantern = this.lighting.add(new PointLight({ radius: 360, intensity: 1.8, height: 90, color: new Color(150, 200, 255) })); - this.hud = mountControls({ - title: 'Normals under the lightmap renderer', - hint: 'Nobody authored a normal map: the cobbles are round because their own alpha says so. Switch the normals off and every fragment takes the light head-on - the relief flattens and the stones read a little brighter, because a surface normal moves light around rather than adding any.', - status: '', - }); - - const panel = mountControlPanel({ title: 'Surfaces', corner: 'top-right' }); - - panel.addToggle({ - label: 'Normals', - value: true, - onChange: value => { - for (const cobble of this.cobbles) { - if (value) { - this.lighting.normalsFrom(cobble, this.normals); - } else { - this.lighting.stopNormals(cobble); - } - } - }, - }); + // DOM labels: canvas text would sit under the lightmap and go dark with + // the floor. + mountControls({ status: 'Left half: with normals', corner: 'top-left' }); + mountControls({ status: 'Right half: without normals', corner: 'bottom-right' }); - panel.addToggle({ - label: 'Show normals', - value: false, - onChange: value => { - this.lighting.debug = value ? 'normals' : null; + demoPanel({ + corner: 'top-right', + controls: { + height: slider('Light height', { min: 8, max: 160, step: 2, value: this.torch.height, format: v => v.toFixed(0) }), + debug: toggle('Show normals'), }, - }); - - panel.addSlider({ - label: 'Light height', - min: 8, - max: 160, - step: 2, - value: this.torch.height, - onChange: value => { - this.torch.height = value; + onChange: (key, value) => { + if (key === 'height') { + this.torch.height = value as number; + } else { + this.lighting.debug = value ? 'normals' : null; + } }, }); } @@ -174,9 +155,6 @@ class LightmapNormalsScene extends Scene { override draw(context: RenderingContext): void { context.render(this.world); - this.hud.setStatus( - `${this.lighting.activeSurfaceCount} lit surfaces - ${this.lighting.quality} renderer - draw calls ${context.stats.drawCalls}`, - ); } } diff --git a/examples/lighting/many-lights.js b/examples/lighting/many-lights.js index 520ee044e..979fc01d3 100644 --- a/examples/lighting/many-lights.js +++ b/examples/lighting/many-lights.js @@ -11,8 +11,8 @@ import { Texture, TextureFormat, } from '@codexo/exojs'; -import { ForwardLighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { ForwardLighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs/lighting'; +import { demoPanel, slider } from '@examples/runtime'; // The FORWARD renderer under load: the light list is a data texture, not a // uniform array, so the light count is a shader loop bound rather than a // compiled-in constant, and the slider below walks from 1 to 48 lights without @@ -103,7 +103,7 @@ class ManyLightsScene extends Scene { draggingFirst = false; manualFirst = true; elapsed = 0; - hud; + panel; onPointerDown = (_pointer, x, y) => { const marker = this.orbits[0]?.marker; this.draggingFirst = !!marker && Math.hypot(x - marker.x, y - marker.y) < 36; @@ -152,22 +152,15 @@ 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({ + this.panel = demoPanel({ 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: '', + corner: 'top-right', + controls: { + lights: slider('Active lights', { min: 1, max: MAX_LIGHTS, value: this.visibleLights, step: 1, format: value => `${value}` }), + }, + actions: { 'Resume first orbit': () => (this.manualFirst = false) }, + onChange: (_key, value) => this.setVisibleLights(value), }); - const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' }); - panel.addSlider({ - label: 'Active lights', - min: 1, - max: MAX_LIGHTS, - step: 1, - 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); @@ -203,14 +196,14 @@ class ManyLightsScene extends Scene { draw(context) { context.render(this.floor); context.render(this.markerLayer); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${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(); + this.panel.dispose(); super.destroy(); } } diff --git a/examples/lighting/many-lights.ts b/examples/lighting/many-lights.ts index ee4cfd65d..3d362fc9a 100644 --- a/examples/lighting/many-lights.ts +++ b/examples/lighting/many-lights.ts @@ -12,8 +12,8 @@ import { Texture, TextureFormat, } from '@codexo/exojs'; -import { ForwardLighting, type Lighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { ForwardLighting, type Lighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs/lighting'; +import { type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; // The FORWARD renderer under load: the light list is a data texture, not a // uniform array, so the light count is a shader loop bound rather than a @@ -129,7 +129,7 @@ class ManyLightsScene extends Scene { private draggingFirst = false; private manualFirst = true; private elapsed = 0; - private hud!: ReturnType; + private panel!: DemoPanel<{ lights: SliderSpec }>; 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; @@ -188,25 +188,15 @@ 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({ + this.panel = demoPanel({ 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' }); - - panel.addSlider({ - label: 'Active lights', - min: 1, - max: MAX_LIGHTS, - step: 1, - value: this.visibleLights, - onChange: value => this.setVisibleLights(value), + corner: 'top-right', + controls: { + lights: slider('Active lights', { min: 1, max: MAX_LIGHTS, value: this.visibleLights, step: 1, format: value => `${value}` }), + }, + actions: { 'Resume first orbit': () => (this.manualFirst = false) }, + onChange: (_key, 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); @@ -252,7 +242,7 @@ class ManyLightsScene extends Scene { override draw(context: RenderingContext): void { context.render(this.floor); context.render(this.markerLayer); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); } override destroy(): void { @@ -260,7 +250,7 @@ class ManyLightsScene extends Scene { 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.panel.dispose(); super.destroy(); } } diff --git a/examples/lighting/normal-mapped-sprites.js b/examples/lighting/normal-mapped-sprites.js index 8d423af0e..af8b4d0cf 100644 --- a/examples/lighting/normal-mapped-sprites.js +++ b/examples/lighting/normal-mapped-sprites.js @@ -11,7 +11,7 @@ import { Texture, TextureFormat, } from '@codexo/exojs'; -import { ForwardLighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs-lighting'; +import { ForwardLighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs/lighting'; import { mountControls } from '@examples/runtime'; // Forward normal mapping on plain sprites. A LitMaterial samples a // tangent-space normal map next to the base texture and shades each fragment diff --git a/examples/lighting/normal-mapped-sprites.ts b/examples/lighting/normal-mapped-sprites.ts index ff5a04082..47f037643 100644 --- a/examples/lighting/normal-mapped-sprites.ts +++ b/examples/lighting/normal-mapped-sprites.ts @@ -12,7 +12,7 @@ import { Texture, TextureFormat, } from '@codexo/exojs'; -import { ForwardLighting, type Lighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs-lighting'; +import { ForwardLighting, type Lighting, LitMaterial, NormalMap, PointLight } from '@codexo/exojs/lighting'; import { mountControls } from '@examples/runtime'; // Forward normal mapping on plain sprites. A LitMaterial samples a diff --git a/examples/lighting/radiance-rooms.js b/examples/lighting/radiance-rooms.js index 857293f67..32cb084d4 100644 --- a/examples/lighting/radiance-rooms.js +++ b/examples/lighting/radiance-rooms.js @@ -4,14 +4,15 @@ import { Color, Container, FixedResolutionCanvasSizing, + Keyboard, RepeatingSprite, ScaleModes, Scene, Sprite, Texture, } from '@codexo/exojs'; -import { AlphaOccluder, LightmapLighting, PointLight, PolygonOccluder, RadianceLighting } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, LightmapLighting, PointLight, PolygonOccluder, RadianceLighting } from '@codexo/exojs/lighting'; +import { choice, demoPanel, slider, toggle } from '@examples/runtime'; // Two rooms, one doorway, one lamp - and a switch between the renderer that // draws a light and the one that transports it. // @@ -121,7 +122,6 @@ const outline = wall => { */ const bounceFactor = 0.9; /** Levels the debug cycle walks, in the order it walks them. */ -const debugViews = [null, 'light', 'mask', 'occluders']; /** Texels of light field per logical pixel. Stated here so the panel can show what was actually run at. */ const lightResolution = 1; /** @@ -132,15 +132,6 @@ const lampAt = phase => ({ x: 340 + Math.sin(phase * 0.3) * 120, y: 360 + Math.cos(phase * 0.22) * 150, }); -/** - * Seconds for one full loop of BOTH terms, so a phase slider covers the whole - * path and the same phase is always the same place. The two rates are 0.3 and - * 0.22, whose common period is `2 * pi` over their greatest common measure of - * 0.02 - not over their difference. - */ -const loopSeconds = (Math.PI * 2) / 0.02; -/** Where the status line reads the lamp's real position into. */ -const lampPosition = { x: 0, y: 0 }; class RadianceRoomsScene extends Scene { world; panel; @@ -150,10 +141,7 @@ class RadianceRoomsScene extends Scene { softness = 0.35; bounce = true; moving = true; - debug = null; elapsed = 0; - hud; - phaseControl; init() { const { width, height } = this.app; this.world = new Container(); @@ -181,117 +169,46 @@ class RadianceRoomsScene extends Scene { this.panel.height = bouncePanel.height; this.panel.setPosition(bouncePanel.x, bouncePanel.y); this.world.addChild(this.panel); - this.build(); - this.hud = mountControls({ - title: 'Radiance Rooms', - hint: 'One lamp, one doorway. Switch the renderer to see the difference between a light that is drawn and light that is transported - they are different transport models and will not agree pixel for pixel. Pause the motion and set a phase to compare the two at the same instant.', - status: '', - }); - const panel = mountControlPanel({ title: 'Lighting', corner: 'top-right' }); - panel.addToggle({ - label: 'Radiance', - value: true, - onChange: value => { - // A renderer is chosen by constructing it, so switching means building - // a new system - which is all a system is here: it owns its passes and - // takes them out again on `destroy()`. Importing the class is also what - // links it, so a project that only ever builds one leaves the other - // out of its bundle. - this.cascading = value; - this.build(); - }, - }); - panel.addToggle({ - label: 'Bounce', - value: this.bounce, - onChange: value => { - this.bounce = value; - // Only the cascades bounce; under the quads the toggle has nothing to - // rebuild. - if (this.cascading) { - this.build(); - } - }, - }); - panel.addToggle({ - label: 'Motion', - value: this.moving, - onChange: value => { - this.moving = value; - }, - }); - this.phaseControl = panel.addSlider({ - label: 'Phase', - min: 0, - max: 1, - // A step is a step along the PATH, and the path is `loopSeconds` long: - // at this rate one is about ten pixels of lamp, which is what makes the - // slider a way to place the lamp rather than to jump it across the room. - step: 0.001, - value: 0, - onChange: value => { - // Placing the lamp by hand is what makes a comparison reproducible: - // the same phase is the same position in either renderer, however long - // either has been running. - this.elapsed = value * loopSeconds; - this.moveLamp(); - }, - }); - panel.addCycle({ - label: 'Field', - options: ['shaded', 'light', 'mask', 'occluders'], - index: 0, - onChange: index => { - this.debug = debugViews[index] ?? null; - this.lighting.debug = this.debug; - }, - }); - panel.addSlider({ - label: 'Lamp', - min: 0.5, - max: 6, - step: 0.1, - value: this.intensity, - onChange: value => { - this.intensity = value; - this.lamp.intensity = value; + // A renderer is chosen by constructing it, so switching means building a + // new system - which is all a system is here: it owns its passes and takes + // them out again on `destroy()`. Importing the class is also what links it, + // so a project that only ever builds one leaves the other out of its bundle. + // Space pauses the lamp, so both renderers can be compared at one instant. + const panel = demoPanel({ + title: 'Lighting', + corner: 'top-right', + controls: { + renderer: choice('Renderer', ['Radiance', 'Lightmap']), + bounce: toggle('Bounce', this.bounce), + moving: toggle('Motion', this.moving), + lamp: slider('Lamp', { min: 0.5, max: 6, step: 0.1, value: this.intensity, format: v => v.toFixed(1) }), + // Source size under radiance, filter width under lightmap. See the + // note at the top of the file: the two are not the same quantity. + softness: slider('Softness', { min: 0, max: 1, step: 0.05, value: this.softness }), }, - }); - panel.addSlider({ - // Source size under radiance, filter width under lightmap. See the note - // at the top of the file: the two are not the same quantity, and the - // slider is labelled for neither so that the difference stays visible. - label: 'Softness', - min: 0, - max: 1, - step: 0.05, - value: this.softness, - onChange: value => { - this.softness = value; - this.lamp.softness = value; + onChange: (_key, _value, values) => { + this.moving = values.moving; + this.intensity = values.lamp; + this.softness = values.softness; + // Only the cascades bounce; under the quads the toggle changes nothing. + this.build(values.renderer === 'Radiance', values.bounce); + this.lamp.intensity = this.intensity; + this.lamp.softness = this.softness; }, }); + this.inputs.onTrigger(Keyboard.Space, () => panel.step('moving')); } update(delta) { if (!this.moving) { return; } this.elapsed += delta; - this.phaseControl.set((this.elapsed % loopSeconds) / loopSeconds); // Moving the lamp is the clearest way to see that nothing about the far // room is baked: the wedge through the doorway sweeps with it. this.moveLamp(); } draw(context) { context.render(this.world); - // Read off the lamp itself, not recomputed from the clock: the status line - // is what says two renderers were compared under the same conditions, so - // it has to report where the light actually is. - this.lamp.getWorldPosition(lampPosition); - const bounce = this.cascading && this.bounce ? 'bounce' : 'no bounce'; - this.hud.setStatus( - `${this.lighting.quality} - ${bounce} - ${this.app.width}x${this.app.height} at ${lightResolution}x - lamp ${lampPosition.x.toFixed(1)}, ${lampPosition.y.toFixed(1)} - draw calls ${context.stats.drawCalls}`, - ); } moveLamp() { const { x, y } = lampAt(this.elapsed); @@ -300,8 +217,13 @@ class RadianceRoomsScene extends Scene { get lamp() { return this.lighting.lights[0]; } - /** Build the lighting system for the renderer currently selected. */ - build() { + /** Builds the lighting system for the selected renderer, unless it is already the one running. */ + build(cascading, bounce) { + if (this.lighting !== undefined && cascading === this.cascading && (bounce === this.bounce || !cascading)) { + return; + } + this.cascading = cascading; + this.bounce = bounce; if (this.lighting !== undefined) { this.systems.remove(this.lighting); this.lighting.destroy(); @@ -310,7 +232,6 @@ class RadianceRoomsScene extends Scene { this.lighting = this.cascading ? new RadianceLighting(this.app, { ...options, bounce: this.bounce ? bounceFactor : 0 }) : new LightmapLighting(this.app, options); - this.lighting.debug = this.debug; this.systems.add(this.lighting); this.lighting.add(new PointLight({ radius: 600, intensity: this.intensity, softness: this.softness, color: new Color(255, 226, 180) })); // Placed straight away rather than on the next tick: switching renderer diff --git a/examples/lighting/radiance-rooms.ts b/examples/lighting/radiance-rooms.ts index c85064b89..76e803ff7 100644 --- a/examples/lighting/radiance-rooms.ts +++ b/examples/lighting/radiance-rooms.ts @@ -3,6 +3,7 @@ import { Color, Container, FixedResolutionCanvasSizing, + Keyboard, type RenderingContext, RepeatingSprite, ScaleModes, @@ -11,16 +12,8 @@ import { Sprite, Texture, } from '@codexo/exojs'; -import { - AlphaOccluder, - type Lighting, - type LightingDebugView, - LightmapLighting, - PointLight, - PolygonOccluder, - RadianceLighting, -} from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, type Lighting, LightmapLighting, PointLight, PolygonOccluder, RadianceLighting } from '@codexo/exojs/lighting'; +import { choice, demoPanel, slider, toggle } from '@examples/runtime'; // Two rooms, one doorway, one lamp - and a switch between the renderer that // draws a light and the one that transports it. @@ -153,7 +146,6 @@ const outline = (wall: Wall): ReadonlyArray<{ x: number; y: number }> => { const bounceFactor = 0.9; /** Levels the debug cycle walks, in the order it walks them. */ -const debugViews: readonly LightingDebugView[] = [null, 'light', 'mask', 'occluders']; /** Texels of light field per logical pixel. Stated here so the panel can show what was actually run at. */ const lightResolution = 1; @@ -167,17 +159,6 @@ const lampAt = (phase: number): { x: number; y: number } => ({ y: 360 + Math.cos(phase * 0.22) * 150, }); -/** - * Seconds for one full loop of BOTH terms, so a phase slider covers the whole - * path and the same phase is always the same place. The two rates are 0.3 and - * 0.22, whose common period is `2 * pi` over their greatest common measure of - * 0.02 - not over their difference. - */ -const loopSeconds = (Math.PI * 2) / 0.02; - -/** Where the status line reads the lamp's real position into. */ -const lampPosition = { x: 0, y: 0 }; - class RadianceRoomsScene extends Scene { private world!: Container; private panel!: Sprite; @@ -187,10 +168,7 @@ class RadianceRoomsScene extends Scene { private softness = 0.35; private bounce = true; private moving = true; - private debug: LightingDebugView = null; private elapsed = 0; - private hud!: ReturnType; - private phaseControl!: { set(value: number): void }; override init(): void { const { width, height } = this.app; @@ -228,106 +206,35 @@ class RadianceRoomsScene extends Scene { this.panel.setPosition(bouncePanel.x, bouncePanel.y); this.world.addChild(this.panel); - this.build(); - - this.hud = mountControls({ - title: 'Radiance Rooms', - hint: 'One lamp, one doorway. Switch the renderer to see the difference between a light that is drawn and light that is transported - they are different transport models and will not agree pixel for pixel. Pause the motion and set a phase to compare the two at the same instant.', - status: '', - }); - - const panel = mountControlPanel({ title: 'Lighting', corner: 'top-right' }); - - panel.addToggle({ - label: 'Radiance', - value: true, - onChange: value => { - // A renderer is chosen by constructing it, so switching means building - // a new system - which is all a system is here: it owns its passes and - // takes them out again on `destroy()`. Importing the class is also what - // links it, so a project that only ever builds one leaves the other - // out of its bundle. - this.cascading = value; - this.build(); - }, - }); - - panel.addToggle({ - label: 'Bounce', - value: this.bounce, - onChange: value => { - this.bounce = value; - - // Only the cascades bounce; under the quads the toggle has nothing to - // rebuild. - if (this.cascading) { - this.build(); - } - }, - }); - - panel.addToggle({ - label: 'Motion', - value: this.moving, - onChange: value => { - this.moving = value; - }, - }); - - this.phaseControl = panel.addSlider({ - label: 'Phase', - min: 0, - max: 1, - // A step is a step along the PATH, and the path is `loopSeconds` long: - // at this rate one is about ten pixels of lamp, which is what makes the - // slider a way to place the lamp rather than to jump it across the room. - step: 0.001, - value: 0, - onChange: value => { - // Placing the lamp by hand is what makes a comparison reproducible: - // the same phase is the same position in either renderer, however long - // either has been running. - this.elapsed = value * loopSeconds; - this.moveLamp(); - }, - }); - - panel.addCycle({ - label: 'Field', - options: ['shaded', 'light', 'mask', 'occluders'], - index: 0, - onChange: index => { - this.debug = debugViews[index] ?? null; - this.lighting.debug = this.debug; + // A renderer is chosen by constructing it, so switching means building a + // new system - which is all a system is here: it owns its passes and takes + // them out again on `destroy()`. Importing the class is also what links it, + // so a project that only ever builds one leaves the other out of its bundle. + // Space pauses the lamp, so both renderers can be compared at one instant. + const panel = demoPanel({ + title: 'Lighting', + corner: 'top-right', + controls: { + renderer: choice('Renderer', ['Radiance', 'Lightmap']), + bounce: toggle('Bounce', this.bounce), + moving: toggle('Motion', this.moving), + lamp: slider('Lamp', { min: 0.5, max: 6, step: 0.1, value: this.intensity, format: v => v.toFixed(1) }), + // Source size under radiance, filter width under lightmap. See the + // note at the top of the file: the two are not the same quantity. + softness: slider('Softness', { min: 0, max: 1, step: 0.05, value: this.softness }), }, - }); - - panel.addSlider({ - label: 'Lamp', - min: 0.5, - max: 6, - step: 0.1, - value: this.intensity, - onChange: value => { - this.intensity = value; - this.lamp.intensity = value; + onChange: (_key, _value, values) => { + this.moving = values.moving; + this.intensity = values.lamp; + this.softness = values.softness; + // Only the cascades bounce; under the quads the toggle changes nothing. + this.build(values.renderer === 'Radiance', values.bounce); + this.lamp.intensity = this.intensity; + this.lamp.softness = this.softness; }, }); - panel.addSlider({ - // Source size under radiance, filter width under lightmap. See the note - // at the top of the file: the two are not the same quantity, and the - // slider is labelled for neither so that the difference stays visible. - label: 'Softness', - min: 0, - max: 1, - step: 0.05, - value: this.softness, - onChange: value => { - this.softness = value; - this.lamp.softness = value; - }, - }); + this.inputs.onTrigger(Keyboard.Space, () => panel.step('moving')); } override update(delta: Seconds): void { @@ -336,7 +243,6 @@ class RadianceRoomsScene extends Scene { } this.elapsed += delta; - this.phaseControl.set((this.elapsed % loopSeconds) / loopSeconds); // Moving the lamp is the clearest way to see that nothing about the far // room is baked: the wedge through the doorway sweeps with it. this.moveLamp(); @@ -344,16 +250,6 @@ class RadianceRoomsScene extends Scene { override draw(context: RenderingContext): void { context.render(this.world); - - // Read off the lamp itself, not recomputed from the clock: the status line - // is what says two renderers were compared under the same conditions, so - // it has to report where the light actually is. - this.lamp.getWorldPosition(lampPosition); - const bounce = this.cascading && this.bounce ? 'bounce' : 'no bounce'; - - this.hud.setStatus( - `${this.lighting.quality} - ${bounce} - ${this.app.width}x${this.app.height} at ${lightResolution}x - lamp ${lampPosition.x.toFixed(1)}, ${lampPosition.y.toFixed(1)} - draw calls ${context.stats.drawCalls}`, - ); } private moveLamp(): void { @@ -366,8 +262,15 @@ class RadianceRoomsScene extends Scene { return this.lighting.lights[0] as PointLight; } - /** Build the lighting system for the renderer currently selected. */ - private build(): void { + /** Builds the lighting system for the selected renderer, unless it is already the one running. */ + private build(cascading: boolean, bounce: boolean): void { + if (this.lighting !== undefined && cascading === this.cascading && (bounce === this.bounce || !cascading)) { + return; + } + + this.cascading = cascading; + this.bounce = bounce; + if (this.lighting !== undefined) { this.systems.remove(this.lighting); this.lighting.destroy(); @@ -378,7 +281,6 @@ class RadianceRoomsScene extends Scene { this.lighting = this.cascading ? new RadianceLighting(this.app, { ...options, bounce: this.bounce ? bounceFactor : 0 }) : new LightmapLighting(this.app, options); - this.lighting.debug = this.debug; this.systems.add(this.lighting); this.lighting.add(new PointLight({ radius: 600, intensity: this.intensity, softness: this.softness, color: new Color(255, 226, 180) })); // Placed straight away rather than on the next tick: switching renderer diff --git a/examples/lighting/shadow-casters.js b/examples/lighting/shadow-casters.js index 303126755..8b9aff1c9 100644 --- a/examples/lighting/shadow-casters.js +++ b/examples/lighting/shadow-casters.js @@ -1,7 +1,7 @@ // Auto-generated from shadow-casters.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite, Texture } from '@codexo/exojs'; -import { AlphaOccluder, LightmapLighting, PointLight, SpotLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, LightmapLighting, PointLight, SpotLight } from '@codexo/exojs/lighting'; +import { demoPanel, slider, toggle } from '@examples/runtime'; // Nothing here models a shadow. Each wall registers the outline it already // has - its own rectangle, or, for the pillar, the silhouette traced out of // its alpha channel - and the lightmap renderer turns that into a shadow for @@ -54,7 +54,7 @@ class ShadowCastersScene extends Scene { beam; turntable; elapsed = 0; - hud; + panel; init() { const { width, height } = this.app; this.world = new Container(); @@ -87,28 +87,17 @@ class ShadowCastersScene extends Scene { new SpotLight({ radius: 760, angle: 28, coneSoftness: 0.35, intensity: 2.3, softness: 0.2, color: new Color(150, 210, 255) }), ); this.beam.setPosition(120, 660); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Shadow Casters', - hint: 'No object declares that it casts a shadow, and nothing here authors an outline: every caster is one line handing the lighting system the sprite it already draws.', - status: '', - }); - const panel = mountControlPanel({ title: 'Shadows', corner: 'top-right' }); - panel.addSlider({ - label: 'Softness', - min: 0, - max: 1, - step: 0.05, - value: this.torch.softness, - onChange: value => { - this.torch.softness = value; - this.beam.softness = value; + corner: 'top-right', + controls: { + softness: slider('Softness', { min: 0, max: 1, value: this.torch.softness, step: 0.05, format: value => value.toFixed(2) }), + occluders: toggle('Show occluders', false), }, - }); - panel.addToggle({ - label: 'Show occluders', - value: false, - onChange: value => { - this.lighting.debug = value ? 'occluders' : null; + onChange: (_key, _value, values) => { + this.torch.softness = values.softness; + this.beam.softness = values.softness; + this.lighting.debug = values.occluders ? 'occluders' : null; }, }); } @@ -124,7 +113,11 @@ class ShadowCastersScene extends Scene { } draw(context) { context.render(this.world); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } + destroy() { + this.panel.dispose(); + super.destroy(); } } const app = new Application({ diff --git a/examples/lighting/shadow-casters.ts b/examples/lighting/shadow-casters.ts index 4917fe9e6..e9ae82210 100644 --- a/examples/lighting/shadow-casters.ts +++ b/examples/lighting/shadow-casters.ts @@ -10,8 +10,8 @@ import { Sprite, Texture, } from '@codexo/exojs'; -import { AlphaOccluder, type Lighting, LightmapLighting, PointLight, SpotLight } from '@codexo/exojs-lighting'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { AlphaOccluder, type Lighting, LightmapLighting, PointLight, SpotLight } from '@codexo/exojs/lighting'; +import { type DemoPanel, demoPanel, slider, type SliderSpec, toggle, type ToggleSpec } from '@examples/runtime'; // Nothing here models a shadow. Each wall registers the outline it already // has - its own rectangle, or, for the pillar, the silhouette traced out of @@ -76,7 +76,7 @@ class ShadowCastersScene extends Scene { private beam!: SpotLight; private turntable!: Sprite; private elapsed = 0; - private hud!: ReturnType; + private panel!: DemoPanel<{ softness: SliderSpec; occluders: ToggleSpec }>; override init(): void { const { width, height } = this.app; @@ -120,31 +120,17 @@ class ShadowCastersScene extends Scene { ); this.beam.setPosition(120, 660); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Shadow Casters', - hint: 'No object declares that it casts a shadow, and nothing here authors an outline: every caster is one line handing the lighting system the sprite it already draws.', - status: '', - }); - - const panel = mountControlPanel({ title: 'Shadows', corner: 'top-right' }); - - panel.addSlider({ - label: 'Softness', - min: 0, - max: 1, - step: 0.05, - value: this.torch.softness, - onChange: value => { - this.torch.softness = value; - this.beam.softness = value; + corner: 'top-right', + controls: { + softness: slider('Softness', { min: 0, max: 1, value: this.torch.softness, step: 0.05, format: value => value.toFixed(2) }), + occluders: toggle('Show occluders', false), }, - }); - - panel.addToggle({ - label: 'Show occluders', - value: false, - onChange: value => { - this.lighting.debug = value ? 'occluders' : null; + onChange: (_key, _value, values) => { + this.torch.softness = values.softness; + this.beam.softness = values.softness; + this.lighting.debug = values.occluders ? 'occluders' : null; }, }); } @@ -163,7 +149,12 @@ class ShadowCastersScene extends Scene { override draw(context: RenderingContext): void { context.render(this.world); - this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + this.panel.status(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`); + } + + override destroy(): void { + this.panel.dispose(); + super.destroy(); } } diff --git a/examples/particles/bonfire.js b/examples/particles/bonfire.js index e8f63588a..553066fd9 100644 --- a/examples/particles/bonfire.js +++ b/examples/particles/bonfire.js @@ -14,8 +14,9 @@ import { ScaleOverLifetime, Turbulence, VectorRange, -} from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider } from '@examples/runtime'; class BonfireScene extends Scene { // Back to front: smoke behind the flames, embers in front. smoke; @@ -33,19 +34,18 @@ class BonfireScene extends Scene { fireX = 0; fireY = 0; panel; - hud; init() { const app = this.app; const { width, height } = app; - const flame = this.loader.get(assets.demo.textures.particleFlame); - const smoke = this.loader.get(assets.demo.textures.particleSmoke); - const star = this.loader.get(assets.demo.textures.particleStar); + const flame = this.loader.get(assets.particleFlame); + const smoke = this.loader.get(assets.particleSmoke); + const star = this.loader.get(assets.particleStar); this.fireX = width / 2; this.fireY = height * 0.74; this.drawGround(width, height); // Firelight on the ground: the soft light texture squashed into an // ellipse, so it has no hard edge. - this.glow = new Sprite(this.loader.get(assets.demo.textures.particleLight)) + this.glow = new Sprite(this.loader.get(assets.particleLight)) .setAnchor(0.5) .setBlendMode(BlendModes.Additive) .setPosition(this.fireX, this.fireY + 30); @@ -170,37 +170,23 @@ class BonfireScene extends Scene { this.winds.push(wind); this.systems.add(system); } - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Campfire', - status: 'Four particle layers: smoke, flames, a hot core and embers.', - hint: 'Each layer has its own texture, lifetime, size and colour curve. Wind pushes all of them.', - }); - this.panel = mountControlPanel({ title: 'Fire controls' }); - this.panel.addSlider({ - label: 'Intensity', - min: 0.3, - max: 1.6, - step: 0.05, - value: 1, - onChange: value => { - this.intensity = value; - this.flameRate.value = 42 * value; - this.coreRate.value = 28 * value; - this.emberRate.value = 5 * value * value; + controls: { + intensity: slider('Intensity', { min: 0.3, max: 1.6, value: 1, step: 0.05, format: value => value.toFixed(2) }), + wind: slider('Wind', { min: -180, max: 180, value: 0, step: 5, format: value => `${value}` }), }, - }); - this.panel.addSlider({ - label: 'Wind', - min: -180, - max: 180, - step: 5, - value: 0, - onChange: value => { + onChange: (_key, _value, values) => { + const { intensity, wind } = values; + this.intensity = intensity; + this.flameRate.value = 42 * intensity; + this.coreRate.value = 28 * intensity; + this.emberRate.value = 5 * intensity * intensity; // Light particles drift further than the dense flame body. - this.winds[0].accelerationX = value * 0.8; - this.winds[1].accelerationX = value * 0.5; - this.winds[2].accelerationX = value * 0.3; - this.winds[3].accelerationX = value; + this.winds[0].accelerationX = wind * 0.8; + this.winds[1].accelerationX = wind * 0.5; + this.winds[2].accelerationX = wind * 0.3; + this.winds[3].accelerationX = wind; }, }); } @@ -237,7 +223,6 @@ class BonfireScene extends Scene { } destroy() { this.panel?.dispose(); - this.hud?.dispose(); this.ground.destroy(); this.glow.destroy(); super.destroy(); diff --git a/examples/particles/bonfire.ts b/examples/particles/bonfire.ts index c4285ad76..a5b7bb545 100644 --- a/examples/particles/bonfire.ts +++ b/examples/particles/bonfire.ts @@ -24,8 +24,9 @@ import { ScaleOverLifetime, Turbulence, VectorRange, -} from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; class BonfireScene extends Scene { // Back to front: smoke behind the flames, embers in front. @@ -43,15 +44,14 @@ class BonfireScene extends Scene { private elapsed = 0; private fireX = 0; private fireY = 0; - private panel!: ReturnType; - private hud!: ReturnType; + private panel!: DemoPanel<{ intensity: SliderSpec; wind: SliderSpec }>; override init(): void { const app = this.app; const { width, height } = app; - const flame = this.loader.get(assets.demo.textures.particleFlame); - const smoke = this.loader.get(assets.demo.textures.particleSmoke); - const star = this.loader.get(assets.demo.textures.particleStar); + const flame = this.loader.get(assets.particleFlame); + const smoke = this.loader.get(assets.particleSmoke); + const star = this.loader.get(assets.particleStar); this.fireX = width / 2; this.fireY = height * 0.74; @@ -59,7 +59,7 @@ class BonfireScene extends Scene { // Firelight on the ground: the soft light texture squashed into an // ellipse, so it has no hard edge. - this.glow = new Sprite(this.loader.get(assets.demo.textures.particleLight)) + this.glow = new Sprite(this.loader.get(assets.particleLight)) .setAnchor(0.5) .setBlendMode(BlendModes.Additive) .setPosition(this.fireX, this.fireY + 30); @@ -191,37 +191,24 @@ class BonfireScene extends Scene { this.systems.add(system); } - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Campfire', - status: 'Four particle layers: smoke, flames, a hot core and embers.', - hint: 'Each layer has its own texture, lifetime, size and colour curve. Wind pushes all of them.', - }); - this.panel = mountControlPanel({ title: 'Fire controls' }); - this.panel.addSlider({ - label: 'Intensity', - min: 0.3, - max: 1.6, - step: 0.05, - value: 1, - onChange: value => { - this.intensity = value; - this.flameRate.value = 42 * value; - this.coreRate.value = 28 * value; - this.emberRate.value = 5 * value * value; + controls: { + intensity: slider('Intensity', { min: 0.3, max: 1.6, value: 1, step: 0.05, format: value => value.toFixed(2) }), + wind: slider('Wind', { min: -180, max: 180, value: 0, step: 5, format: value => `${value}` }), }, - }); - this.panel.addSlider({ - label: 'Wind', - min: -180, - max: 180, - step: 5, - value: 0, - onChange: value => { + onChange: (_key, _value, values) => { + const { intensity, wind } = values; + + this.intensity = intensity; + this.flameRate.value = 42 * intensity; + this.coreRate.value = 28 * intensity; + this.emberRate.value = 5 * intensity * intensity; // Light particles drift further than the dense flame body. - this.winds[0].accelerationX = value * 0.8; - this.winds[1].accelerationX = value * 0.5; - this.winds[2].accelerationX = value * 0.3; - this.winds[3].accelerationX = value; + this.winds[0].accelerationX = wind * 0.8; + this.winds[1].accelerationX = wind * 0.5; + this.winds[2].accelerationX = wind * 0.3; + this.winds[3].accelerationX = wind; }, }); } @@ -267,7 +254,6 @@ class BonfireScene extends Scene { override destroy(): void { this.panel?.dispose(); - this.hud?.dispose(); this.ground.destroy(); this.glow.destroy(); super.destroy(); diff --git a/examples/particles/cursor-attractor-particles.js b/examples/particles/cursor-attractor-particles.js index 6981ae941..348626790 100644 --- a/examples/particles/cursor-attractor-particles.js +++ b/examples/particles/cursor-attractor-particles.js @@ -9,9 +9,10 @@ import { ParticleSystem, RateSpawn, RepelFromPoint, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; -// Acceleration magnitude shared by both force modules (units / s²). Only the +// Acceleration magnitude shared by both force modules (units per second squared). Only the // active mode's module carries this strength; the inactive one is held at 0. const forceStrength = 700; class CursorAttractorParticlesScene extends Scene { @@ -23,7 +24,7 @@ class CursorAttractorParticlesScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 32000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 32000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height / 2); this.system.addSpawnModule( @@ -44,28 +45,22 @@ class CursorAttractorParticlesScene extends Scene { this.system.addUpdateModule(this.attractor); this.system.addUpdateModule(this.repeller); this.system.addUpdateModule(new AlphaFadeOverLifetime()); - app.input.onPointerMove.add(pointer => { - const localX = pointer.x - this.system.position.x; - const localY = pointer.y - this.system.position.y; - this.attractor.x = localX; - this.attractor.y = localY; - this.repeller.x = localX; - this.repeller.y = localY; - }); - this.hud = mountControls({ - title: 'Cursor Attractor Particles', - controls: [ - { keys: 'Move', action: 'steer the force field' }, - { keys: 'Toggle', action: 'switch attract / repel' }, - ], - status: 'Mode: Attract', - hint: 'Move the cursor over the canvas to drag the particle field around.', - }); - app.input.onPointerDown.add(() => { - this.setMode(this.mode === 'attract' ? 'repel' : 'attract'); - }); + this.hud = mountControls({ title: 'Cursor Attractor Particles', status: 'Mode: Attract' }); + app.input.onPointerMove.add(this.onPointerMove); + app.input.onPointerDown.add(this.onPointerDown); this.setMode('attract'); } + onPointerMove = pointer => { + const localX = pointer.x - this.system.position.x; + const localY = pointer.y - this.system.position.y; + this.attractor.x = localX; + this.attractor.y = localY; + this.repeller.x = localX; + this.repeller.y = localY; + }; + onPointerDown = () => { + this.setMode(this.mode === 'attract' ? 'repel' : 'attract'); + }; setMode(mode) { this.mode = mode; if (mode === 'attract') { @@ -81,6 +76,12 @@ class CursorAttractorParticlesScene extends Scene { draw(context) { context.render(this.system); } + destroy() { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { CursorAttractorParticlesScene }, diff --git a/examples/particles/cursor-attractor-particles.ts b/examples/particles/cursor-attractor-particles.ts index f84e494c5..20ddcdd71 100644 --- a/examples/particles/cursor-attractor-particles.ts +++ b/examples/particles/cursor-attractor-particles.ts @@ -8,10 +8,11 @@ import { ParticleSystem, RateSpawn, RepelFromPoint, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; -// Acceleration magnitude shared by both force modules (units / s²). Only the +// Acceleration magnitude shared by both force modules (units per second squared). Only the // active mode's module carries this strength; the inactive one is held at 0. const forceStrength = 700; @@ -26,7 +27,7 @@ class CursorAttractorParticlesScene extends Scene { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 32000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 32000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height / 2); @@ -51,33 +52,27 @@ class CursorAttractorParticlesScene extends Scene { this.system.addUpdateModule(this.repeller); this.system.addUpdateModule(new AlphaFadeOverLifetime()); - app.input.onPointerMove.add(pointer => { - const localX = pointer.x - this.system.position.x; - const localY = pointer.y - this.system.position.y; - - this.attractor.x = localX; - this.attractor.y = localY; - this.repeller.x = localX; - this.repeller.y = localY; - }); - - this.hud = mountControls({ - title: 'Cursor Attractor Particles', - controls: [ - { keys: 'Move', action: 'steer the force field' }, - { keys: 'Toggle', action: 'switch attract / repel' }, - ], - status: 'Mode: Attract', - hint: 'Move the cursor over the canvas to drag the particle field around.', - }); - - app.input.onPointerDown.add(() => { - this.setMode(this.mode === 'attract' ? 'repel' : 'attract'); - }); + this.hud = mountControls({ title: 'Cursor Attractor Particles', status: 'Mode: Attract' }); + app.input.onPointerMove.add(this.onPointerMove); + app.input.onPointerDown.add(this.onPointerDown); this.setMode('attract'); } + private readonly onPointerMove = (pointer: { x: number; y: number }): void => { + const localX = pointer.x - this.system.position.x; + const localY = pointer.y - this.system.position.y; + + this.attractor.x = localX; + this.attractor.y = localY; + this.repeller.x = localX; + this.repeller.y = localY; + }; + + private readonly onPointerDown = (): void => { + this.setMode(this.mode === 'attract' ? 'repel' : 'attract'); + }; + private setMode(mode: 'attract' | 'repel'): void { this.mode = mode; @@ -95,6 +90,13 @@ class CursorAttractorParticlesScene extends Scene { override draw(context: RenderingContext): void { context.render(this.system); } + + override destroy(): void { + this.app.input.onPointerMove.remove(this.onPointerMove); + this.app.input.onPointerDown.remove(this.onPointerDown); + this.hud.dispose(); + super.destroy(); + } } const app = new Application({ diff --git a/examples/particles/custom-wgsl-module.js b/examples/particles/custom-wgsl-module.js index 9befd47b5..6632f99c4 100644 --- a/examples/particles/custom-wgsl-module.js +++ b/examples/particles/custom-wgsl-module.js @@ -1,6 +1,7 @@ // Auto-generated from custom-wgsl-module.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Vector } from '@codexo/exojs'; -import { Constant, particlesExtension, ParticleSystem, RateSpawn, UpdateModule } from '@codexo/exojs-particles'; +import { Constant, particlesExtension, ParticleSystem, RateSpawn, UpdateModule } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; /** Adds horizontal sway after integration, using the same operation on every backend. */ class SwayModule extends UpdateModule { @@ -45,7 +46,7 @@ class CustomWgslModuleScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 26000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 26000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 60); this.system.addSpawnModule( @@ -57,14 +58,9 @@ class CustomWgslModuleScene extends Scene { }), ); this.system.addUpdateModule(new SwayModule(250, 8)); - this.hud = mountControls({ - title: 'Custom Particle Module', - controls: [{ keys: 'Auto', action: 'CPU / WebGL2 transform feedback / WebGPU compute' }], - // GPU vs CPU routing is decided on the first update() once the - // backend is known - show "detecting" until then. - status: 'Compute path: detecting…', - hint: 'SwayModule provides apply(), glsl() and wgsl() with shared uniforms. Inspect the selected simulation path below.', - }); + // The simulation path (CPU, WebGL2 transform feedback or WebGPU compute) + // is chosen on the first update, once the backend is known. + this.hud = mountControls({ status: 'Simulation: detecting...' }); } update(_delta) { this.hud.setStatus(`Simulation: ${this.system.simulationBackend}`); diff --git a/examples/particles/custom-wgsl-module.ts b/examples/particles/custom-wgsl-module.ts index bd020c76f..c824283af 100644 --- a/examples/particles/custom-wgsl-module.ts +++ b/examples/particles/custom-wgsl-module.ts @@ -8,7 +8,8 @@ import { RateSpawn, UpdateModule, type WgslContribution, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; /** Adds horizontal sway after integration, using the same operation on every backend. */ @@ -63,7 +64,7 @@ class CustomWgslModuleScene extends Scene { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 26000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 26000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 60); this.system.addSpawnModule( @@ -76,14 +77,9 @@ class CustomWgslModuleScene extends Scene { ); this.system.addUpdateModule(new SwayModule(250, 8)); - this.hud = mountControls({ - title: 'Custom Particle Module', - controls: [{ keys: 'Auto', action: 'CPU / WebGL2 transform feedback / WebGPU compute' }], - // GPU vs CPU routing is decided on the first update() once the - // backend is known - show "detecting" until then. - status: 'Compute path: detecting…', - hint: 'SwayModule provides apply(), glsl() and wgsl() with shared uniforms. Inspect the selected simulation path below.', - }); + // The simulation path (CPU, WebGL2 transform feedback or WebGPU compute) + // is chosen on the first update, once the backend is known. + this.hud = mountControls({ status: 'Simulation: detecting...' }); } override update(_delta: Seconds): void { diff --git a/examples/particles/emitter-basics.js b/examples/particles/emitter-basics.js index 1d4ec68dc..e059e601b 100644 --- a/examples/particles/emitter-basics.js +++ b/examples/particles/emitter-basics.js @@ -12,19 +12,18 @@ import { Range, RateSpawn, ScaleOverLifetime, -} from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class EmitterBasicsScene extends Scene { system; - hud; init() { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 4000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 4000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that - // shoots upward (-π/2) with a ±36° spread and 280-460 px/s speed. + // shoots upward (-pi/2) with a +/-36 degree spread and 280-460 px/s speed. this.system.addSpawnModule( new RateSpawn({ rate: new Constant(180), @@ -46,7 +45,7 @@ class EmitterBasicsScene extends Scene { ]), ), ); - // Colour gradient over lifetime: white-hot core → orange → deep red, then + // Colour gradient over lifetime: white-hot core, then orange, then deep red, then // fading to transparent so particles dissolve at the end of their life. this.system.addUpdateModule( new ColorOverLifetime( @@ -58,12 +57,6 @@ class EmitterBasicsScene extends Scene { ]), ), ); - this.hud = mountControls({ - title: 'Emitter Basics', - controls: [{ keys: 'Auto', action: 'continuous fountain emission' }], - status: 'Rate 180/s · lifetime 0.6–1.4s · gravity', - hint: 'RateSpawn drives emission; ScaleOverLifetime sizes and ColorOverLifetime tints each particle as it ages.', - }); } draw(context) { context.render(this.system); diff --git a/examples/particles/emitter-basics.ts b/examples/particles/emitter-basics.ts index d6163bef8..6d3af8cde 100644 --- a/examples/particles/emitter-basics.ts +++ b/examples/particles/emitter-basics.ts @@ -11,23 +11,22 @@ import { Range, RateSpawn, ScaleOverLifetime, -} from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class EmitterBasicsScene extends Scene { private system!: ParticleSystem; - private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; - this.system = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 4000 }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 4000 }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); // Rate, lifetime, and a cone-shaped velocity spread: a fountain that - // shoots upward (-π/2) with a ±36° spread and 280-460 px/s speed. + // shoots upward (-pi/2) with a +/-36 degree spread and 280-460 px/s speed. this.system.addSpawnModule( new RateSpawn({ rate: new Constant(180), @@ -52,7 +51,7 @@ class EmitterBasicsScene extends Scene { ), ); - // Colour gradient over lifetime: white-hot core → orange → deep red, then + // Colour gradient over lifetime: white-hot core, then orange, then deep red, then // fading to transparent so particles dissolve at the end of their life. this.system.addUpdateModule( new ColorOverLifetime( @@ -64,13 +63,6 @@ class EmitterBasicsScene extends Scene { ]), ), ); - - this.hud = mountControls({ - title: 'Emitter Basics', - controls: [{ keys: 'Auto', action: 'continuous fountain emission' }], - status: 'Rate 180/s · lifetime 0.6–1.4s · gravity', - hint: 'RateSpawn drives emission; ScaleOverLifetime sizes and ColorOverLifetime tints each particle as it ages.', - }); } override draw(context: RenderingContext): void { diff --git a/examples/particles/fireworks.js b/examples/particles/fireworks.js index 5baa9d64e..6d1e994a3 100644 --- a/examples/particles/fireworks.js +++ b/examples/particles/fireworks.js @@ -27,8 +27,8 @@ import { ScaleOverLifetime, Turbulence, VectorRange, -} from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const random = new Random(2026); const autoLaunchInterval = Time.seconds(1.6); // Upward launch speed range (px/s, negative = up). Higher = higher apex. @@ -68,13 +68,11 @@ class FireworksScene extends Scene { autoLaunchTimer; rocketTexture; rockets = []; - launchCount = 0; - hud; init() { const app = this.app; const { width, height } = app; - const star = this.loader.get(assets.demo.textures.particleStar); - const light = this.loader.get(assets.demo.textures.particleLight); + const star = this.loader.get(assets.particleStar); + const light = this.loader.get(assets.particleLight); this.canvasSize = new Size(width, height); this.rocketTexture = star; // 1. Flash: one large, very short-lived glow that sells the detonation. @@ -111,7 +109,7 @@ class FireworksScene extends Scene { position: new Constant(this.burstPosition), // A narrow speed band puts the sparks on one expanding shell. velocity: ConeDirection.omni(200, 320), - scale: new Constant(new Vector(0.1, 0.1)), + scale: new Constant(new Vector(0.2, 0.2)), tint: new Constant(fireworkColors[0]), }); const sparks = createLayer(star, this.sparkBurst, { capacity: 4096 }); @@ -120,8 +118,8 @@ class FireworksScene extends Scene { sparks.addUpdateModule( new ScaleOverLifetime( new Curve([ - { t: 0, v: 0.12 }, - { t: 1, v: 0.04 }, + { t: 0, v: 0.2 }, + { t: 1, v: 0.06 }, ]), ), ); @@ -140,7 +138,7 @@ class FireworksScene extends Scene { lifetime: new Range(2.2, 3.2), position: new Constant(this.burstPosition), velocity: ConeDirection.omni(15, 80), - scale: new Constant(new Vector(0.06, 0.06)), + scale: new Constant(new Vector(0.1, 0.1)), tint: new Constant(new Color(255, 214, 150)), }); const embers = createLayer(star, this.emberBurst, { capacity: 1024 }); @@ -165,12 +163,6 @@ class FireworksScene extends Scene { app.input.onPointerDown.add(this.onPointerDown); // Ambient fallback: keep the sky alive even without clicks. this.autoLaunchTimer = new Timer(autoLaunchInterval, true); - this.hud = mountControls({ - title: 'Fireworks', - controls: [{ keys: 'Click', action: 'launch a rocket' }], - status: 'Launched: 0 · in flight: 0', - hint: 'Three layers per burst: a short flash, sparks that brake and fall, and slow embers that linger.', - }); this.launchRocket(width * 0.5); } // Application input outlives the scene, so the handler is removed in destroy(). @@ -188,8 +180,6 @@ class FireworksScene extends Scene { color, trail: [], }); - this.launchCount++; - this.updateStatus(); } detonate(rocket) { this.burstPosition.set(rocket.position.x, rocket.position.y); @@ -201,9 +191,6 @@ class FireworksScene extends Scene { this.sparkBurst.reset(); this.emberBurst.reset(); } - updateStatus() { - this.hud.setStatus(`Launched: ${this.launchCount} · in flight: ${this.rockets.length}`); - } update(delta) { if (this.autoLaunchTimer.expired) { this.launchRocket(random.next(120, this.canvasSize.width - 120)); @@ -224,7 +211,6 @@ class FireworksScene extends Scene { this.detonate(rocket); rocket.sprite.destroy(); this.rockets.splice(i, 1); - this.updateStatus(); } } } @@ -250,7 +236,6 @@ class FireworksScene extends Scene { } destroy() { this.app.input.onPointerDown.remove(this.onPointerDown); - this.hud.dispose(); this.trails.destroy(); for (const rocket of this.rockets) { rocket.sprite.destroy(); diff --git a/examples/particles/fireworks.ts b/examples/particles/fireworks.ts index 5b90d75bb..1c3cbe4ff 100644 --- a/examples/particles/fireworks.ts +++ b/examples/particles/fireworks.ts @@ -30,8 +30,8 @@ import { ScaleOverLifetime, Turbulence, VectorRange, -} from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const random = new Random(2026); const autoLaunchInterval = Time.seconds(1.6); @@ -85,14 +85,12 @@ class FireworksScene extends Scene { private autoLaunchTimer!: Timer; private rocketTexture!: Texture; private rockets: Rocket[] = []; - private launchCount = 0; - private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; - const star = this.loader.get(assets.demo.textures.particleStar); - const light = this.loader.get(assets.demo.textures.particleLight); + const star = this.loader.get(assets.particleStar); + const light = this.loader.get(assets.particleLight); this.canvasSize = new Size(width, height); this.rocketTexture = star; @@ -132,7 +130,7 @@ class FireworksScene extends Scene { position: new Constant(this.burstPosition), // A narrow speed band puts the sparks on one expanding shell. velocity: ConeDirection.omni(200, 320), - scale: new Constant(new Vector(0.1, 0.1)), + scale: new Constant(new Vector(0.2, 0.2)), tint: new Constant(fireworkColors[0]), }); const sparks = createLayer(star, this.sparkBurst, { capacity: 4096 }); @@ -141,8 +139,8 @@ class FireworksScene extends Scene { sparks.addUpdateModule( new ScaleOverLifetime( new Curve([ - { t: 0, v: 0.12 }, - { t: 1, v: 0.04 }, + { t: 0, v: 0.2 }, + { t: 1, v: 0.06 }, ]), ), ); @@ -162,7 +160,7 @@ class FireworksScene extends Scene { lifetime: new Range(2.2, 3.2), position: new Constant(this.burstPosition), velocity: ConeDirection.omni(15, 80), - scale: new Constant(new Vector(0.06, 0.06)), + scale: new Constant(new Vector(0.1, 0.1)), tint: new Constant(new Color(255, 214, 150)), }); const embers = createLayer(star, this.emberBurst, { capacity: 1024 }); @@ -192,13 +190,6 @@ class FireworksScene extends Scene { // Ambient fallback: keep the sky alive even without clicks. this.autoLaunchTimer = new Timer(autoLaunchInterval, true); - this.hud = mountControls({ - title: 'Fireworks', - controls: [{ keys: 'Click', action: 'launch a rocket' }], - status: 'Launched: 0 · in flight: 0', - hint: 'Three layers per burst: a short flash, sparks that brake and fall, and slow embers that linger.', - }); - this.launchRocket(width * 0.5); } @@ -222,9 +213,6 @@ class FireworksScene extends Scene { color, trail: [], }); - - this.launchCount++; - this.updateStatus(); } private detonate(rocket: Rocket): void { @@ -238,10 +226,6 @@ class FireworksScene extends Scene { this.emberBurst.reset(); } - private updateStatus(): void { - this.hud.setStatus(`Launched: ${this.launchCount} · in flight: ${this.rockets.length}`); - } - override update(delta: Seconds): void { if (this.autoLaunchTimer.expired) { this.launchRocket(random.next(120, this.canvasSize.width - 120)); @@ -266,7 +250,6 @@ class FireworksScene extends Scene { this.detonate(rocket); rocket.sprite.destroy(); this.rockets.splice(i, 1); - this.updateStatus(); } } } @@ -301,7 +284,6 @@ class FireworksScene extends Scene { override destroy(): void { this.app.input.onPointerDown.remove(this.onPointerDown); - this.hud.dispose(); this.trails.destroy(); for (const rocket of this.rockets) { diff --git a/examples/particles/gpu-particles.js b/examples/particles/gpu-particles.js index e8851caf9..09ee94502 100644 --- a/examples/particles/gpu-particles.js +++ b/examples/particles/gpu-particles.js @@ -9,8 +9,9 @@ import { ParticleSystem, Range, RateSpawn, -} from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, slider } from '@examples/runtime'; // Illustrative loads for the two GPU backends; measure on target hardware. const budgets = { webgpu: { capacity: 320_000, rate: 75_000 }, @@ -18,7 +19,7 @@ const budgets = { }; class GpuParticlesScene extends Scene { system; - hud; + panel; capacity = 0; spawnRate; frameIntervalMs = 0; @@ -30,7 +31,7 @@ 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.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( @@ -43,17 +44,18 @@ class GpuParticlesScene extends Scene { ); this.system.addUpdateModule(new ApplyForce(0, 320)); this.system.addUpdateModule(new AlphaFadeOverLifetime()); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Particle Capacity', - hint: 'Quad simulation uses WebGPU compute or WebGL2 transform feedback when eligible. Frame interval includes the whole application.', - }); - mountControlPanel({ title: 'Load' }).addSlider({ - label: 'Spawn / second', - min: 0, - max: rate, - step: isWebGpu ? 5000 : 250, - value: rate, - onChange: value => { + controls: { + rate: slider('Spawn / second', { + min: 0, + max: rate, + value: rate, + step: isWebGpu ? 5000 : 250, + format: value => value.toLocaleString(), + }), + }, + onChange: (_key, value) => { this.spawnRate.value = value; }, }); @@ -64,13 +66,17 @@ class GpuParticlesScene extends Scene { // slow frame; the raw frame-to-frame delta is what the display actually got. const { rawFrameDeltaMs } = this.app.backend.stats; this.frameIntervalMs = this.frameIntervalMs * 0.9 + rawFrameDeltaMs * 0.1; - this.hud.setStatus( - `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${this.spawnRate.value.toLocaleString()}/s · ${this.frameIntervalMs.toFixed(1)} ms between frames · ${backend}`, + this.panel.status( + `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap, ${this.frameIntervalMs.toFixed(1)} ms between frames, ${backend}`, ); } draw(context) { context.render(this.system); } + destroy() { + this.panel.dispose(); + super.destroy(); + } } const app = new Application({ scenes: { GpuParticlesScene }, @@ -81,9 +87,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); await app.start(GpuParticlesScene); diff --git a/examples/particles/gpu-particles.ts b/examples/particles/gpu-particles.ts index 70af874e0..7f4e66cfd 100644 --- a/examples/particles/gpu-particles.ts +++ b/examples/particles/gpu-particles.ts @@ -8,8 +8,9 @@ import { ParticleSystem, Range, RateSpawn, -} from '@codexo/exojs-particles'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; // Illustrative loads for the two GPU backends; measure on target hardware. const budgets = { @@ -19,7 +20,7 @@ const budgets = { class GpuParticlesScene extends Scene { private system!: ParticleSystem; - private hud!: ReturnType; + private panel!: DemoPanel<{ rate: SliderSpec }>; private capacity = 0; private spawnRate!: Constant; private frameIntervalMs = 0; @@ -33,7 +34,7 @@ class GpuParticlesScene extends Scene { this.capacity = capacity; this.spawnRate = new Constant(rate); - this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity }); + this.system = new ParticleSystem(this.loader.get(assets.particleLight), { capacity }); this.systems.add(this.system); this.system.setPosition(width / 2, height - 80); this.system.addSpawnModule( @@ -47,17 +48,18 @@ class GpuParticlesScene extends Scene { this.system.addUpdateModule(new ApplyForce(0, 320)); this.system.addUpdateModule(new AlphaFadeOverLifetime()); - this.hud = mountControls({ + this.panel = demoPanel({ title: 'Particle Capacity', - hint: 'Quad simulation uses WebGPU compute or WebGL2 transform feedback when eligible. Frame interval includes the whole application.', - }); - mountControlPanel({ title: 'Load' }).addSlider({ - label: 'Spawn / second', - min: 0, - max: rate, - step: isWebGpu ? 5000 : 250, - value: rate, - onChange: value => { + controls: { + rate: slider('Spawn / second', { + min: 0, + max: rate, + value: rate, + step: isWebGpu ? 5000 : 250, + format: value => value.toLocaleString(), + }), + }, + onChange: (_key, value) => { this.spawnRate.value = value; }, }); @@ -70,14 +72,19 @@ class GpuParticlesScene extends Scene { const { rawFrameDeltaMs } = this.app.backend.stats; this.frameIntervalMs = this.frameIntervalMs * 0.9 + rawFrameDeltaMs * 0.1; - this.hud.setStatus( - `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${this.spawnRate.value.toLocaleString()}/s · ${this.frameIntervalMs.toFixed(1)} ms between frames · ${backend}`, + this.panel.status( + `${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap, ${this.frameIntervalMs.toFixed(1)} ms between frames, ${backend}`, ); } override draw(context: RenderingContext): void { context.render(this.system); } + + override destroy(): void { + this.panel.dispose(); + super.destroy(); + } } const app = new Application({ @@ -89,9 +96,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); diff --git a/examples/pathfinding/grid-navigation.js b/examples/pathfinding/grid-navigation.js index 5037f10b7..c8ca22fee 100644 --- a/examples/pathfinding/grid-navigation.js +++ b/examples/pathfinding/grid-navigation.js @@ -1,7 +1,7 @@ // Auto-generated from grid-navigation.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene } from '@codexo/exojs'; -import { GridSpace, Pathfinder } from '@codexo/exojs-pathfinding'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { GridSpace, Pathfinder } from '@codexo/exojs/pathfinding'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Click-to-move over a weighted grid. Everything here is plain data: a GridSpace // of costs and a Pathfinder, neither of which is a scene node, neither of which // is registered with the Application. The Graphics below only *draws* what the @@ -51,38 +51,44 @@ class GridNavigationScene extends Scene { pruning = true; agentSize = 1; diagonals = 'no-corner-cutting'; - hud; + panel; init() { this.app.input.onPointerTap.add(pointer => this.applyPaint(pointer.x, pointer.y)); - this.hud = mountControls({ - title: 'Grid Navigation', - controls: [ - { keys: 'Click', action: 'set the goal (or paint, see panel)' }, - { keys: 'panel', action: 'pruning / smoothing / agent size' }, - ], - status: '', - hint: 'Painting mud makes the grid non-uniform, which turns jump-point pruning off by itself — the expanded-node counter jumps.', - }); - const panel = mountControlPanel({ title: 'Pathfinding' }); - panel.addCycle({ - label: 'Click paints', - options: ['goal', 'wall', 'mud'], - index: 0, - onChange: (_index, value) => (this.paint = value), - }); - panel.addToggle({ label: 'Jump-point pruning', value: true, onChange: value => this.replan(() => (this.pruning = value)) }); - panel.addToggle({ label: 'Smooth path', value: false, onChange: value => this.replan(() => (this.smooth = value)) }); - panel.addToggle({ label: '2x2 agent', value: false, onChange: value => this.replan(() => (this.agentSize = value ? 2 : 1)) }); - panel.addCycle({ - label: 'Diagonals', - options: ['no-corner-cutting', 'never', 'always'], - index: 0, - // The diagonal policy is fixed at construction, so changing it rebuilds - // the window from the costs the current one holds. - onChange: (_index, value) => { - const previous = this.grid; - this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { cellSize: CELL, diagonals: value }); - this.diagonals = value; + this.panel = demoPanel({ + title: 'Pathfinding', + controls: { + paint: choice('Click paints', ['goal', 'wall', 'mud']), + pruning: toggle('Jump-point pruning', true), + smooth: toggle('Smooth path', false), + bigAgent: toggle('2x2 agent', false), + diagonals: choice('Diagonals', ['no-corner-cutting', 'never', 'always']), + }, + onChange: (key, value) => { + switch (key) { + case 'paint': + this.paint = value; + return; + case 'pruning': + this.pruning = value; + break; + case 'smooth': + this.smooth = value; + break; + case 'bigAgent': + this.agentSize = value ? 2 : 1; + break; + case 'diagonals': { + // The diagonal policy is fixed at construction, so changing it + // rebuilds the window from the costs the current one holds. + const previous = this.grid; + this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { + cellSize: CELL, + diagonals: value, + }); + this.diagonals = value; + break; + } + } this.replan(); }, }); @@ -162,10 +168,14 @@ class GridNavigationScene extends Scene { this.waypoint = 0; const { status, cost, nodes, expandedNodes } = this.result; const pruned = this.pruning && this.grid.pruning(this.agentSize) !== null; - this.hud.setStatus( - `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'} · ${this.diagonals}`, + this.panel?.status( + `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'}`, ); } + destroy() { + this.panel?.dispose(); + super.destroy(); + } drawTerrain() { this.terrain.clear(); for (let y = 0; y < ROWS; y++) { diff --git a/examples/pathfinding/grid-navigation.ts b/examples/pathfinding/grid-navigation.ts index 5201e855e..d88d137d5 100644 --- a/examples/pathfinding/grid-navigation.ts +++ b/examples/pathfinding/grid-navigation.ts @@ -1,6 +1,6 @@ import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds } from '@codexo/exojs'; -import { type DiagonalPolicy, GridSpace, Pathfinder, type PathResult } from '@codexo/exojs-pathfinding'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { type DiagonalPolicy, GridSpace, Pathfinder, type PathResult } from '@codexo/exojs/pathfinding'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Click-to-move over a weighted grid. Everything here is plain data: a GridSpace // of costs and a Pathfinder, neither of which is a scene node, neither of which @@ -60,43 +60,49 @@ class GridNavigationScene extends Scene { private pruning = true; private agentSize = 1; private diagonals: DiagonalPolicy = 'no-corner-cutting'; - private hud!: ReturnType; + private panel?: ReturnType; override init(): void { this.app.input.onPointerTap.add(pointer => this.applyPaint(pointer.x, pointer.y)); - this.hud = mountControls({ - title: 'Grid Navigation', - controls: [ - { keys: 'Click', action: 'set the goal (or paint, see panel)' }, - { keys: 'panel', action: 'pruning / smoothing / agent size' }, - ], - status: '', - hint: 'Painting mud makes the grid non-uniform, which turns jump-point pruning off by itself — the expanded-node counter jumps.', - }); - - const panel = mountControlPanel({ title: 'Pathfinding' }); + this.panel = demoPanel({ + title: 'Pathfinding', + controls: { + paint: choice('Click paints', ['goal', 'wall', 'mud']), + pruning: toggle('Jump-point pruning', true), + smooth: toggle('Smooth path', false), + bigAgent: toggle('2x2 agent', false), + diagonals: choice('Diagonals', ['no-corner-cutting', 'never', 'always']), + }, + onChange: (key, value) => { + switch (key) { + case 'paint': + this.paint = value as PaintMode; + + return; + case 'pruning': + this.pruning = value as boolean; + break; + case 'smooth': + this.smooth = value as boolean; + break; + case 'bigAgent': + this.agentSize = value ? 2 : 1; + break; + case 'diagonals': { + // The diagonal policy is fixed at construction, so changing it + // rebuilds the window from the costs the current one holds. + const previous = this.grid; + + this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { + cellSize: CELL, + diagonals: value as DiagonalPolicy, + }); + this.diagonals = value as DiagonalPolicy; + break; + } + } - panel.addCycle({ - label: 'Click paints', - options: ['goal', 'wall', 'mud'], - index: 0, - onChange: (_index, value) => (this.paint = value as PaintMode), - }); - panel.addToggle({ label: 'Jump-point pruning', value: true, onChange: value => this.replan(() => (this.pruning = value)) }); - panel.addToggle({ label: 'Smooth path', value: false, onChange: value => this.replan(() => (this.smooth = value)) }); - panel.addToggle({ label: '2x2 agent', value: false, onChange: value => this.replan(() => (this.agentSize = value ? 2 : 1)) }); - panel.addCycle({ - label: 'Diagonals', - options: ['no-corner-cutting', 'never', 'always'], - index: 0, - // The diagonal policy is fixed at construction, so changing it rebuilds - // the window from the costs the current one holds. - onChange: (_index, value) => { - const previous = this.grid; - - this.grid = GridSpace.from(COLUMNS, ROWS, (x, y) => previous.costAt(x, y), { cellSize: CELL, diagonals: value as DiagonalPolicy }); - this.diagonals = value as DiagonalPolicy; this.replan(); }, }); @@ -198,11 +204,16 @@ class GridNavigationScene extends Scene { const { status, cost, nodes, expandedNodes } = this.result; const pruned = this.pruning && this.grid.pruning(this.agentSize) !== null; - this.hud.setStatus( - `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'} · ${this.diagonals}`, + this.panel?.status( + `${status} · cost ${cost.toFixed(1)} · ${nodes.length} waypoints · ${expandedNodes} nodes expanded · ${pruned ? 'jump-point' : 'plain A*'}`, ); } + override destroy(): void { + this.panel?.dispose(); + super.destroy(); + } + private drawTerrain(): void { this.terrain.clear(); diff --git a/examples/pathfinding/tilemap-navigation.js b/examples/pathfinding/tilemap-navigation.js index 47aa20c48..db12a680f 100644 --- a/examples/pathfinding/tilemap-navigation.js +++ b/examples/pathfinding/tilemap-navigation.js @@ -2,7 +2,6 @@ import { ActionMap, Application, - Asset, BehaviorComponent, Color, Container, @@ -15,10 +14,11 @@ import { Vector, VectorAction, } from '@codexo/exojs'; -import { NavigationAgentComponent, TileNavigationSpace, WaypointGraph } from '@codexo/exojs-pathfinding'; -import { CircleShape, KinematicMovementComponent, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; -import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs-tilemap'; -import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; +import { NavigationAgentComponent, TileNavigationSpace, WaypointGraph } from '@codexo/exojs/pathfinding'; +import { CircleShape, KinematicMovementComponent, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs/physics'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs/tilemap'; +import { TileColliderStreamer } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Navigation, movement and collision over one tile map, with no package // depending on another: @@ -121,7 +121,7 @@ class TilemapNavigationScene extends Scene { }); hud; async load() { - const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const texture = await this.loader.load(assets.mapPackTilesheet); const tileset = new TileSet({ name: 'map', texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), @@ -230,22 +230,14 @@ class TilemapNavigationScene extends Scene { this.patrol.agent.onStatusChange.add(this.onPatrolStatus); this.advancePatrol(); this.app.input.onPointerTap.add(this.onTap); - this.hud = mountControls({ - title: 'Tilemap Navigation', - controls: [ - { keys: 'WASD / Arrows', action: 'move the player' }, - { keys: 'Click', action: 'open or close a tile' }, - ], - status: '', - hint: 'Hunters re-route when you move to another tile or edit the map; the blue patroller follows a waypoint graph.', - }); + this.hud = mountControls(); } update() { // Rebuilds only the collider blocks whose tiles changed; the navigation // space synchronises itself when the agents read it. this.colliders.sync(); const statuses = this.hunters.map(({ agent }) => agent.status).join(', '); - this.hud.setStatus(`hunters: ${statuses} · patrol: ${this.patrol.agent.status} · navigation revision ${this.navigation.revision}`); + this.hud.setStatus(`hunters: ${statuses} · patrol: ${this.patrol.agent.status}`); } draw(context) { const overlay = this.overlay; diff --git a/examples/pathfinding/tilemap-navigation.ts b/examples/pathfinding/tilemap-navigation.ts index f93094aa5..9dc823f8f 100644 --- a/examples/pathfinding/tilemap-navigation.ts +++ b/examples/pathfinding/tilemap-navigation.ts @@ -1,7 +1,6 @@ import { ActionMap, Application, - Asset, BehaviorComponent, Color, Container, @@ -15,8 +14,8 @@ import { Vector, VectorAction, } from '@codexo/exojs'; -import { NavigationAgentComponent, type NavigationStatus, TileNavigationSpace, WaypointGraph } from '@codexo/exojs-pathfinding'; -import { CircleShape, KinematicMovementComponent, type PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; +import { NavigationAgentComponent, type NavigationStatus, TileNavigationSpace, WaypointGraph } from '@codexo/exojs/pathfinding'; +import { CircleShape, KinematicMovementComponent, type PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs/physics'; import { type ResolvedTile, TILE_TRANSFORM_IDENTITY, @@ -25,8 +24,9 @@ import { tilemapExtension, type TileMapView, TileSet, -} from '@codexo/exojs-tilemap'; -import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; +} from '@codexo/exojs/tilemap'; +import { TileColliderStreamer } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Navigation, movement and collision over one tile map, with no package @@ -149,7 +149,7 @@ class TilemapNavigationScene extends Scene { private hud!: ReturnType; override async load(): Promise { - const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const texture = await this.loader.load(assets.mapPackTilesheet); const tileset = new TileSet({ name: 'map', texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), @@ -274,15 +274,7 @@ class TilemapNavigationScene extends Scene { this.advancePatrol(); this.app.input.onPointerTap.add(this.onTap); - this.hud = mountControls({ - title: 'Tilemap Navigation', - controls: [ - { keys: 'WASD / Arrows', action: 'move the player' }, - { keys: 'Click', action: 'open or close a tile' }, - ], - status: '', - hint: 'Hunters re-route when you move to another tile or edit the map; the blue patroller follows a waypoint graph.', - }); + this.hud = mountControls(); } override update(): void { @@ -292,7 +284,7 @@ class TilemapNavigationScene extends Scene { const statuses = this.hunters.map(({ agent }) => agent.status).join(', '); - this.hud.setStatus(`hunters: ${statuses} · patrol: ${this.patrol.agent.status} · navigation revision ${this.navigation.revision}`); + this.hud.setStatus(`hunters: ${statuses} · patrol: ${this.patrol.agent.status}`); } override draw(context: RenderingContext): void { diff --git a/examples/performance/backend-comparison.js b/examples/performance/backend-comparison.js index 7f8090d00..1bd6852b7 100644 --- a/examples/performance/backend-comparison.js +++ b/examples/performance/backend-comparison.js @@ -1,7 +1,7 @@ // Auto-generated from backend-comparison.ts - edit the .ts source, not this file. import { Application, Capabilities, Color, Container, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; -import { mountControls } from '@examples/runtime'; +import { choice, demoPanel, slider } from '@examples/runtime'; const MAX_SPRITES = 2400; const COUNT_STEP = 400; let spriteCount = 800; @@ -33,7 +33,7 @@ class BatchingScene extends Scene { layer; sprites = []; textures = []; - hud; + panel; init() { this.layer = new Container(); this.addChild(this.layer); @@ -47,41 +47,38 @@ class BatchingScene extends Scene { this.layer.addChild(sprite); this.sprites.push(sprite); } - this.hud = mountControls({ - title: 'Sprite Batching Laboratory', + // Keys and panel share one state; changing the backend rebuilds the whole + // application, which mounts a fresh panel from the same module state. + this.panel = demoPanel({ + title: 'Workload', 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.', + controls: { + count: slider('Sprites', { min: COUNT_STEP, max: MAX_SPRITES, step: COUNT_STEP, value: spriteCount, format: v => v.toFixed(0) }), + textures: choice('Textures', ['1', '4'], textureCount === 1 ? '1' : '4'), + backend: choice('Backend', ['webgl2', 'webgpu'], backendType), + }, + onChange: (_key, _value, values) => { + spriteCount = values.count; + textureCount = Number(values.textures); + this.sprites.forEach((sprite, index) => { + sprite.visible = index < spriteCount; + sprite.setTexture(this.textures[index % textureCount]); + }); + if (values.backend === backendType) { + return; + } + if (values.backend === 'webgpu' && !webGpuAvailable) { + this.panel.set('backend', 'webgl2'); + this.panel.status('No WebGPU adapter: staying on WebGL2.'); + return; + } + void boot(values.backend).catch(showStartupError); + }, }); - 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, () => { - 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'}`); + this.inputs.onTrigger(Keyboard.Up, () => this.panel.step('count', 1)); + this.inputs.onTrigger(Keyboard.Down, () => this.panel.step('count', -1)); + this.inputs.onTrigger(Keyboard.T, () => this.panel.step('textures')); + this.inputs.onTrigger(Keyboard.B, () => this.panel.step('backend')); } update(_delta) { const time = this.app.activeSeconds; @@ -98,7 +95,7 @@ class BatchingScene extends Scene { context.render(this.layer); } destroy() { - this.hud.dispose(); + this.panel.dispose(); super.destroy(); this.textures.forEach(texture => texture.destroy()); } diff --git a/examples/performance/backend-comparison.ts b/examples/performance/backend-comparison.ts index e85d39931..1ef713b9e 100644 --- a/examples/performance/backend-comparison.ts +++ b/examples/performance/backend-comparison.ts @@ -12,7 +12,7 @@ import { Texture, } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; -import { mountControls } from '@examples/runtime'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, slider, type SliderSpec } from '@examples/runtime'; const MAX_SPRITES = 2400; const COUNT_STEP = 400; @@ -49,7 +49,7 @@ class BatchingScene extends Scene { private layer!: Container; private sprites: Sprite[] = []; private textures: Texture[] = []; - private hud!: ReturnType; + private panel!: DemoPanel<{ count: SliderSpec; textures: ChoiceSpec<'1' | '4'>; backend: ChoiceSpec<'webgl2' | 'webgpu'> }>; override init(): void { this.layer = new Container(); @@ -66,45 +66,42 @@ class BatchingScene extends Scene { this.sprites.push(sprite); } - this.hud = mountControls({ - title: 'Sprite Batching Laboratory', + // Keys and panel share one state; changing the backend rebuilds the whole + // application, which mounts a fresh panel from the same module state. + this.panel = demoPanel({ + title: 'Workload', 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.', + controls: { + count: slider('Sprites', { min: COUNT_STEP, max: MAX_SPRITES, step: COUNT_STEP, value: spriteCount, format: v => v.toFixed(0) }), + textures: choice('Textures', ['1', '4'], textureCount === 1 ? '1' : '4'), + backend: choice('Backend', ['webgl2', 'webgpu'], backendType), + }, + onChange: (_key, _value, values) => { + spriteCount = values.count; + textureCount = Number(values.textures); + this.sprites.forEach((sprite, index) => { + sprite.visible = index < spriteCount; + sprite.setTexture(this.textures[index % textureCount]); + }); + + if (values.backend === backendType) { + return; + } + + if (values.backend === 'webgpu' && !webGpuAvailable) { + this.panel.set('backend', 'webgl2'); + this.panel.status('No WebGPU adapter: staying on WebGL2.'); + + return; + } + + void boot(values.backend).catch(showStartupError); + }, }); - 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, () => { - 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'}`); + this.inputs.onTrigger(Keyboard.Up, () => this.panel.step('count', 1)); + this.inputs.onTrigger(Keyboard.Down, () => this.panel.step('count', -1)); + this.inputs.onTrigger(Keyboard.T, () => this.panel.step('textures')); + this.inputs.onTrigger(Keyboard.B, () => this.panel.step('backend')); } override update(_delta: Seconds): void { @@ -125,7 +122,7 @@ class BatchingScene extends Scene { } override destroy(): void { - this.hud.dispose(); + this.panel.dispose(); super.destroy(); this.textures.forEach(texture => texture.destroy()); } diff --git a/examples/performance/particle-stress.js b/examples/performance/particle-stress.js index 5bd70ca69..faf81018c 100644 --- a/examples/performance/particle-stress.js +++ b/examples/performance/particle-stress.js @@ -12,7 +12,7 @@ import { ScaleOverLifetime, UpdateModule, VectorRange, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; class TintCycle extends UpdateModule { palette; next = 0; diff --git a/examples/performance/particle-stress.ts b/examples/performance/particle-stress.ts index efe371bd4..ed8b3353b 100644 --- a/examples/performance/particle-stress.ts +++ b/examples/performance/particle-stress.ts @@ -12,7 +12,7 @@ import { ScaleOverLifetime, UpdateModule, VectorRange, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; /** One emitter's tuning, as `buildSystem` reads it. */ interface EmitterConfig { diff --git a/examples/physics/character-movement.js b/examples/physics/character-movement.js index 343e8f401..67846e30c 100644 --- a/examples/physics/character-movement.js +++ b/examples/physics/character-movement.js @@ -13,6 +13,7 @@ import { Scene, SystemOrder, } from '@codexo/exojs'; +import { PhysicsDebugDraw } from '@codexo/exojs/debug'; import { BoxShape, CapsuleShape, @@ -23,8 +24,7 @@ import { PhysicsBodyComponent, PhysicsWorld, PolygonShape, -} from '@codexo/exojs-physics'; -import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; +} from '@codexo/exojs/physics'; import { mountControls } from '@examples/runtime'; // A platformer character on CharacterMovementComponent: it walks along the // ground, climbs the slope and the small step, slides off the steep face, @@ -145,16 +145,7 @@ class CharacterMovementScene extends Scene { ); this.addChild(hero); this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true }); - this.hud = mountControls({ - title: 'Character Movement', - controls: [ - { keys: 'A / D', action: 'walk' }, - { keys: 'Space / W', action: 'jump (once more in the air)' }, - { keys: 'S', action: 'drop through the one-way platform' }, - ], - status: '', - hint: 'Climb the 30° slope and the low step; the steep face and the tall block stop a walk. Ride the moving platforms.', - }); + this.hud = mountControls(); } update() { const character = this.character; @@ -214,7 +205,7 @@ class CharacterMovementScene extends Scene { ]) { level.addCollider({ shape: new BoxShape(width, height), offset: { x, y } }); } - // A walkable 30° ramp up to a plateau, and a 60° face down the other side. + // A walkable 30 degree ramp up to a plateau, and a 60 degree face down the other side. const rampRise = Math.tan(Math.PI / 6) * 180; const faceRun = rampRise / Math.tan(Math.PI / 3); level.addCollider({ diff --git a/examples/physics/character-movement.ts b/examples/physics/character-movement.ts index 6cd42f70c..371525c70 100644 --- a/examples/physics/character-movement.ts +++ b/examples/physics/character-movement.ts @@ -13,6 +13,7 @@ import { Scene, SystemOrder, } from '@codexo/exojs'; +import { PhysicsDebugDraw } from '@codexo/exojs/debug'; import { BoxShape, CapsuleShape, @@ -24,8 +25,7 @@ import { PhysicsBodyComponent, PhysicsWorld, PolygonShape, -} from '@codexo/exojs-physics'; -import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; +} from '@codexo/exojs/physics'; import { mountControls } from '@examples/runtime'; // A platformer character on CharacterMovementComponent: it walks along the @@ -161,16 +161,7 @@ class CharacterMovementScene extends Scene { this.addChild(hero); this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true }); - this.hud = mountControls({ - title: 'Character Movement', - controls: [ - { keys: 'A / D', action: 'walk' }, - { keys: 'Space / W', action: 'jump (once more in the air)' }, - { keys: 'S', action: 'drop through the one-way platform' }, - ], - status: '', - hint: 'Climb the 30° slope and the low step; the steep face and the tall block stop a walk. Ride the moving platforms.', - }); + this.hud = mountControls(); } override update(): void { @@ -245,7 +236,7 @@ class CharacterMovementScene extends Scene { level.addCollider({ shape: new BoxShape(width, height), offset: { x, y } }); } - // A walkable 30° ramp up to a plateau, and a 60° face down the other side. + // A walkable 30 degree ramp up to a plateau, and a 60 degree face down the other side. const rampRise = Math.tan(Math.PI / 6) * 180; const faceRun = rampRise / Math.tan(Math.PI / 3); diff --git a/examples/physics/joint-driven-shadows.js b/examples/physics/joint-driven-shadows.js index b1545ce2c..6388b9fc2 100644 --- a/examples/physics/joint-driven-shadows.js +++ b/examples/physics/joint-driven-shadows.js @@ -1,9 +1,7 @@ // 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 toDegrees = radians => Math.round((Math.atan2(Math.sin(radians), Math.cos(radians)) * 180) / Math.PI); +import { LightmapLighting, PhysicsOccluder, PointLight } from '@codexo/exojs/lighting'; +import { BoxShape, CircleShape, PhysicsWorld, RevoluteJoint } from '@codexo/exojs/physics'; const makeBar = (width, color) => { const bar = new Graphics(); bar.fillColor = color; @@ -23,8 +21,6 @@ class JointShadowsScene extends Scene { hinge; dragging = false; targetAngle = 0; - hud; - statusClock = 0; init() { this.backdrop.fillColor = new Color(27, 39, 56); this.backdrop.drawRectangle(0, 0, 1280, 720); @@ -66,12 +62,6 @@ class JointShadowsScene extends Scene { collideConnected: false, }), ); - 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); @@ -85,7 +75,6 @@ class JointShadowsScene extends Scene { 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) { @@ -93,7 +82,6 @@ class JointShadowsScene extends Scene { } this.targetAngle = Math.atan2(pointer.y - 220, pointer.x - 650); this.endBody.wake(); - this.hud.setStatus(`Aim ${toDegrees(this.targetAngle)} degrees; arm ${toDegrees(this.endBody.angle)} degrees.`); }; onEnd = () => { if (!this.dragging) { @@ -102,9 +90,8 @@ class JointShadowsScene extends Scene { this.dragging = false; this.hinge.enableMotor = false; this.hinge.motorSpeed = 0; - this.hud.setStatus('Released. The hinged arm settles under gravity.'); }; - update(delta) { + update() { if (this.dragging) { // The body angle accumulates whole turns while atan2 stays within +-pi: // steer by the shortest signed difference, not the raw one. @@ -112,16 +99,6 @@ class JointShadowsScene extends Scene { const error = Math.atan2(Math.sin(difference), Math.cos(difference)); this.hinge.motorSpeed = Math.max(-6, Math.min(6, error * 10)); } - this.statusClock += delta; - if (this.statusClock < 0.25) { - return; - } - this.statusClock = 0; - this.hud.setStatus( - this.dragging - ? `Aim ${toDegrees(this.targetAngle)} degrees; arm ${toDegrees(this.endBody.angle)} 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); @@ -134,7 +111,6 @@ class JointShadowsScene extends Scene { 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(); diff --git a/examples/physics/joint-driven-shadows.ts b/examples/physics/joint-driven-shadows.ts index 9bc3de317..17fd04720 100644 --- a/examples/physics/joint-driven-shadows.ts +++ b/examples/physics/joint-driven-shadows.ts @@ -1,18 +1,6 @@ -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 toDegrees = (radians: number): number => Math.round((Math.atan2(Math.sin(radians), Math.cos(radians)) * 180) / Math.PI); +import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, SystemOrder } from '@codexo/exojs'; +import { LightmapLighting, PhysicsOccluder, PointLight } from '@codexo/exojs/lighting'; +import { BoxShape, CircleShape, type PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs/physics'; const makeBar = (width: number, color: Color): Graphics => { const bar = new Graphics(); @@ -35,8 +23,6 @@ class JointShadowsScene extends Scene { 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); @@ -84,12 +70,6 @@ class JointShadowsScene extends Scene { }), ); - 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); @@ -105,7 +85,6 @@ class JointShadowsScene extends Scene { 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) { @@ -114,7 +93,6 @@ class JointShadowsScene extends Scene { this.targetAngle = Math.atan2(pointer.y - 220, pointer.x - 650); this.endBody.wake(); - this.hud.setStatus(`Aim ${toDegrees(this.targetAngle)} degrees; arm ${toDegrees(this.endBody.angle)} degrees.`); }; private readonly onEnd = (): void => { if (!this.dragging) { @@ -124,10 +102,9 @@ class JointShadowsScene extends Scene { 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 { + override update(): void { if (this.dragging) { // The body angle accumulates whole turns while atan2 stays within +-pi: // steer by the shortest signed difference, not the raw one. @@ -136,19 +113,6 @@ class JointShadowsScene extends Scene { this.hinge.motorSpeed = Math.max(-6, Math.min(6, error * 10)); } - - this.statusClock += delta; - - if (this.statusClock < 0.25) { - return; - } - - this.statusClock = 0; - this.hud.setStatus( - this.dragging - ? `Aim ${toDegrees(this.targetAngle)} degrees; arm ${toDegrees(this.endBody.angle)} degrees.` - : `Arm at (${this.endBody.x.toFixed(0)}, ${this.endBody.y.toFixed(0)}). Drag it to change its shadow.`, - ); } override draw(context: RenderingContext): void { @@ -163,7 +127,6 @@ class JointShadowsScene extends Scene { 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(); diff --git a/examples/physics/kinematic-movement.js b/examples/physics/kinematic-movement.js index 1fcbcc4e1..b12b4af59 100644 --- a/examples/physics/kinematic-movement.js +++ b/examples/physics/kinematic-movement.js @@ -12,8 +12,8 @@ import { SystemOrder, VectorAction, } from '@codexo/exojs'; -import { BoxShape, CircleShape, KinematicMovementComponent, PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; -import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; +import { PhysicsDebugDraw } from '@codexo/exojs/debug'; +import { BoxShape, CircleShape, KinematicMovementComponent, PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs/physics'; import { mountControls } from '@examples/runtime'; // Collision-aware movement for a kinematic body, driven straight from input. // The movement casts the body's shape along the requested motion, stops at @@ -71,12 +71,7 @@ class KinematicMovementScene extends Scene { hero.addComponent(new Steering(this.movement, this.controls.move)); this.addChild(hero); this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true }); - this.hud = mountControls({ - title: 'Kinematic Movement', - controls: [{ keys: 'WASD / Arrows', action: 'move' }], - status: '', - hint: 'Push into a wall at an angle to slide along it; drive into a corner of the room to stop there.', - }); + this.hud = mountControls(); } update() { const { blocked, contactNormal } = this.movement; diff --git a/examples/physics/kinematic-movement.ts b/examples/physics/kinematic-movement.ts index 90777dbb7..a7fa89551 100644 --- a/examples/physics/kinematic-movement.ts +++ b/examples/physics/kinematic-movement.ts @@ -12,8 +12,8 @@ import { SystemOrder, VectorAction, } from '@codexo/exojs'; -import { BoxShape, CircleShape, KinematicMovementComponent, PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; -import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; +import { PhysicsDebugDraw } from '@codexo/exojs/debug'; +import { BoxShape, CircleShape, KinematicMovementComponent, PhysicsBody, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs/physics'; import { mountControls } from '@examples/runtime'; // Collision-aware movement for a kinematic body, driven straight from input. @@ -83,12 +83,7 @@ class KinematicMovementScene extends Scene { this.addChild(hero); this.debug = new PhysicsDebugDraw(this.app, this.world, { drawShapes: true }); - this.hud = mountControls({ - title: 'Kinematic Movement', - controls: [{ keys: 'WASD / Arrows', action: 'move' }], - status: '', - hint: 'Push into a wall at an angle to slide along it; drive into a corner of the room to stop there.', - }); + this.hud = mountControls(); } override update(): void { diff --git a/examples/physics/sprite-follows-body.js b/examples/physics/sprite-follows-body.js index 1b694249f..2a674d2f5 100644 --- a/examples/physics/sprite-follows-body.js +++ b/examples/physics/sprite-follows-body.js @@ -1,17 +1,7 @@ // Auto-generated from sprite-follows-body.ts - edit the .ts source, not this file. -import { - Application, - Asset, - Color, - FixedResolutionCanvasSizing, - Keyboard, - Scene, - Sprite, - Spritesheet, - SystemOrder, - Vector, -} from '@codexo/exojs'; -import { BoxShape, MouseJoint, PhysicsWorld } from '@codexo/exojs-physics'; +import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Spritesheet, SystemOrder, Vector } from '@codexo/exojs'; +import { BoxShape, MouseJoint, PhysicsWorld } from '@codexo/exojs/physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class SpriteFollowsBodyScene extends Scene { world; @@ -19,36 +9,37 @@ class SpriteFollowsBodyScene extends Scene { actorBody; dragJoint = null; floor; + floorBody; floorY = 0; settled = 0; hud; spritesheetData; async load() { - this.spritesheetData = await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)); + this.spritesheetData = await this.loader.load(assets.platformerCharactersAtlas); } init() { const app = this.app; const { width, height } = app; - // Gravity in px/s², +Y down - matches the engine's screen space. + // Gravity in px/s^2, +Y down - matches the engine's screen space. this.world = new PhysicsWorld({ gravity: { x: 0, y: 1400 }, fixedDelta: app.fixedTimeStep }); this.systems.add(this.world, { order: SystemOrder.Physics }); - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), this.spritesheetData); + const characters = new Spritesheet(this.loader.get(assets.platformerCharactersImage), this.spritesheetData); this.floorY = height - 80; - // ── Static floor ────────────────────────────────────────────────── + // Static floor. // A wide static body. `world.attach` binds it to the floor sprite, so // the sprite is positioned from the body (no manual placement needed). const floorWidth = width - 120; const floorHeight = 48; - this.floor = new Sprite(this.loader.get(assets.demo.textures.pixelWhite)).setAnchor(0.5); + this.floor = new Sprite(this.loader.get(assets.pixelWhite)).setAnchor(0.5); this.floor.width = floorWidth; this.floor.height = floorHeight; this.floor.tint = new Color(70, 92, 120); - this.world.attach(this.floor, { + const floorBody = this.world.attach(this.floor, { type: 'static', position: { x: width / 2, y: this.floorY }, shape: new BoxShape(floorWidth, floorHeight), }); - // ── Dynamic actor ───────────────────────────────────────────────── + // Dynamic actor. // A dynamic body dropped from above. Its collider is a box sized to the // character art; `world.attach` binds the sprite, so it falls, lands and // tracks the body (position + rotation) every fixed step. @@ -60,26 +51,30 @@ class SpriteFollowsBodyScene extends Scene { friction: 0.4, restitution: 0.15, }); - this.hud = mountControls({ - 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 binds the sprite to a body. MouseJoint pulls the body, and the sprite follows the simulated transform.', - }); + // Contact begins are rare events - one per landing - so each is logged to + // the Output panel with the speed the actor hit the floor at. + this.world.onCollisionStart.add(this.onContact); + this.floorBody = floorBody; + this.hud = mountControls({ status: 'Dropping...' }); 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); } + onContact = event => { + const bodies = [event.bodyA, event.bodyB]; + if (bodies.includes(this.actorBody) && bodies.includes(this.floorBody)) { + const speed = Math.hypot(this.actorBody.linearVelocityX, this.actorBody.linearVelocityY); + console.log(`Contact: actor landed on the floor at ${speed.toFixed(0)} px/s`); + } + }; onDown = pointer => { if (Math.abs(pointer.x - this.actorBody.x) > 42 || Math.abs(pointer.y - this.actorBody.y) > 55) { return; } this.onEnd(); + console.log('Grabbed: MouseJoint attached to the actor'); this.dragJoint = this.world.addJoint( new MouseJoint({ body: this.actorBody, target: pointer, hertz: 7, dampingRatio: 0.8, maxForce: 400_000 }), ); @@ -95,6 +90,7 @@ class SpriteFollowsBodyScene extends Scene { } this.world.removeJoint(this.dragJoint); this.dragJoint = null; + console.log(`Released: thrown at ${Math.hypot(this.actorBody.linearVelocityX, this.actorBody.linearVelocityY).toFixed(0)} px/s`); }; resetActor = () => { this.onEnd(); @@ -130,6 +126,7 @@ class SpriteFollowsBodyScene extends Scene { } destroy() { this.onEnd(); + this.world.onCollisionStart.remove(this.onContact); this.app.input.onPointerDown.remove(this.onDown); this.app.input.onPointerMove.remove(this.onMove); this.app.input.onPointerUp.remove(this.onEnd); diff --git a/examples/physics/sprite-follows-body.ts b/examples/physics/sprite-follows-body.ts index 00cba28a0..ef5dd70fa 100644 --- a/examples/physics/sprite-follows-body.ts +++ b/examples/physics/sprite-follows-body.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Color, FixedResolutionCanvasSizing, Keyboard, @@ -13,7 +12,8 @@ import { SystemOrder, Vector, } from '@codexo/exojs'; -import { BoxShape, MouseJoint, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, type CollisionEvent, MouseJoint, type PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class SpriteFollowsBodyScene extends Scene { @@ -22,47 +22,46 @@ class SpriteFollowsBodyScene extends Scene { private actorBody!: PhysicsBody; private dragJoint: MouseJoint | null = null; private floor!: Sprite; + private floorBody!: PhysicsBody; private floorY = 0; private settled = 0; private hud!: ReturnType; private spritesheetData!: SpritesheetData; override async load(): Promise { - this.spritesheetData = (await this.loader.load( - Asset.type('json', assets.demo.spritesheets.platformerCharacters.data), - )) as SpritesheetData; + this.spritesheetData = (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData; } override init(): void { const app = this.app; const { width, height } = app; - // Gravity in px/s², +Y down - matches the engine's screen space. + // Gravity in px/s^2, +Y down - matches the engine's screen space. this.world = new PhysicsWorld({ gravity: { x: 0, y: 1400 }, fixedDelta: app.fixedTimeStep }); this.systems.add(this.world, { order: SystemOrder.Physics }); - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), this.spritesheetData); + const characters = new Spritesheet(this.loader.get(assets.platformerCharactersImage), this.spritesheetData); this.floorY = height - 80; - // ── Static floor ────────────────────────────────────────────────── + // Static floor. // A wide static body. `world.attach` binds it to the floor sprite, so // the sprite is positioned from the body (no manual placement needed). const floorWidth = width - 120; const floorHeight = 48; - this.floor = new Sprite(this.loader.get(assets.demo.textures.pixelWhite)).setAnchor(0.5); + this.floor = new Sprite(this.loader.get(assets.pixelWhite)).setAnchor(0.5); this.floor.width = floorWidth; this.floor.height = floorHeight; this.floor.tint = new Color(70, 92, 120); - this.world.attach(this.floor, { + const floorBody = this.world.attach(this.floor, { type: 'static', position: { x: width / 2, y: this.floorY }, shape: new BoxShape(floorWidth, floorHeight), }); - // ── Dynamic actor ───────────────────────────────────────────────── + // Dynamic actor. // A dynamic body dropped from above. Its collider is a box sized to the // character art; `world.attach` binds the sprite, so it falls, lands and // tracks the body (position + rotation) every fixed step. @@ -75,15 +74,11 @@ class SpriteFollowsBodyScene extends Scene { restitution: 0.15, }); - this.hud = mountControls({ - 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 binds the sprite to a body. MouseJoint pulls the body, and the sprite follows the simulated transform.', - }); + // Contact begins are rare events - one per landing - so each is logged to + // the Output panel with the speed the actor hit the floor at. + this.world.onCollisionStart.add(this.onContact); + this.floorBody = floorBody; + this.hud = mountControls({ status: 'Dropping...' }); app.input.onPointerDown.add(this.onDown); app.input.onPointerMove.add(this.onMove); app.input.onPointerUp.add(this.onEnd); @@ -91,12 +86,23 @@ class SpriteFollowsBodyScene extends Scene { this.inputs.onTrigger(Keyboard.R, this.resetActor); } + private readonly onContact = (event: CollisionEvent): void => { + const bodies = [event.bodyA, event.bodyB]; + + if (bodies.includes(this.actorBody) && bodies.includes(this.floorBody)) { + const speed = Math.hypot(this.actorBody.linearVelocityX, this.actorBody.linearVelocityY); + + console.log(`Contact: actor landed on the floor at ${speed.toFixed(0)} px/s`); + } + }; + 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(); + console.log('Grabbed: MouseJoint attached to the actor'); this.dragJoint = this.world.addJoint( new MouseJoint({ body: this.actorBody, target: pointer, hertz: 7, dampingRatio: 0.8, maxForce: 400_000 }), ); @@ -115,6 +121,7 @@ class SpriteFollowsBodyScene extends Scene { this.world.removeJoint(this.dragJoint); this.dragJoint = null; + console.log(`Released: thrown at ${Math.hypot(this.actorBody.linearVelocityX, this.actorBody.linearVelocityY).toFixed(0)} px/s`); }; private readonly resetActor = (): void => { @@ -158,6 +165,7 @@ class SpriteFollowsBodyScene extends Scene { override destroy(): void { this.onEnd(); + this.world.onCollisionStart.remove(this.onContact); this.app.input.onPointerDown.remove(this.onDown); this.app.input.onPointerMove.remove(this.onMove); this.app.input.onPointerUp.remove(this.onEnd); diff --git a/examples/physics/tiled-map-physics-actor.js b/examples/physics/tiled-map-physics-actor.js index 7a0d80df3..0423aa26f 100644 --- a/examples/physics/tiled-map-physics-actor.js +++ b/examples/physics/tiled-map-physics-actor.js @@ -1,17 +1,17 @@ // Auto-generated from tiled-map-physics-actor.ts - edit the .ts source, not this file. 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 { PhysicsDebugDraw } from '@codexo/exojs/debug'; +import { BoxShape, PhysicsWorld } from '@codexo/exojs/physics'; +import { buildObjectLayerColliders } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { buildObjectLayerColliders } from '@codexo/exojs-tilemap-physics'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, toggle } from '@examples/runtime'; class TiledMapPhysicsActorScene extends Scene { world; mapNode; actor; actorBody; debug; - hud; panel; showOutlines = false; settled = 0; @@ -20,9 +20,9 @@ class TiledMapPhysicsActorScene extends Scene { spritesheetData; async load() { 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.loader.load(Asset.type('tileMap', assets.physicsRoomMap.source)), + this.loader.load(assets.platformerCharactersImage), + this.loader.load(assets.platformerCharactersAtlas), ]); this.map = map; this.charactersTexture = charactersTexture; @@ -36,7 +36,7 @@ class TiledMapPhysicsActorScene extends Scene { 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 }); + 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, { @@ -48,17 +48,10 @@ class TiledMapPhysicsActorScene extends Scene { }); this.actorBody.applyImpulse(2600, 0); 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.panel = demoPanel({ + title: 'Collision', + controls: { outlines: toggle('Show outlines', false) }, + onChange: (_key, value) => { this.showOutlines = value; }, }); @@ -85,7 +78,6 @@ class TiledMapPhysicsActorScene extends Scene { } } destroy() { - this.hud?.dispose(); this.panel?.dispose(); this.debug?.destroy(); this.mapNode?.destroy(); @@ -98,6 +90,5 @@ const app = new Application({ canvas: { width: 1280, height: 704, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(38, 46, 66), 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 82a22ca62..ace4a1e9b 100644 --- a/examples/physics/tiled-map-physics-actor.ts +++ b/examples/physics/tiled-map-physics-actor.ts @@ -13,12 +13,13 @@ import { type Texture, Vector, } from '@codexo/exojs'; -import { BoxShape, type PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; -import { PhysicsDebugDraw } from '@codexo/exojs-physics/debug'; +import { PhysicsDebugDraw } from '@codexo/exojs/debug'; +import { BoxShape, type PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; +import type { TileMap } from '@codexo/exojs/tilemap'; +import { buildObjectLayerColliders } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import type { TileMap } from '@codexo/exojs-tilemap'; -import { buildObjectLayerColliders } from '@codexo/exojs-tilemap-physics'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, toggle } from '@examples/runtime'; class TiledMapPhysicsActorScene extends Scene { private world!: PhysicsWorld; @@ -26,8 +27,7 @@ class TiledMapPhysicsActorScene extends Scene { private actor!: Sprite; private actorBody!: PhysicsBody; private debug!: PhysicsDebugDraw; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: ReturnType; private showOutlines = false; private settled = 0; private map!: TileMap; @@ -36,9 +36,9 @@ class TiledMapPhysicsActorScene extends Scene { override async load(): Promise { 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.loader.load(Asset.type('tileMap', assets.physicsRoomMap.source)), + this.loader.load(assets.platformerCharactersImage), + this.loader.load(assets.platformerCharactersAtlas), ]); this.map = map; this.charactersTexture = charactersTexture; @@ -56,7 +56,7 @@ class TiledMapPhysicsActorScene extends Scene { throw new Error('physics-room.tmj needs a Collision object layer.'); } - const colliders = buildObjectLayerColliders(this.world, collision, { friction: 0.7, restitution: 0.05 }); + 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); @@ -70,17 +70,10 @@ class TiledMapPhysicsActorScene extends Scene { this.actorBody.applyImpulse(2600, 0); 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.panel = demoPanel({ + title: 'Collision', + controls: { outlines: toggle('Show outlines', false) }, + onChange: (_key, value) => { this.showOutlines = value; }, }); @@ -113,7 +106,6 @@ class TiledMapPhysicsActorScene extends Scene { } override destroy(): void { - this.hud?.dispose(); this.panel?.dispose(); this.debug?.destroy(); this.mapNode?.destroy(); @@ -127,7 +119,6 @@ const app = new Application({ canvas: { width: 1280, height: 704, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(38, 46, 66), extensions: [tiledExtension], - loader: { basePath: 'assets/' }, }); await app.start(TiledMapPhysicsActorScene); diff --git a/examples/render-targets/bloom-lite.js b/examples/render-targets/bloom-lite.js index e0ba98da7..2bf8a4647 100644 --- a/examples/render-targets/bloom-lite.js +++ b/examples/render-targets/bloom-lite.js @@ -12,6 +12,7 @@ import { Scene, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BloomLiteScene extends Scene { baseRt; glowRt; @@ -28,7 +29,7 @@ class BloomLiteScene extends Scene { this.baseRt = new RenderTexture(width, height); this.glowRt = new RenderTexture(width, height); this.blurredRt = new RenderTexture(width, height); - this.bunny = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(1.9); + this.bunny = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(1.9); this.baseSprite = new Sprite(this.baseRt); this.glowSprite = new Sprite(this.blurredRt).setTint(new Color(255, 255, 255, 0.8)).setBlendMode(BlendModes.Additive); this.blur = new BlurFilter({ strength: 5 }); @@ -88,8 +89,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BloomLiteScene); diff --git a/examples/render-targets/bloom-lite.ts b/examples/render-targets/bloom-lite.ts index efbff5a27..6fd6c1185 100644 --- a/examples/render-targets/bloom-lite.ts +++ b/examples/render-targets/bloom-lite.ts @@ -13,6 +13,7 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BloomLiteScene extends Scene { private baseRt!: RenderTexture; @@ -32,7 +33,7 @@ class BloomLiteScene extends Scene { this.baseRt = new RenderTexture(width, height); this.glowRt = new RenderTexture(width, height); this.blurredRt = new RenderTexture(width, height); - this.bunny = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(1.9); + this.bunny = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(1.9); this.baseSprite = new Sprite(this.baseRt); this.glowSprite = new Sprite(this.blurredRt).setTint(new Color(255, 255, 255, 0.8)).setBlendMode(BlendModes.Additive); this.blur = new BlurFilter({ strength: 5 }); @@ -97,9 +98,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BloomLiteScene); diff --git a/examples/render-targets/color-pipeline.js b/examples/render-targets/color-pipeline.js index 068443624..ca8fd2420 100644 --- a/examples/render-targets/color-pipeline.js +++ b/examples/render-targets/color-pipeline.js @@ -58,7 +58,7 @@ class ColorPipelineScene extends Scene { nodes = []; textures = []; labels = []; - hud; + hud = null; numericTarget = null; srgbTarget = null; hdrTarget = null; @@ -174,21 +174,17 @@ class ColorPipelineScene extends Scene { this.label('RGBA16F are not all supported here.', c2, r1 + 113); this.label('The SDR panels are unaffected.', c2, r1 + 150); } - this.hud = mountControls({ - title: 'Colour Pipeline', - corner: 'top-left', - controls: this.hdrSupported ? [{ keys: 'Space', action: 'HDR panel: clamp or Reinhard' }] : [], - status: this.hdrSupported ? `HDR panel: ${TONE_MAPPINGS[this.toneIndex]}` : 'HDR panel unavailable', - hint: 'Colour blends in linear light and is encoded once at output; data stays numeric.', - }); if (this.hdrSupported) { - this.inputs.onTrigger(Keyboard.Space, () => { - this.toneIndex = (this.toneIndex + 1) % TONE_MAPPINGS.length; - this.hud.setStatus(`HDR panel: ${TONE_MAPPINGS[this.toneIndex]}`); - void this.readHdr(); - }); + this.hud = mountControls({ title: 'Colour Pipeline', corner: 'top-left', status: `HDR panel: ${TONE_MAPPINGS[this.toneIndex]}` }); + this.inputs.onTrigger(Keyboard.Space, this.cycleTone); + this.app.input.onPointerTap.add(this.cycleTone); } } + cycleTone = () => { + this.toneIndex = (this.toneIndex + 1) % TONE_MAPPINGS.length; + this.hud?.setStatus(`HDR panel: ${TONE_MAPPINGS[this.toneIndex]}`); + void this.readHdr(); + }; async readbacks() { const rendering = this.app.rendering; const numeric = this.numericTarget; @@ -245,6 +241,7 @@ class ColorPipelineScene extends Scene { } } destroy() { + this.app.input.onPointerTap.remove(this.cycleTone); this.hud?.dispose(); this.numericTarget?.destroy(); this.srgbTarget?.destroy(); diff --git a/examples/render-targets/color-pipeline.ts b/examples/render-targets/color-pipeline.ts index 13bfadd94..bd49bf837 100644 --- a/examples/render-targets/color-pipeline.ts +++ b/examples/render-targets/color-pipeline.ts @@ -73,7 +73,7 @@ class ColorPipelineScene extends Scene { private readonly nodes: RenderNode[] = []; private readonly textures: Texture[] = []; private readonly labels: Text[] = []; - private hud!: ReturnType; + private hud: ReturnType | null = null; private numericTarget: RenderTexture | null = null; private srgbTarget: RenderTexture | null = null; @@ -207,23 +207,19 @@ class ColorPipelineScene extends Scene { this.label('The SDR panels are unaffected.', c2, r1 + 150); } - this.hud = mountControls({ - title: 'Colour Pipeline', - corner: 'top-left', - controls: this.hdrSupported ? [{ keys: 'Space', action: 'HDR panel: clamp or Reinhard' }] : [], - status: this.hdrSupported ? `HDR panel: ${TONE_MAPPINGS[this.toneIndex]}` : 'HDR panel unavailable', - hint: 'Colour blends in linear light and is encoded once at output; data stays numeric.', - }); - if (this.hdrSupported) { - this.inputs.onTrigger(Keyboard.Space, () => { - this.toneIndex = (this.toneIndex + 1) % TONE_MAPPINGS.length; - this.hud.setStatus(`HDR panel: ${TONE_MAPPINGS[this.toneIndex]}`); - void this.readHdr(); - }); + this.hud = mountControls({ title: 'Colour Pipeline', corner: 'top-left', status: `HDR panel: ${TONE_MAPPINGS[this.toneIndex]}` }); + this.inputs.onTrigger(Keyboard.Space, this.cycleTone); + this.app.input.onPointerTap.add(this.cycleTone); } } + private readonly cycleTone = (): void => { + this.toneIndex = (this.toneIndex + 1) % TONE_MAPPINGS.length; + this.hud?.setStatus(`HDR panel: ${TONE_MAPPINGS[this.toneIndex]}`); + void this.readHdr(); + }; + private async readbacks(): Promise { const rendering = this.app.rendering; const numeric = this.numericTarget!; @@ -292,6 +288,7 @@ class ColorPipelineScene extends Scene { } override destroy(): void { + this.app.input.onPointerTap.remove(this.cycleTone); this.hud?.dispose(); this.numericTarget?.destroy(); this.srgbTarget?.destroy(); diff --git a/examples/render-targets/mini-map.js b/examples/render-targets/mini-map.js index 0aabf2a6a..48371c83f 100644 --- a/examples/render-targets/mini-map.js +++ b/examples/render-targets/mini-map.js @@ -13,7 +13,6 @@ import { Sprite, View, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; const WORLD_WIDTH = 3200; const WORLD_HEIGHT = 1800; const MAP_SIZE = 200; @@ -59,7 +58,6 @@ class MiniMapScene extends Scene { mapCamera; mapTexture; pipeline; - hud; time = 0; init() { const { width, height } = this.app; @@ -88,11 +86,6 @@ class MiniMapScene extends Scene { .addPass(new RenderNodePass(this.world, { target: this.mapTexture, view: this.mapCamera, clear: Color.black })) .addPass(new RenderNodePass(this.world, { view: this.camera, clear: Color.black })) .addPass(new RenderNodePass(this.overlay)); - this.hud = mountControls({ - title: 'Minimap', - status: 'The player wanders a world larger than the screen.', - hint: 'Both views follow the same player through the same container; the minimap camera only frames a wider area into a small texture.', - }); } update(delta) { this.time += delta * 0.35; @@ -116,7 +109,6 @@ class MiniMapScene extends Scene { this.world.destroy(); this.overlay.destroy(); this.mask.destroy(); - this.hud.dispose(); super.destroy(); } } diff --git a/examples/render-targets/mini-map.ts b/examples/render-targets/mini-map.ts index 11264c4f3..41e32ecd9 100644 --- a/examples/render-targets/mini-map.ts +++ b/examples/render-targets/mini-map.ts @@ -14,7 +14,6 @@ import { Sprite, View, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; const WORLD_WIDTH = 3200; const WORLD_HEIGHT = 1800; @@ -69,7 +68,6 @@ class MiniMapScene extends Scene { private mapCamera!: View; private mapTexture!: RenderTexture; private pipeline!: RenderPipeline; - private hud!: ReturnType; private time = 0; override init(): void { @@ -104,12 +102,6 @@ class MiniMapScene extends Scene { .addPass(new RenderNodePass(this.world, { target: this.mapTexture, view: this.mapCamera, clear: Color.black })) .addPass(new RenderNodePass(this.world, { view: this.camera, clear: Color.black })) .addPass(new RenderNodePass(this.overlay)); - - this.hud = mountControls({ - title: 'Minimap', - status: 'The player wanders a world larger than the screen.', - hint: 'Both views follow the same player through the same container; the minimap camera only frames a wider area into a small texture.', - }); } override update(delta: Seconds): void { @@ -139,7 +131,6 @@ class MiniMapScene extends Scene { this.world.destroy(); this.overlay.destroy(); this.mask.destroy(); - this.hud.dispose(); super.destroy(); } } diff --git a/examples/render-targets/post-processing-chain.js b/examples/render-targets/post-processing-chain.js index d7bc58875..7f42cf759 100644 --- a/examples/render-targets/post-processing-chain.js +++ b/examples/render-targets/post-processing-chain.js @@ -85,8 +85,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PostProcessingChainScene); diff --git a/examples/render-targets/post-processing-chain.ts b/examples/render-targets/post-processing-chain.ts index 3d72398a7..6016b7307 100644 --- a/examples/render-targets/post-processing-chain.ts +++ b/examples/render-targets/post-processing-chain.ts @@ -94,9 +94,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PostProcessingChainScene); diff --git a/examples/render-targets/render-pipeline.js b/examples/render-targets/render-pipeline.js index eccd57289..c7d914f68 100644 --- a/examples/render-targets/render-pipeline.js +++ b/examples/render-targets/render-pipeline.js @@ -7,6 +7,7 @@ import { Container, FixedResolutionCanvasSizing, Graphics, + Keyboard, RenderNodePass, RenderPipeline, RenderTexture, @@ -14,7 +15,7 @@ import { Sprite, TextureFormat, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { 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. @@ -69,7 +70,7 @@ class RenderPipelineScene extends Scene { ), ) .addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); - // world → off-screen → blur → composite → UI overlay. A RenderPipeline is itself a RenderPass, + // world, off-screen, blur, composite, UI overlay. A RenderPipeline is itself a RenderPass, // so `ui` nests directly. this.frame = new RenderPipeline({ label: 'frame' }) .addPass(new RenderNodePass(this.world, { target: this.sceneRt, clear: Color.black, label: 'world' })) @@ -85,21 +86,16 @@ 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'}`); - }, - }); + this.controls = mountControls({ title: 'Composable Render Pipeline', status: 'Blur: on' }); + this.inputs.onTrigger(Keyboard.Space, this.toggleBlur); + app.input.onPointerTap.add(this.toggleBlur); } + toggleBlur = () => { + const enabled = !this.blurPass.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; @@ -116,6 +112,7 @@ class RenderPipelineScene extends Scene { } destroy() { this.detachResize?.(); + this.app.input.onPointerTap.remove(this.toggleBlur); // Cascades destroy() to every pass; the pipeline never destroys caller-owned nodes or targets. this.frame.destroy(); this.sceneRt.destroy(); @@ -134,8 +131,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(RenderPipelineScene); diff --git a/examples/render-targets/render-pipeline.ts b/examples/render-targets/render-pipeline.ts index 64e99bcb6..5d3e41bde 100644 --- a/examples/render-targets/render-pipeline.ts +++ b/examples/render-targets/render-pipeline.ts @@ -6,6 +6,7 @@ import { Container, FixedResolutionCanvasSizing, Graphics, + Keyboard, type RenderingContext, RenderNodePass, RenderPipeline, @@ -15,7 +16,7 @@ import { Sprite, TextureFormat, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { 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. @@ -78,7 +79,7 @@ class RenderPipelineScene extends Scene { ) .addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' })); - // world → off-screen → blur → composite → UI overlay. A RenderPipeline is itself a RenderPass, + // world, off-screen, blur, composite, UI overlay. A RenderPipeline is itself a RenderPass, // so `ui` nests directly. this.frame = new RenderPipeline({ label: 'frame' }) .addPass(new RenderNodePass(this.world, { target: this.sceneRt, clear: Color.black, label: 'world' })) @@ -97,22 +98,19 @@ 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'}`); - }, - }); + this.controls = mountControls({ title: 'Composable Render Pipeline', status: 'Blur: on' }); + this.inputs.onTrigger(Keyboard.Space, this.toggleBlur); + app.input.onPointerTap.add(this.toggleBlur); } + private readonly toggleBlur = (): void => { + const enabled = !this.blurPass.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 { const app = this.app; const { width, height } = app; @@ -133,6 +131,7 @@ class RenderPipelineScene extends Scene { override destroy(): void { this.detachResize?.(); + this.app.input.onPointerTap.remove(this.toggleBlur); // Cascades destroy() to every pass; the pipeline never destroys caller-owned nodes or targets. this.frame.destroy(); this.sceneRt.destroy(); @@ -152,9 +151,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(RenderPipelineScene); diff --git a/examples/render-targets/render-to-texture.js b/examples/render-targets/render-to-texture.js index 24790bb83..a75c87aa4 100644 --- a/examples/render-targets/render-to-texture.js +++ b/examples/render-targets/render-to-texture.js @@ -1,6 +1,6 @@ // Auto-generated from render-to-texture.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, RenderTexture, Scene, Sprite, Text } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class RenderToTextureScene extends Scene { source; texture; @@ -11,11 +11,9 @@ class RenderToTextureScene extends Scene { snapshot; labels; captureRequested = true; - captures = 0; - hud; time = 0; init() { - this.texture = this.loader.get(assets.demo.textures.shipA); + this.texture = this.loader.get(assets.ship); this.source = new Container(); for (let i = 0; i < 25; i++) { const ship = new Sprite(this.texture) @@ -33,16 +31,11 @@ class RenderToTextureScene extends Scene { 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; - }); + this.app.input.onPointerTap.add(this.onTap); } + onTap = () => { + this.captureRequested = true; + }; update(delta) { this.time += delta; for (let i = 0; i < this.ships.length; i++) { @@ -54,10 +47,6 @@ class RenderToTextureScene extends Scene { 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); @@ -66,6 +55,7 @@ class RenderToTextureScene extends Scene { } } destroy() { + this.app.input.onPointerTap.remove(this.onTap); this.liveTarget.destroy(); this.target.destroy(); super.destroy(); diff --git a/examples/render-targets/render-to-texture.ts b/examples/render-targets/render-to-texture.ts index 2da379e97..2896e943e 100644 --- a/examples/render-targets/render-to-texture.ts +++ b/examples/render-targets/render-to-texture.ts @@ -11,7 +11,7 @@ import { Text, type Texture, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class RenderToTextureScene extends Scene { private source!: Container; @@ -23,12 +23,10 @@ class RenderToTextureScene extends Scene { private snapshot!: Sprite; private labels!: Text[]; private captureRequested = true; - private captures = 0; - private hud!: ReturnType; private time = 0; override init(): void { - this.texture = this.loader.get(assets.demo.textures.shipA); + this.texture = this.loader.get(assets.ship); this.source = new Container(); for (let i = 0; i < 25; i++) { @@ -48,17 +46,13 @@ class RenderToTextureScene extends Scene { 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; - }); + this.app.input.onPointerTap.add(this.onTap); } + private readonly onTap = (): void => { + this.captureRequested = true; + }; + override update(delta: Seconds): void { this.time += delta; @@ -73,10 +67,6 @@ class RenderToTextureScene extends Scene { 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); @@ -88,6 +78,7 @@ class RenderToTextureScene extends Scene { } override destroy(): void { + this.app.input.onPointerTap.remove(this.onTap); this.liveTarget.destroy(); this.target.destroy(); super.destroy(); diff --git a/examples/render-targets/trail-feedback.js b/examples/render-targets/trail-feedback.js index 82324cff4..805b7f3c4 100644 --- a/examples/render-targets/trail-feedback.js +++ b/examples/render-targets/trail-feedback.js @@ -11,6 +11,7 @@ import { Sprite, TextureFormat, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TrailFeedbackScene extends Scene { // Two render targets, ping-ponged each frame. Reading from and writing to // the SAME render target forms a GL feedback loop (INVALID_OPERATION), so we @@ -33,7 +34,7 @@ class TrailFeedbackScene extends Scene { // format: eight bits of linear storage quantize the dark end of the fade coarsely. this.rtA = new RenderTexture(width, height, { format: TextureFormat.Rgba8Srgb }); this.rtB = new RenderTexture(width, height, { format: TextureFormat.Rgba8Srgb }); - this.bunny = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5); + this.bunny = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5); this.decayA = new Sprite(this.rtA); this.decayB = new Sprite(this.rtB); const showA = new Sprite(this.rtA); @@ -95,8 +96,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TrailFeedbackScene); diff --git a/examples/render-targets/trail-feedback.ts b/examples/render-targets/trail-feedback.ts index 6799791ff..66794b5b7 100644 --- a/examples/render-targets/trail-feedback.ts +++ b/examples/render-targets/trail-feedback.ts @@ -12,6 +12,7 @@ import { Sprite, TextureFormat, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TrailFeedbackScene extends Scene { // Two render targets, ping-ponged each frame. Reading from and writing to @@ -37,7 +38,7 @@ class TrailFeedbackScene extends Scene { // format: eight bits of linear storage quantize the dark end of the fade coarsely. this.rtA = new RenderTexture(width, height, { format: TextureFormat.Rgba8Srgb }); this.rtB = new RenderTexture(width, height, { format: TextureFormat.Rgba8Srgb }); - this.bunny = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5); + this.bunny = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5); this.decayA = new Sprite(this.rtA); this.decayB = new Sprite(this.rtB); @@ -106,9 +107,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TrailFeedbackScene); diff --git a/examples/render-targets/water-mirror.js b/examples/render-targets/water-mirror.js index 4371888fe..e938df93f 100644 --- a/examples/render-targets/water-mirror.js +++ b/examples/render-targets/water-mirror.js @@ -17,7 +17,7 @@ import { TextureFormat, WrapModes, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const MAP_SIZE = 256; const createRippleMap = () => { const data = new Uint8Array(MAP_SIZE * MAP_SIZE * 4); @@ -50,7 +50,6 @@ class WaterMirrorScene extends Scene { tint; waterline; pipeline; - hud; dragging = false; onPointerDown = (_pointer, x, y) => { if (y < this.app.height / 2) { @@ -72,7 +71,7 @@ class WaterMirrorScene extends Scene { const half = height / 2; this.target = new RenderTexture(width, half); this.map = createRippleMap(); - this.source = new Sprite(this.loader.get('image/ship-a.png')) + this.source = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2, half / 2) .setScale(5); @@ -96,12 +95,6 @@ class WaterMirrorScene extends Scene { ) .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); @@ -110,7 +103,6 @@ class WaterMirrorScene extends Scene { 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 { width, height } = this.app; @@ -129,7 +121,6 @@ class WaterMirrorScene extends Scene { 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.target.destroy(); this.ripple.destroy(); @@ -147,6 +138,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(WaterMirrorScene); diff --git a/examples/render-targets/water-mirror.ts b/examples/render-targets/water-mirror.ts index 3b37a3cbe..91a5cef5d 100644 --- a/examples/render-targets/water-mirror.ts +++ b/examples/render-targets/water-mirror.ts @@ -19,7 +19,7 @@ import { TextureFormat, WrapModes, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const MAP_SIZE = 256; @@ -57,7 +57,6 @@ class WaterMirrorScene extends Scene { private tint!: ColorMatrixFilter; private waterline!: Graphics; private pipeline!: RenderPipeline; - private hud!: ReturnType; private dragging = false; private readonly onPointerDown = (_pointer: unknown, x: number, y: number): void => { if (y < this.app.height / 2) { @@ -81,7 +80,7 @@ class WaterMirrorScene extends Scene { const half = height / 2; this.target = new RenderTexture(width, half); this.map = createRippleMap(); - this.source = new Sprite(this.loader.get('image/ship-a.png')) + this.source = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setPosition(width / 2, half / 2) .setScale(5); @@ -108,12 +107,6 @@ class WaterMirrorScene extends Scene { .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); @@ -123,7 +116,6 @@ class WaterMirrorScene extends Scene { 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 { @@ -145,7 +137,6 @@ class WaterMirrorScene extends Scene { 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.target.destroy(); this.ripple.destroy(); @@ -164,7 +155,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(WaterMirrorScene); diff --git a/examples/scene-graph/containers.js b/examples/scene-graph/containers.js index 9a2055e19..fb6ae103c 100644 --- a/examples/scene-graph/containers.js +++ b/examples/scene-graph/containers.js @@ -1,14 +1,13 @@ // Auto-generated from containers.ts - edit the .ts source, not this file. -import { Application, Asset, Assets, Color, Container, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { Application, Assets, Color, Container, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ContainersScene extends Scene { rainbow; bunnies; init() { const app = this.app; const { width, height } = app; - const { bunny, rainbow } = this.loader.get( - Assets.from({ bunny: Asset.type('texture', 'image/ship-a.png'), rainbow: Asset.type('texture', 'image/hue-ramp.png') }), - ); + const { bunny, rainbow } = this.loader.get(Assets.from({ bunny: assets.ship, rainbow: assets.hueRamp })); this.rainbow = new Sprite(rainbow); this.bunnies = new Container(); this.bunnies.setPosition((width / 2) | 0, (height / 2) | 0); @@ -40,8 +39,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ContainersScene); diff --git a/examples/scene-graph/containers.ts b/examples/scene-graph/containers.ts index 2657c8629..113ba32ff 100644 --- a/examples/scene-graph/containers.ts +++ b/examples/scene-graph/containers.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Assets, Color, Container, @@ -10,6 +9,7 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ContainersScene extends Scene { private rainbow!: Sprite; @@ -18,9 +18,7 @@ class ContainersScene extends Scene { override init(): void { const app = this.app; const { width, height } = app; - const { bunny, rainbow } = this.loader.get( - Assets.from({ bunny: Asset.type('texture', 'image/ship-a.png'), rainbow: Asset.type('texture', 'image/hue-ramp.png') }), - ); + const { bunny, rainbow } = this.loader.get(Assets.from({ bunny: assets.ship, rainbow: assets.hueRamp })); this.rainbow = new Sprite(rainbow); @@ -62,9 +60,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ContainersScene); diff --git a/examples/scene-graph/local-vs-global-transform.js b/examples/scene-graph/local-vs-global-transform.js index bd6e8ee18..2f1041718 100644 --- a/examples/scene-graph/local-vs-global-transform.js +++ b/examples/scene-graph/local-vs-global-transform.js @@ -1,5 +1,6 @@ // Auto-generated from local-vs-global-transform.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class LocalVsGlobalTransformScene extends Scene { parent; localSprite; @@ -9,7 +10,7 @@ class LocalVsGlobalTransformScene extends Scene { init() { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/ship-a.png'); + const texture = this.loader.get(assets.ship); this.parent = new Container().setPosition(width / 4, height / 2); this.localSprite = new Sprite(texture) .setAnchor(0.5) @@ -46,8 +47,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(LocalVsGlobalTransformScene); diff --git a/examples/scene-graph/local-vs-global-transform.ts b/examples/scene-graph/local-vs-global-transform.ts index 49c078f44..ff9371f92 100644 --- a/examples/scene-graph/local-vs-global-transform.ts +++ b/examples/scene-graph/local-vs-global-transform.ts @@ -9,6 +9,7 @@ import { Sprite, Text, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class LocalVsGlobalTransformScene extends Scene { private parent!: Container; @@ -20,7 +21,7 @@ class LocalVsGlobalTransformScene extends Scene { override init(): void { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/ship-a.png'); + const texture = this.loader.get(assets.ship); this.parent = new Container().setPosition(width / 4, height / 2); this.localSprite = new Sprite(texture) @@ -62,9 +63,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(LocalVsGlobalTransformScene); diff --git a/examples/scene-graph/masks.js b/examples/scene-graph/masks.js index c47c91f75..e9966ebdc 100644 --- a/examples/scene-graph/masks.js +++ b/examples/scene-graph/masks.js @@ -1,6 +1,7 @@ // Auto-generated from masks.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Rectangle, Scene, Sprite } from '@codexo/exojs'; -const ALPHA_RINGS = assets.technical.alpha.alphaGradientRings; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +const ALPHA_RINGS = assets.alphaGradientRings; class MasksScene extends Scene { rectSprite; rectMask; diff --git a/examples/scene-graph/masks.ts b/examples/scene-graph/masks.ts index 91d485fa1..b6b687073 100644 --- a/examples/scene-graph/masks.ts +++ b/examples/scene-graph/masks.ts @@ -9,8 +9,9 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; -const ALPHA_RINGS = assets.technical.alpha.alphaGradientRings; +const ALPHA_RINGS = assets.alphaGradientRings; class MasksScene extends Scene { private rectSprite!: Sprite; diff --git a/examples/scene-graph/nested-transforms.js b/examples/scene-graph/nested-transforms.js index 3cf2deeaa..fd885e451 100644 --- a/examples/scene-graph/nested-transforms.js +++ b/examples/scene-graph/nested-transforms.js @@ -61,8 +61,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(NestedTransformsScene); diff --git a/examples/scene-graph/nested-transforms.ts b/examples/scene-graph/nested-transforms.ts index 4c440ef26..0e8ccae72 100644 --- a/examples/scene-graph/nested-transforms.ts +++ b/examples/scene-graph/nested-transforms.ts @@ -79,9 +79,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(NestedTransformsScene); diff --git a/examples/scene-graph/parallax-starfield.js b/examples/scene-graph/parallax-starfield.js index 6196a7669..467c430d5 100644 --- a/examples/scene-graph/parallax-starfield.js +++ b/examples/scene-graph/parallax-starfield.js @@ -1,13 +1,11 @@ // 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; @@ -28,11 +26,6 @@ class ParallaxStarfieldScene extends Scene { } return g; }); - 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) { @@ -47,7 +40,6 @@ class ParallaxStarfieldScene extends Scene { } destroy() { this.app.input.onPointerMove.remove(this.onPointerMove); - this.hud.dispose(); super.destroy(); } } @@ -60,8 +52,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ParallaxStarfieldScene); diff --git a/examples/scene-graph/parallax-starfield.ts b/examples/scene-graph/parallax-starfield.ts index 9d3c14f24..302cb78ac 100644 --- a/examples/scene-graph/parallax-starfield.ts +++ b/examples/scene-graph/parallax-starfield.ts @@ -1,5 +1,4 @@ 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]; @@ -8,7 +7,6 @@ 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; @@ -35,11 +33,6 @@ class ParallaxStarfieldScene extends Scene { return g; }); - 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); } @@ -57,7 +50,6 @@ class ParallaxStarfieldScene extends Scene { override destroy(): void { this.app.input.onPointerMove.remove(this.onPointerMove); - this.hud.dispose(); super.destroy(); } } @@ -71,9 +63,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ParallaxStarfieldScene); diff --git a/examples/scene-graph/pivot-and-anchor.js b/examples/scene-graph/pivot-and-anchor.js index 7ea7f990a..89e640ab9 100644 --- a/examples/scene-graph/pivot-and-anchor.js +++ b/examples/scene-graph/pivot-and-anchor.js @@ -1,65 +1,62 @@ // 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 }, - { name: 'off-canvas', anchor: [0.5, 0.5], origin: [180, -80] }, +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +// Three copies of one sprite, each turning about a different origin. The red dot +// is the sprite's position, which is always where its origin sits; the cyan box +// is its world-space bounds. +const VARIANTS = [ + { title: 'Origin at the top-left corner', anchor: [0, 0], origin: null }, + { title: 'Origin at the centre', anchor: [0.5, 0.5], origin: null }, + { title: 'Origin outside the texture', anchor: [0.5, 0.5], origin: [110, -20] }, ]; +const SCALE = 1.5; class PivotAndAnchorScene extends Scene { - sprite; - pivotMarker; - label; - hud; - panel; - init() { - const app = this.app; - const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setPosition(width / 2, height / 2); - this.pivotMarker = new Graphics(); - this.label = new Text('', { fillColor: Color.white, fontSize: 18 }); - this.label.setPosition(20, 20); - 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); + sprites = []; + labels = []; + overlay = new Graphics(); + ship; + // The anchor derives the origin from the texture size, so the texture is + // awaited rather than claimed as a placeholder that is still 0 x 0. + async load() { + this.ship = await this.loader.load(assets.ship); } - applyMode(index) { - const mode = modes[index]; - this.sprite.setAnchor(mode.anchor[0], mode.anchor[1]); - 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)})`, - ); + init() { + const { width, height } = this.app; + const column = width / VARIANTS.length; + VARIANTS.forEach((variant, index) => { + const x = column * (index + 0.5); + const sprite = new Sprite(this.ship).setScale(SCALE).setPosition(x, height / 2); + sprite.setAnchor(variant.anchor[0], variant.anchor[1]); + if (variant.origin) { + sprite.setOrigin(variant.origin[0], variant.origin[1]); + } + this.sprites.push(sprite); + this.labels.push( + new Text(variant.title, { fillColor: Color.white, fontSize: 18, align: 'center' }).setAnchor(0.5, 0).setPosition(x, 40), + ); + }); } update(delta) { - this.sprite.rotate(delta * 45); + for (const sprite of this.sprites) { + 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, 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(); + this.overlay.clear(); + this.overlay.lineColor = new Color(110, 215, 245); + this.overlay.lineWidth = 2; + for (const sprite of this.sprites) { + const bounds = sprite.getBounds(); + context.render(sprite); + this.overlay.fillColor = Color.transparent; + this.overlay.drawRectangle(bounds.x, bounds.y, bounds.width, bounds.height); + this.overlay.fillColor = new Color(255, 80, 80); + this.overlay.drawCircle(sprite.x, sprite.y, 6); + } + context.render(this.overlay); + for (const label of this.labels) { + context.render(label); + } } } const app = new Application({ @@ -71,8 +68,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PivotAndAnchorScene); diff --git a/examples/scene-graph/pivot-and-anchor.ts b/examples/scene-graph/pivot-and-anchor.ts index 698d1934d..5b2b03fc8 100644 --- a/examples/scene-graph/pivot-and-anchor.ts +++ b/examples/scene-graph/pivot-and-anchor.ts @@ -8,75 +8,80 @@ import { type Seconds, Sprite, Text, + type Texture, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; -const modes = [ - { name: 'corner', anchor: [0, 0] as [number, number], origin: [0, 0] as [number, number] | null }, - { name: 'center', anchor: [0.5, 0.5] as [number, number], origin: null }, - { name: 'off-canvas', anchor: [0.5, 0.5] as [number, number], origin: [180, -80] as [number, number] | null }, -]; +// Three copies of one sprite, each turning about a different origin. The red dot +// is the sprite's position, which is always where its origin sits; the cyan box +// is its world-space bounds. +const VARIANTS = [ + { title: 'Origin at the top-left corner', anchor: [0, 0], origin: null }, + { title: 'Origin at the centre', anchor: [0.5, 0.5], origin: null }, + { title: 'Origin outside the texture', anchor: [0.5, 0.5], origin: [110, -20] }, +] as const; + +const SCALE = 1.5; class PivotAndAnchorScene extends Scene { - private sprite!: Sprite; - private pivotMarker!: Graphics; - private label!: Text; - private hud!: ReturnType; - private panel!: ReturnType; + private readonly sprites: Sprite[] = []; + private readonly labels: Text[] = []; + private readonly overlay = new Graphics(); + private ship!: Texture; - override init(): void { - const app = this.app; - const { width, height } = app; - - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setPosition(width / 2, height / 2); - this.pivotMarker = new Graphics(); - this.label = new Text('', { fillColor: Color.white, fontSize: 18 }); - this.label.setPosition(20, 20); - 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); + // The anchor derives the origin from the texture size, so the texture is + // awaited rather than claimed as a placeholder that is still 0 x 0. + override async load(): Promise { + this.ship = await this.loader.load(assets.ship); } - private applyMode(index: number): void { - const mode = modes[index]; - this.sprite.setAnchor(mode.anchor[0], mode.anchor[1]); - 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 init(): void { + const { width, height } = this.app; + const column = width / VARIANTS.length; + + VARIANTS.forEach((variant, index) => { + const x = column * (index + 0.5); + const sprite = new Sprite(this.ship).setScale(SCALE).setPosition(x, height / 2); + + sprite.setAnchor(variant.anchor[0], variant.anchor[1]); + + if (variant.origin) { + sprite.setOrigin(variant.origin[0], variant.origin[1]); + } + + this.sprites.push(sprite); + this.labels.push( + new Text(variant.title, { fillColor: Color.white, fontSize: 18, align: 'center' }).setAnchor(0.5, 0).setPosition(x, 40), + ); + }); } override update(delta: Seconds): void { - this.sprite.rotate(delta * 45); + for (const sprite of this.sprites) { + 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, 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); - } + this.overlay.clear(); + this.overlay.lineColor = new Color(110, 215, 245); + this.overlay.lineWidth = 2; + + for (const sprite of this.sprites) { + const bounds = sprite.getBounds(); - override destroy(): void { - this.panel?.dispose(); - this.hud?.dispose(); - this.sprite?.destroy(); - this.pivotMarker?.destroy(); - this.label?.destroy(); - super.destroy(); + context.render(sprite); + this.overlay.fillColor = Color.transparent; + this.overlay.drawRectangle(bounds.x, bounds.y, bounds.width, bounds.height); + this.overlay.fillColor = new Color(255, 80, 80); + this.overlay.drawCircle(sprite.x, sprite.y, 6); + } + + context.render(this.overlay); + + for (const label of this.labels) { + context.render(label); + } } } @@ -89,9 +94,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(PivotAndAnchorScene); diff --git a/examples/scene-graph/retained-container.js b/examples/scene-graph/retained-container.js index 2a1b9aa53..d72680d99 100644 --- a/examples/scene-graph/retained-container.js +++ b/examples/scene-graph/retained-container.js @@ -10,7 +10,7 @@ import { Sprite, Texture, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, toggle } from '@examples/runtime'; // A decor field far larger than the viewport: thousands of sprites that are // authored ONCE and never mutated again - the exact shape the retained tier is // built for. Only the group as a whole ever moves (the "camera" pan below). @@ -63,25 +63,18 @@ class RetainedContainerScene extends Scene { // Exponential moving average of the wall-clock frame time, so the readout // is legible instead of flickering frame to frame. smoothedFrameMs = 0; - hud; panel; init() { this.atlas = createAtlasTexture(); this.field = this.buildField(this.retained); - this.hud = mountControls({ - title: 'Retained Container', - controls: [ - { keys: ['Retained tier'], action: 'the whole static field replays as O(batches) — no per-child walk' }, - { keys: ['Plain container'], action: 'every child is walked and re-collected each frame' }, - ], - hint: 'The field only ever moves as a whole. Toggle the tier and watch the frame-time readout.', - }); - this.panel = mountControlPanel({ title: 'Render tier' }); - this.panel.addToggle({ - label: 'Retained tier', - value: this.retained, - onChange: value => { - this.retained = value; + this.panel = demoPanel({ + title: 'Render tier', + controls: { retained: toggle('Retained tier', this.retained) }, + onChange: (_key, retained) => { + if (retained === this.retained) { + return; + } + this.retained = retained; // Rebuild the field into the other container type. Destroying the // OLD group is safe: it owns no child that outlives it, so the // whole subtree is torn down together (see the in-place-destroy @@ -111,10 +104,8 @@ class RetainedContainerScene extends Scene { // retained tier removes. The gap widens with field size and on slower // machines; on a fast desktop with a few thousand sprites it can be small. this.smoothedFrameMs += (context.stats.frameTimeMs - this.smoothedFrameMs) * 0.1; - const tier = this.retained ? 'RetainedContainer' : 'plain Container'; - this.hud.setStatus( - `${tier} · ${FIELD_COUNT} static sprites · submitted: ${context.stats.submittedNodes} · ` + - `drawCalls: ${context.stats.drawCalls} · frame: ${this.smoothedFrameMs.toFixed(2)} ms`, + this.panel.status( + `${FIELD_COUNT} sprites, ${context.stats.submittedNodes} nodes submitted, ${this.smoothedFrameMs.toFixed(2)} ms per frame`, ); } /** @@ -148,7 +139,6 @@ class RetainedContainerScene extends Scene { return group; } destroy() { - this.hud?.dispose(); this.panel?.dispose(); this.field?.destroy(); this.atlas?.destroy(); diff --git a/examples/scene-graph/retained-container.ts b/examples/scene-graph/retained-container.ts index 8111a7063..4dceddfd2 100644 --- a/examples/scene-graph/retained-container.ts +++ b/examples/scene-graph/retained-container.ts @@ -11,7 +11,7 @@ import { Sprite, Texture, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; // A decor field far larger than the viewport: thousands of sprites that are // authored ONCE and never mutated again - the exact shape the retained tier is @@ -82,28 +82,21 @@ class RetainedContainerScene extends Scene { // is legible instead of flickering frame to frame. private smoothedFrameMs = 0; - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: DemoPanel<{ retained: ToggleSpec }>; override init(): void { this.atlas = createAtlasTexture(); this.field = this.buildField(this.retained); - this.hud = mountControls({ - title: 'Retained Container', - controls: [ - { keys: ['Retained tier'], action: 'the whole static field replays as O(batches) — no per-child walk' }, - { keys: ['Plain container'], action: 'every child is walked and re-collected each frame' }, - ], - hint: 'The field only ever moves as a whole. Toggle the tier and watch the frame-time readout.', - }); + this.panel = demoPanel({ + title: 'Render tier', + controls: { retained: toggle('Retained tier', this.retained) }, + onChange: (_key, retained) => { + if (retained === this.retained) { + return; + } - this.panel = mountControlPanel({ title: 'Render tier' }); - this.panel.addToggle({ - label: 'Retained tier', - value: this.retained, - onChange: value => { - this.retained = value; + this.retained = retained; // Rebuild the field into the other container type. Destroying the // OLD group is safe: it owns no child that outlives it, so the // whole subtree is torn down together (see the in-place-destroy @@ -139,10 +132,8 @@ class RetainedContainerScene extends Scene { // machines; on a fast desktop with a few thousand sprites it can be small. this.smoothedFrameMs += (context.stats.frameTimeMs - this.smoothedFrameMs) * 0.1; - const tier = this.retained ? 'RetainedContainer' : 'plain Container'; - this.hud.setStatus( - `${tier} · ${FIELD_COUNT} static sprites · submitted: ${context.stats.submittedNodes} · ` + - `drawCalls: ${context.stats.drawCalls} · frame: ${this.smoothedFrameMs.toFixed(2)} ms`, + this.panel.status( + `${FIELD_COUNT} sprites, ${context.stats.submittedNodes} nodes submitted, ${this.smoothedFrameMs.toFixed(2)} ms per frame`, ); } @@ -184,7 +175,6 @@ class RetainedContainerScene extends Scene { } override destroy(): void { - this.hud?.dispose(); this.panel?.dispose(); this.field?.destroy(); this.atlas?.destroy(); diff --git a/examples/scene-graph/z-ordering.js b/examples/scene-graph/z-ordering.js index f2d42d9ec..36d3af4eb 100644 --- a/examples/scene-graph/z-ordering.js +++ b/examples/scene-graph/z-ordering.js @@ -1,5 +1,6 @@ // Auto-generated from z-ordering.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ZOrderingScene extends Scene { group; label; @@ -7,7 +8,7 @@ class ZOrderingScene extends Scene { init() { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/ship-a.png'); + const texture = this.loader.get(assets.ship); this.group = new Container(); this.label = new Text('Press 1, 2, 3 — front: 3 (blue)', { fillColor: Color.white, fontSize: 18 }); this.label.setPosition(18, 18); @@ -48,8 +49,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ZOrderingScene); diff --git a/examples/scene-graph/z-ordering.ts b/examples/scene-graph/z-ordering.ts index 4ac699357..33cffbbdf 100644 --- a/examples/scene-graph/z-ordering.ts +++ b/examples/scene-graph/z-ordering.ts @@ -9,6 +9,7 @@ import { Sprite, Text, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ZOrderingScene extends Scene { private group!: Container; @@ -18,7 +19,7 @@ class ZOrderingScene extends Scene { override init(): void { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/ship-a.png'); + const texture = this.loader.get(assets.ship); this.group = new Container(); this.label = new Text('Press 1, 2, 3 — front: 3 (blue)', { fillColor: Color.white, fontSize: 18 }); @@ -66,9 +67,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(ZOrderingScene); diff --git a/examples/shared/assets-global.d.ts b/examples/shared/assets-global.d.ts deleted file mode 100644 index b87cc4302..000000000 --- a/examples/shared/assets-global.d.ts +++ /dev/null @@ -1,230 +0,0 @@ -// AUTO-GENERATED by `pnpm -C site examples:sync` - DO NOT EDIT BY HAND. -// Source of truth: examples/assets/assets.ts (the canonical hierarchical catalog). -// Regenerate with `tsx scripts/generate-examples-global-dts.ts`; drift is guarded -// by test/examples/assets-global-dts.test.ts. -// -// Self-contained, importless typing for the globally-injected `assets` catalog. -// The value is installed on `globalThis`/`window` by the controlled example -// runtimes (Playground iframe, Example/Guide preview, Asset Browser, smoke -// harness, Full Release harness) BEFORE the example module evaluates. Example -// authors use it directly with full autocomplete and exact literal types - no -// import: -// -// assets.demo.textures.particleFlame; -// assets.demo.audio.musicLoop; -// assets.technical.alpha.alphaEdgeStraight; -// -// It is NOT part of the engine public API and is unavailable in consumer apps. - -interface ExampleAssetCatalog { - readonly demo: { - readonly isometricLandscape: 'json/maps/isometric-landscape.tmj'; - readonly textures: { - readonly particleFlame: 'demo/textures/particle-flame.png'; - readonly particleSmoke: 'demo/textures/particle-smoke.png'; - readonly particleStar: 'demo/textures/particle-star.png'; - readonly particleSpark: 'demo/textures/particle-spark.png'; - readonly particleLight: 'demo/textures/particle-light.png'; - readonly shipA: 'demo/textures/ship-a.png'; - readonly pixelWhite: 'demo/textures/pixel-white.png'; - readonly pixelBlack: 'demo/textures/pixel-black.png'; - readonly pixelTransparent: 'demo/textures/pixel-transparent.png'; - readonly checkerboardTransparent: 'demo/textures/checkerboard-transparent.png'; - readonly kenneyUv: 'demo/textures/kenney-uv.png'; - readonly prototypeDark01: 'demo/textures/prototype-dark-01.png'; - readonly prototypeLight01: 'demo/textures/prototype-light-01.png'; - readonly prototypeGrid: 'demo/textures/prototype-grid.png'; - }; - readonly sprites: { - readonly buttons: { - readonly image: 'demo/sprites/buttons.png'; - readonly data: 'demo/sprites/buttons.json'; - }; - }; - readonly spritesheets: { - readonly buttons: { - readonly image: 'demo/sprites/buttons.png'; - readonly data: 'demo/sprites/buttons.json'; - }; - readonly platformerBackgrounds: { - readonly image: 'demo/spritesheets/kenney/platformer-backgrounds.png'; - readonly data: 'demo/spritesheets/kenney/platformer-backgrounds.json'; - }; - readonly platformerCharacters: { - readonly image: 'demo/spritesheets/kenney/platformer-characters.png'; - readonly data: 'demo/spritesheets/kenney/platformer-characters.json'; - }; - readonly platformerEnemies: { - readonly image: 'demo/spritesheets/kenney/platformer-enemies.png'; - readonly data: 'demo/spritesheets/kenney/platformer-enemies.json'; - }; - readonly platformerTiles: { - readonly image: 'demo/spritesheets/kenney/platformer-tiles.png'; - readonly data: 'demo/spritesheets/kenney/platformer-tiles.json'; - }; - readonly backgroundElements: { - readonly image: 'demo/spritesheets/kenney/background-elements.png'; - readonly data: 'demo/spritesheets/kenney/background-elements.json'; - }; - readonly mapPack: { - readonly image: 'demo/spritesheets/kenney/map-pack.png'; - readonly data: 'demo/spritesheets/kenney/map-pack.json'; - }; - }; - readonly tilesets: { - readonly platformer: { - readonly image: 'demo/spritesheets/kenney/platformer-tiles.png'; - readonly data: 'demo/spritesheets/kenney/platformer-tiles.json'; - readonly tileWidth: 64; - readonly tileHeight: 64; - }; - readonly map: { - readonly image: 'demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png'; - readonly tileWidth: 64; - readonly tileHeight: 64; - }; - }; - readonly audio: { - readonly uiClick: 'demo/audio/ui-click.ogg'; - readonly uiConfirm: 'demo/audio/ui-confirm.ogg'; - readonly uiBong: 'demo/audio/ui-bong.ogg'; - readonly impactLight: 'demo/audio/impact-light.ogg'; - readonly impactHeavy: 'demo/audio/impact-heavy.ogg'; - readonly musicA: 'demo/audio/demo-loop-a.ogg'; - readonly musicB: 'demo/audio/demo-loop-b.ogg'; - readonly musicLoop: 'demo/audio/demo-loop-main.ogg'; - }; - readonly voice: { - readonly congratulations: 'demo/voice/congratulations.ogg'; - readonly missionComplete: 'demo/voice/mission-complete.ogg'; - readonly objectiveAchieved: 'demo/voice/objective-achieved.ogg'; - }; - readonly sound: { - readonly uiClick: 'demo/sound/ui-click.ogg'; - readonly uiConfirm: 'demo/sound/ui-confirm.ogg'; - readonly uiBong: 'demo/sound/ui-bong.ogg'; - readonly back: 'demo/sound/back.ogg'; - readonly clickAlt: 'demo/sound/click-alt.ogg'; - readonly switch: 'demo/sound/switch.ogg'; - readonly impactLight: 'demo/sound/impact-light.ogg'; - readonly impactHeavy: 'demo/sound/impact-heavy.ogg'; - readonly impactWood: 'demo/sound/impact-wood.ogg'; - readonly jump: 'demo/sound/jump.ogg'; - readonly coin: 'demo/sound/coin.ogg'; - readonly hurt: 'demo/sound/hurt.ogg'; - readonly powerUp: 'demo/sound/power-up.ogg'; - readonly laser: 'demo/sound/laser.ogg'; - }; - readonly music: { - readonly loopA: 'demo/music/demo-loop-a.ogg'; - readonly loopB: 'demo/music/demo-loop-b.ogg'; - readonly loopMain: 'demo/music/demo-loop-main.ogg'; - readonly jingleSuccess: 'demo/music/jingle-success.ogg'; - readonly jingleFailure: 'demo/music/jingle-failure.ogg'; - readonly jingleRetroA: 'demo/music/jingle-retro-a.ogg'; - readonly jingleRetroB: 'demo/music/jingle-retro-b.ogg'; - }; - readonly soundSprites: { - readonly ui: { - readonly audio: 'demo/sound-sprites/ui/ui.ogg'; - readonly data: 'demo/sound-sprites/ui/ui.json'; - }; - readonly impacts: { - readonly audio: 'demo/sound-sprites/impacts/impacts.ogg'; - readonly data: 'demo/sound-sprites/impacts/impacts.json'; - }; - readonly digital: { - readonly audio: 'demo/sound-sprites/digital/digital.ogg'; - readonly data: 'demo/sound-sprites/digital/digital.json'; - }; - }; - readonly fonts: { - readonly kenneyFuture: 'demo/fonts/Kenney Future.ttf'; - readonly kenneyPixel: 'demo/fonts/Kenney Pixel.ttf'; - readonly kenneyMini: 'demo/fonts/Kenney Mini.ttf'; - readonly kenneyMiniSquareMono: 'demo/fonts/Kenney Mini Square Mono.ttf'; - readonly kenneyBlocksFnt: 'demo/fonts/kenney-blocks-32.fnt'; - readonly kenneyBlocksPng: 'demo/fonts/kenney-blocks-32.png'; - }; - readonly backgrounds: { - readonly platformerClouds: 'demo/backgrounds/platformer-clouds.png'; - readonly platformerHills: 'demo/backgrounds/platformer-hills.png'; - readonly castles: 'demo/backgrounds/castles.png'; - readonly forest: 'demo/backgrounds/forest.png'; - }; - readonly cursors: { - readonly arrow: 'demo/cursors/arrow.svg'; - readonly pointer: 'demo/cursors/pointer.svg'; - readonly hand: 'demo/cursors/hand.svg'; - readonly busy: 'demo/cursors/busy.svg'; - readonly cross: 'demo/cursors/cross.svg'; - }; - readonly svg: { - readonly play: 'demo/svg/play.svg'; - readonly pause: 'demo/svg/pause.svg'; - readonly reset: 'demo/svg/reset.svg'; - readonly arrowRight: 'demo/svg/arrow-right.svg'; - readonly sparkle: 'demo/svg/sparkle.svg'; - readonly audioWave: 'demo/svg/audio-wave.svg'; - readonly imagePlaceholder: 'demo/svg/image-placeholder.svg'; - readonly runeMark: 'demo/svg/rune-mark.svg'; - }; - readonly video: { - readonly demoLoop: 'demo/video/demo-loop.webm'; - readonly highRes: 'demo/video/high-res.mp4'; - readonly highFps: 'demo/video/high-fps.webm'; - readonly hdr10: 'demo/video/hdr10.webm'; - }; - readonly inputPrompts: { - readonly generic: { - readonly image: 'demo/input-prompts/generic.png'; - readonly data: 'demo/input-prompts/generic.json'; - }; - readonly xboxSeries: { - readonly image: 'demo/input-prompts/xbox-series.png'; - readonly data: 'demo/input-prompts/xbox-series.json'; - }; - readonly playstationSeries: { - readonly image: 'demo/input-prompts/playstation-series.png'; - readonly data: 'demo/input-prompts/playstation-series.json'; - }; - readonly nintendoSwitch: { - readonly image: 'demo/input-prompts/nintendo-switch.png'; - readonly data: 'demo/input-prompts/nintendo-switch.json'; - }; - readonly keyboardMouse: { - readonly image: 'demo/input-prompts/keyboard-mouse.png'; - readonly data: 'demo/input-prompts/keyboard-mouse.json'; - }; - }; - readonly vendor: { - readonly kenneyManifest: 'demo/vendor/kenney/manifest.json'; - }; - }; - readonly technical: { - readonly alpha: { - readonly alphaEdgeStraight: 'technical/alpha/alpha-edge-straight.png'; - readonly alphaGradientRings: 'technical/alpha/alpha-gradient-rings.png'; - }; - readonly filtering: { - readonly checker256: 'technical/filtering/checker-256.png'; - readonly pixelGrid128: 'technical/filtering/pixel-grid-128.png'; - readonly uvGrid256: 'technical/filtering/uv-grid-256.png'; - }; - readonly color: { - readonly srgbRamp: 'technical/color/srgb-ramp.png'; - readonly hueRamp: 'technical/color/hue-ramp.png'; - readonly primaryRamp: 'technical/color/primary-ramp.png'; - }; - }; -} - -declare global { - const assets: ExampleAssetCatalog; - - interface Window { - readonly assets: ExampleAssetCatalog; - } -} - -export {}; diff --git a/examples/shared/runtime.d.ts b/examples/shared/runtime.d.ts index 1b5d45a35..b1fc0e346 100644 --- a/examples/shared/runtime.d.ts +++ b/examples/shared/runtime.d.ts @@ -33,10 +33,6 @@ export interface ExampleRuntimeMeta { order?: number; status?: string; } -export interface ExampleRuntime { - assets: Record; - assetUrl?: (path: string) => string; -} declare global { interface Window { __EXAMPLE_META__?: ExampleRuntimeMeta | null; @@ -72,6 +68,8 @@ export interface ControlPanelHandle { max?: number; step?: number; value?: number; + /** Readout text for a value; two decimals when omitted. */ + format?: (value: number) => string; onChange?: (value: number) => void; }): ControlBinding; addToggle(options: { @@ -141,3 +139,71 @@ export declare const trackPressButtons: (canvas: HTMLCanvasElement) => PressButt * consistent, discoverable way instead of hand-rolling canvas hit-tests. */ export declare const mountControlPanel: (options?: MountControlPanelOptions) => ControlPanelHandle; +/** A numeric range control. */ +export interface SliderSpec { + readonly kind: 'slider'; + readonly label: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly format?: (value: number) => string; +} +/** An on/off control. */ +export interface ToggleSpec { + readonly kind: 'toggle'; + readonly label: string; + readonly value: boolean; +} +/** One of a fixed list of named options; shows the name and its position, e.g. `Screen (5/18)`. */ +export interface ChoiceSpec { + readonly kind: 'choice'; + readonly label: string; + readonly options: readonly T[]; + readonly value: T; +} +export type DemoControlSpec = SliderSpec | ToggleSpec | ChoiceSpec; +/** The value type a control spec holds. */ +export type DemoControlValue = S extends SliderSpec ? number : S extends ToggleSpec ? boolean : S extends ChoiceSpec ? T : never; +export type DemoPanelValues> = { + -readonly [K in keyof C]: DemoControlValue; +}; +export declare const slider: (label: string, options: { + min: number; + max: number; + value: number; + step?: number; + format?: (value: number) => string; +}) => SliderSpec; +export declare const toggle: (label: string, value?: boolean) => ToggleSpec; +export declare const choice: (label: string, options: readonly T[], value?: T) => ChoiceSpec; +export interface DemoPanelOptions> { + title?: string; + corner?: OverlayCorner; + controls: C; + /** Buttons below the controls, in order. */ + actions?: Record void>; + /** + * Applies a changed value to the scene. Called once with every key on mount, + * then for each change from a control or from `set`/`step`. + */ + onChange?: (key: K, value: DemoControlValue, values: Readonly>) => void; +} +/** Handle returned by {@link demoPanel}. */ +export interface DemoPanel> { + readonly element: HTMLElement; + /** Current values; read-only, change them through `set` or `step`. */ + readonly values: Readonly>; + set(key: K, value: DemoControlValue): void; + /** Moves a choice by `delta` options (wrapping), a slider by `delta` steps (clamped), or flips a toggle. */ + step(key: keyof C, delta?: number): void; + /** A short status line under the controls; an empty string hides it. */ + status(text: string): void; + dispose(): void; +} +/** + * Mounts a small control panel from a declarative control list and keeps one + * value per control. Keyboard shortcuts should call `set`/`step` so the panel + * and the shortcut never disagree. + */ +export declare const demoPanel: >(options: DemoPanelOptions) => DemoPanel; diff --git a/examples/shared/runtime.js b/examples/shared/runtime.js index b850f4d39..e96943d29 100644 --- a/examples/shared/runtime.js +++ b/examples/shared/runtime.js @@ -240,7 +240,7 @@ export const mountControlPanel = (options = {}) => { panel.addEventListener('pointerdown', stop); return { element: panel, - addSlider({ label, min = 0, max = 1, step = 0.01, value = min, onChange }) { + addSlider({ label, min = 0, max = 1, step = 0.01, value = min, format = v => v.toFixed(2), onChange }) { const row = createControlRowContainer(); const input = document.createElement('input'); const readout = document.createElement('span'); @@ -260,7 +260,7 @@ export const mountControlPanel = (options = {}) => { font: `600 12px ${FONT_STACK}`, }); const render = v => { - readout.textContent = Number(v).toFixed(2); + readout.textContent = format(Number(v)); }; render(value); input.addEventListener('input', () => { @@ -387,3 +387,109 @@ export const mountControlPanel = (options = {}) => { }, }; }; +export const slider = (label, options) => ({ kind: 'slider', step: (options.max - options.min) / 100, ...options, label }); +export const toggle = (label, value = false) => ({ kind: 'toggle', label, value }); +export const choice = (label, options, value = options[0]) => ({ + kind: 'choice', + label, + options, + value, +}); +/** + * Mounts a small control panel from a declarative control list and keeps one + * value per control. Keyboard shortcuts should call `set`/`step` so the panel + * and the shortcut never disagree. + */ +export const demoPanel = options => { + const panel = mountControlPanel({ title: options.title, corner: options.corner }); + const values = {}; + const renderers = new Map(); + const keys = Object.keys(options.controls); + const statusEl = document.createElement('div'); + const commit = (key, value, fromControl) => { + values[key] = value; + if (!fromControl) { + renderers.get(key)?.set(value); + } + options.onChange?.(key, value, values); + }; + for (const key of keys) { + const spec = options.controls[key]; + switch (spec.kind) { + case 'slider': { + values[key] = spec.value; + renderers.set( + key, + panel.addSlider({ + label: spec.label, + min: spec.min, + max: spec.max, + step: spec.step, + value: spec.value, + format: spec.format, + onChange: value => commit(key, value, true), + }), + ); + break; + } + case 'toggle': { + values[key] = spec.value; + renderers.set( + key, + panel.addToggle({ + label: spec.label, + value: spec.value, + onChange: value => commit(key, value, true), + }), + ); + break; + } + case 'choice': { + const labelled = spec.options.map((name, index) => `${name} (${index + 1}/${spec.options.length})`); + const binding = panel.addCycle({ + label: spec.label, + options: labelled, + index: Math.max(0, spec.options.indexOf(spec.value)), + onChange: index => commit(key, spec.options[index], true), + }); + values[key] = spec.value; + renderers.set(key, { set: value => binding.set(spec.options.indexOf(value)) }); + break; + } + } + } + for (const [label, run] of Object.entries(options.actions ?? {})) { + panel.addButton({ label, onClick: run }); + } + Object.assign(statusEl.style, { marginTop: '6px', font: `600 12px ${FONT_STACK}`, color: '#9fd0ff' }); + statusEl.setAttribute('aria-live', 'polite'); + statusEl.hidden = true; + panel.element.append(statusEl); + const handle = { + element: panel.element, + values, + set: (key, value) => commit(key, value, false), + step(key, delta = 1) { + const spec = options.controls[key]; + if (spec.kind === 'toggle') { + commit(key, !values[key], false); + } else if (spec.kind === 'choice') { + const count = spec.options.length; + const index = spec.options.indexOf(values[key]); + commit(key, spec.options[(((index + delta) % count) + count) % count], false); + } else { + const next = Math.min(spec.max, Math.max(spec.min, values[key] + delta * spec.step)); + commit(key, next, false); + } + }, + status(text) { + statusEl.textContent = text; + statusEl.hidden = text === ''; + }, + dispose: () => panel.dispose(), + }; + for (const key of keys) { + options.onChange?.(key, values[key], values); + } + return handle; +}; diff --git a/examples/shared/runtime.ts b/examples/shared/runtime.ts index 1a0b51c5b..624b335ae 100644 --- a/examples/shared/runtime.ts +++ b/examples/shared/runtime.ts @@ -46,16 +46,10 @@ export interface ExampleRuntimeMeta { status?: string; } -export interface ExampleRuntime { - assets: Record; - assetUrl?: (path: string) => string; -} - declare global { interface Window { __EXAMPLE_META__?: ExampleRuntimeMeta | null; __EXAMPLE_PREVIEW_AUTOPLAY__?: (() => void | Promise) | null; - // `assets` is declared (typed) in assets-global.d.ts. } // Declared on the global scope as well as on `Window`: the runner injects the @@ -97,6 +91,8 @@ export interface ControlPanelHandle { max?: number; step?: number; value?: number; + /** Readout text for a value; two decimals when omitted. */ + format?: (value: number) => string; onChange?: (value: number) => void; }): ControlBinding; addToggle(options: { label: string; value?: boolean; onChange?: (value: boolean) => void }): ControlBinding; @@ -443,7 +439,7 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr return { element: panel, - addSlider({ label, min = 0, max = 1, step = 0.01, value = min, onChange }) { + addSlider({ label, min = 0, max = 1, step = 0.01, value = min, format = v => v.toFixed(2), onChange }) { const row = createControlRowContainer(); const input = document.createElement('input'); const readout = document.createElement('span'); @@ -466,7 +462,7 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr }); const render = (v: number) => { - readout.textContent = Number(v).toFixed(2); + readout.textContent = format(Number(v)); }; render(value); @@ -620,3 +616,204 @@ export const mountControlPanel = (options: MountControlPanelOptions = {}): Contr }, }; }; + +// --------------------------------------------------------------------------- +// Declarative demo panel +// +// One state object drives both the visible controls and any keyboard or pointer +// shortcut an example adds: shortcuts call `panel.set()`/`panel.step()`, the +// controls re-render from the same value, and `onChange` applies it to the scene +// in exactly one place. +// --------------------------------------------------------------------------- + +/** A numeric range control. */ +export interface SliderSpec { + readonly kind: 'slider'; + readonly label: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly format?: (value: number) => string; +} + +/** An on/off control. */ +export interface ToggleSpec { + readonly kind: 'toggle'; + readonly label: string; + readonly value: boolean; +} + +/** One of a fixed list of named options; shows the name and its position, e.g. `Screen (5/18)`. */ +export interface ChoiceSpec { + readonly kind: 'choice'; + readonly label: string; + readonly options: readonly T[]; + readonly value: T; +} + +export type DemoControlSpec = SliderSpec | ToggleSpec | ChoiceSpec; + +/** The value type a control spec holds. */ +export type DemoControlValue = S extends SliderSpec + ? number + : S extends ToggleSpec + ? boolean + : S extends ChoiceSpec + ? T + : never; + +export type DemoPanelValues> = { -readonly [K in keyof C]: DemoControlValue }; + +export const slider = ( + label: string, + options: { min: number; max: number; value: number; step?: number; format?: (value: number) => string }, +): SliderSpec => ({ kind: 'slider', step: (options.max - options.min) / 100, ...options, label }); + +export const toggle = (label: string, value = false): ToggleSpec => ({ kind: 'toggle', label, value }); + +export const choice = (label: string, options: readonly T[], value: T = options[0]!): ChoiceSpec => ({ + kind: 'choice', + label, + options, + value, +}); + +export interface DemoPanelOptions> { + title?: string; + corner?: OverlayCorner; + controls: C; + /** Buttons below the controls, in order. */ + actions?: Record void>; + /** + * Applies a changed value to the scene. Called once with every key on mount, + * then for each change from a control or from `set`/`step`. + */ + onChange?: (key: K, value: DemoControlValue, values: Readonly>) => void; +} + +/** Handle returned by {@link demoPanel}. */ +export interface DemoPanel> { + readonly element: HTMLElement; + /** Current values; read-only, change them through `set` or `step`. */ + readonly values: Readonly>; + set(key: K, value: DemoControlValue): void; + /** Moves a choice by `delta` options (wrapping), a slider by `delta` steps (clamped), or flips a toggle. */ + step(key: keyof C, delta?: number): void; + /** A short status line under the controls; an empty string hides it. */ + status(text: string): void; + dispose(): void; +} + +/** + * Mounts a small control panel from a declarative control list and keeps one + * value per control. Keyboard shortcuts should call `set`/`step` so the panel + * and the shortcut never disagree. + */ +export const demoPanel = >(options: DemoPanelOptions): DemoPanel => { + const panel = mountControlPanel({ title: options.title, corner: options.corner }); + const values = {} as DemoPanelValues; + const renderers = new Map>(); + const keys = Object.keys(options.controls) as Array; + const statusEl = document.createElement('div'); + + const commit = (key: K, value: DemoControlValue, fromControl: boolean): void => { + values[key] = value as DemoPanelValues[K]; + + if (!fromControl) { + renderers.get(key)?.set(value as never); + } + + options.onChange?.(key, value, values); + }; + + for (const key of keys) { + const spec = options.controls[key]!; + + switch (spec.kind) { + case 'slider': { + values[key] = spec.value as DemoPanelValues[typeof key]; + renderers.set( + key, + panel.addSlider({ + label: spec.label, + min: spec.min, + max: spec.max, + step: spec.step, + value: spec.value, + format: spec.format, + onChange: value => commit(key, value as DemoControlValue, true), + }) as ControlBinding, + ); + break; + } + case 'toggle': { + values[key] = spec.value as DemoPanelValues[typeof key]; + renderers.set( + key, + panel.addToggle({ + label: spec.label, + value: spec.value, + onChange: value => commit(key, value as DemoControlValue, true), + }) as ControlBinding, + ); + break; + } + case 'choice': { + const labelled = spec.options.map((name, index) => `${name} (${index + 1}/${spec.options.length})`); + const binding = panel.addCycle({ + label: spec.label, + options: labelled, + index: Math.max(0, spec.options.indexOf(spec.value)), + onChange: index => commit(key, spec.options[index] as DemoControlValue, true), + }); + + values[key] = spec.value as DemoPanelValues[typeof key]; + renderers.set(key, { set: (value: never) => binding.set(spec.options.indexOf(value)) }); + break; + } + } + } + + for (const [label, run] of Object.entries(options.actions ?? {})) { + panel.addButton({ label, onClick: run }); + } + + Object.assign(statusEl.style, { marginTop: '6px', font: `600 12px ${FONT_STACK}`, color: '#9fd0ff' }); + statusEl.setAttribute('aria-live', 'polite'); + statusEl.hidden = true; + panel.element.append(statusEl); + + const handle: DemoPanel = { + element: panel.element, + values, + set: (key, value) => commit(key, value, false), + step(key, delta = 1) { + const spec = options.controls[key]!; + + if (spec.kind === 'toggle') { + commit(key, !values[key] as DemoControlValue, false); + } else if (spec.kind === 'choice') { + const count = spec.options.length; + const index = spec.options.indexOf(values[key] as string); + + commit(key, spec.options[(((index + delta) % count) + count) % count] as DemoControlValue, false); + } else { + const next = Math.min(spec.max, Math.max(spec.min, (values[key] as number) + delta * spec.step)); + + commit(key, next as DemoControlValue, false); + } + }, + status(text) { + statusEl.textContent = text; + statusEl.hidden = text === ''; + }, + dispose: () => panel.dispose(), + }; + + for (const key of keys) { + options.onChange?.(key, values[key], values); + } + + return handle; +}; diff --git a/examples/showcase/audio-reactive-particles.js b/examples/showcase/audio-reactive-particles.js index 546541c42..65ed7d6fd 100644 --- a/examples/showcase/audio-reactive-particles.js +++ b/examples/showcase/audio-reactive-particles.js @@ -1,8 +1,8 @@ // Auto-generated from audio-reactive-particles.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text, Vector } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; -import { AlphaFadeOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn } from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Text, Vector } from '@codexo/exojs'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; +import { AlphaFadeOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const colors = [new Color(255, 120, 140), new Color(120, 220, 255), new Color(130, 255, 170), new Color(255, 220, 120)]; class AudioReactiveParticlesScene extends Scene { music; @@ -13,7 +13,6 @@ class AudioReactiveParticlesScene extends Scene { spawn; rate; cone; - hud; tapPrompt; async load() { const app = this.app; @@ -21,13 +20,13 @@ class AudioReactiveParticlesScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); // Two parallel taps of the same track: the analyser gives per-band // energy (drives emission), the detector gives beats (recolours). this.analyser = new AudioAnalyser({ fftSize: 1024, source: app.audio.music }); this.detector = new BeatDetector(); this.detector.source = app.audio.music; - this.ps = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 6000 }); + this.ps = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 6000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); // The rate (density) and the cone speed range (spread) are mutated every @@ -50,12 +49,6 @@ class AudioReactiveParticlesScene extends Scene { this.detector.onBeat.add(() => { this.spawn.config.tint = new Constant(colors[(Math.random() * colors.length) | 0]); }); - this.hud = mountControls({ - title: 'Audio Reactive Particles', - controls: [{ keys: 'Audio', action: 'bass → density · treble → spread' }], - status: 'Listening…', - hint: 'Density follows low-band energy; spread follows high-band energy. Silence = still.', - }); // 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 the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -63,21 +56,18 @@ class AudioReactiveParticlesScene extends Scene { .setPosition(width / 2, height - 64); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } update(_delta) { // Low band (bass) drives how MANY particles spawn this second. const low = this.analyser.getBandEnergy(20, 180); // High band (treble) drives how WIDE the velocity cone fans out. const high = this.analyser.getBandEnergy(2000, 16000); - // bass → density: 0 in silence, up to ~1200 particles/s on heavy bass. + // Bass drives density: 0 in silence, up to ~1200 particles/s on heavy bass. this.rate.value = low * low * 1200; - // treble → spread: a tight slow core grows into a fast wide burst. + // Treble drives spread: a tight slow core grows into a fast wide burst. this.cone.minSpeed = 40 + high * 120; this.cone.maxSpeed = 90 + high * 360; - if (this.musicVoice) { - this.hud.setStatus(`bass ${(low * 100) | 0}% treble ${(high * 100) | 0}%`); - } } draw(context) { const app = this.app; diff --git a/examples/showcase/audio-reactive-particles.ts b/examples/showcase/audio-reactive-particles.ts index a14e4c1e4..bcc4ec39c 100644 --- a/examples/showcase/audio-reactive-particles.ts +++ b/examples/showcase/audio-reactive-particles.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -11,9 +10,9 @@ import { Vector, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; -import { AlphaFadeOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn } from '@codexo/exojs-particles'; -import { mountControls } from '@examples/runtime'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; +import { AlphaFadeOverLifetime, ConeDirection, Constant, particlesExtension, ParticleSystem, RateSpawn } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const colors = [new Color(255, 120, 140), new Color(120, 220, 255), new Color(130, 255, 170), new Color(255, 220, 120)]; @@ -26,7 +25,6 @@ class AudioReactiveParticlesScene extends Scene { private spawn!: RateSpawn; private rate!: Constant; private cone!: ConeDirection; - private hud!: ReturnType; private tapPrompt!: Text; override async load(): Promise { @@ -36,7 +34,7 @@ class AudioReactiveParticlesScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); // Two parallel taps of the same track: the analyser gives per-band // energy (drives emission), the detector gives beats (recolours). @@ -44,7 +42,7 @@ class AudioReactiveParticlesScene extends Scene { this.detector = new BeatDetector(); this.detector.source = app.audio.music; - this.ps = new ParticleSystem(this.loader.get(assets.demo.textures.particleLight), { capacity: 6000 }); + this.ps = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 6000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); @@ -70,13 +68,6 @@ class AudioReactiveParticlesScene extends Scene { this.spawn.config.tint = new Constant(colors[(Math.random() * colors.length) | 0]); }); - this.hud = mountControls({ - title: 'Audio Reactive Particles', - controls: [{ keys: 'Audio', action: 'bass → density · treble → spread' }], - status: 'Listening…', - hint: 'Density follows low-band energy; spread follows high-band energy. Silence = still.', - }); - // 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 the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -85,7 +76,7 @@ class AudioReactiveParticlesScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(_delta: Seconds): void { @@ -94,16 +85,12 @@ class AudioReactiveParticlesScene extends Scene { // High band (treble) drives how WIDE the velocity cone fans out. const high = this.analyser.getBandEnergy(2000, 16000); - // bass → density: 0 in silence, up to ~1200 particles/s on heavy bass. + // Bass drives density: 0 in silence, up to ~1200 particles/s on heavy bass. this.rate.value = low * low * 1200; - // treble → spread: a tight slow core grows into a fast wide burst. + // Treble drives spread: a tight slow core grows into a fast wide burst. this.cone.minSpeed = 40 + high * 120; this.cone.maxSpeed = 90 + high * 360; - - if (this.musicVoice) { - this.hud.setStatus(`bass ${(low * 100) | 0}% treble ${(high * 100) | 0}%`); - } } override draw(context: RenderingContext): void { diff --git a/examples/showcase/audio-visualisation.js b/examples/showcase/audio-visualisation.js index 10976a446..e66da30a2 100644 --- a/examples/showcase/audio-visualisation.js +++ b/examples/showcase/audio-visualisation.js @@ -1,7 +1,7 @@ // Auto-generated from audio-visualisation.ts - edit the .ts source, not this file. -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'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const BAND_LABELS = ['Sub', 'Bass', 'Low mid', 'Mid', 'High mid', 'Presence', 'Treble', 'Air']; class AudioVisualisationScene extends Scene { music; @@ -14,7 +14,6 @@ class AudioVisualisationScene extends Scene { progressStyle = 'rgba(255, 255, 255, 0.1)'; texture; screen; - hud; tapPrompt; bandEdges = []; async load() { @@ -23,7 +22,7 @@ class AudioVisualisationScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); // One analyser tap for spectrum/waveform, one beat detector for the // beat-pulse ring. Both read the music bus the stream plays through, // without altering playback. @@ -50,12 +49,6 @@ class AudioVisualisationScene extends Scene { this.gradientStyle.addColorStop(1, '#f70'); this.texture = new Texture(this.canvas); this.screen = new Sprite(this.texture); - this.hud = mountControls({ - title: 'Spectrum and Waveform', - controls: [{ keys: 'Click', action: 'play / pause' }], - status: 'Playing…', - 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. this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -72,11 +65,10 @@ class AudioVisualisationScene extends Scene { } else { this.musicVoice.pause(); } - this.hud.setStatus(this.musicVoice.paused ? 'Paused' : 'Playing…'); }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } draw(context) { const app = this.app; @@ -144,7 +136,8 @@ class AudioVisualisationScene extends Scene { } this.screen.updateTexture(); context.render(this.screen); - if (app.audio.locked) { + if (app.audio.locked || this.musicVoice.paused) { + this.tapPrompt.text = app.audio.locked ? 'Click or press any key to start the music' : 'Paused - click to resume'; context.render(this.tapPrompt); } } diff --git a/examples/showcase/audio-visualisation.ts b/examples/showcase/audio-visualisation.ts index 971ef0aca..8428ccb9c 100644 --- a/examples/showcase/audio-visualisation.ts +++ b/examples/showcase/audio-visualisation.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -13,8 +12,8 @@ import { Texture, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser, BeatDetector } from '@codexo/exojs-audio-fx'; -import { mountControls } from '@examples/runtime'; +import { AudioAnalyser, BeatDetector } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const BAND_LABELS = ['Sub', 'Bass', 'Low mid', 'Mid', 'High mid', 'Presence', 'Treble', 'Air']; @@ -29,7 +28,6 @@ class AudioVisualisationScene extends Scene { private progressStyle = 'rgba(255, 255, 255, 0.1)'; private texture!: Texture; private screen!: Sprite; - private hud!: ReturnType; private tapPrompt!: Text; private bandEdges: number[] = []; @@ -40,7 +38,7 @@ class AudioVisualisationScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); // One analyser tap for spectrum/waveform, one beat detector for the // beat-pulse ring. Both read the music bus the stream plays through, @@ -75,13 +73,6 @@ class AudioVisualisationScene extends Scene { this.texture = new Texture(this.canvas); this.screen = new Sprite(this.texture); - this.hud = mountControls({ - title: 'Spectrum and Waveform', - controls: [{ keys: 'Click', action: 'play / pause' }], - status: 'Playing…', - 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. this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -100,13 +91,11 @@ class AudioVisualisationScene extends Scene { } else { this.musicVoice.pause(); } - - this.hud.setStatus(this.musicVoice.paused ? 'Paused' : 'Playing…'); }); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }) as Voice & Pausable & Seekable; + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }) as Voice & Pausable & Seekable; } override draw(context: RenderingContext): void { @@ -191,7 +180,8 @@ class AudioVisualisationScene extends Scene { context.render(this.screen); - if (app.audio.locked) { + if (app.audio.locked || this.musicVoice.paused) { + this.tapPrompt.text = app.audio.locked ? 'Click or press any key to start the music' : 'Paused - click to resume'; context.render(this.tapPrompt); } } diff --git a/examples/showcase/boss-intro-cinematic.js b/examples/showcase/boss-intro-cinematic.js index 87f4fd95e..431846afe 100644 --- a/examples/showcase/boss-intro-cinematic.js +++ b/examples/showcase/boss-intro-cinematic.js @@ -1,6 +1,6 @@ // Auto-generated from boss-intro-cinematic.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, Text, Time, View } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite, Text, Time, View } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const titleText = 'VOID EMPEROR'; class BossIntroCinematicScene extends Scene { view; @@ -12,7 +12,7 @@ class BossIntroCinematicScene extends Scene { boss; music; musicVoice; - hud; + finished = false; tapPrompt; width = 0; height = 0; @@ -30,21 +30,13 @@ class BossIntroCinematicScene extends Scene { this.title = new Text('', { fillColor: Color.white, fontSize: 56, fontWeight: 'bold' }); this.title.setPosition(width * 0.12, height * 0.2); this.titleState = { count: 0 }; - this.boss = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.boss = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.4) .setPosition(width * 0.62, height / 2) .setTint(new Color(255, 130, 130)); - // AudioStream is a non-leaf resource kind (no seamless placeholder), so it - // is loaded directly through `Asset.type('music', ...)` and awaited rather - // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.music.loopMain)); - this.hud = mountControls({ - title: 'Boss Intro Cinematic', - controls: [{ keys: ['R', 'Click'], action: 'replay sequence' }], - status: 'Playing…', - hint: 'Push-in, letterbox bars, a typewriter title reveal, and a screen shake punched on the reveal beat.', - }); + // Music has no placeholder handle, so it is awaited with `load()`. + this.music = await this.loader.load(assets.musicLoopMain); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the sting + cinematic start. this.tapPrompt = new Text('Click or press any key to start the cinematic', { fillColor: Color.white, fontSize: 22, align: 'center' }) @@ -69,7 +61,6 @@ class BossIntroCinematicScene extends Scene { this.musicVoice.resume(); } this.playSequence(); - this.hud.setStatus('Replaying…'); }; playSequence = () => { const { width, height } = this; @@ -82,6 +73,7 @@ class BossIntroCinematicScene extends Scene { this.barSize.v = 0; this.titleState.count = 0; this.title.text = ''; + this.finished = false; this.boss.setScale(0.4); // Letterbox bars slam in. this.sequenceTweens.push(this.tweens.create(this.barSize).to({ v: 84 }, 0.6).start()); @@ -106,10 +98,13 @@ class BossIntroCinematicScene extends Scene { .onUpdate(() => { this.title.text = titleText.slice(0, this.titleState.count | 0); }) + .onComplete(() => { + this.finished = true; + }) .start(), ); // Music swells up under the reveal. - this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2).start()); + this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.6 }, 2).start()); }; draw(context) { const app = this.app; @@ -126,7 +121,10 @@ class BossIntroCinematicScene extends Scene { 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, { view: context.screenView }); - if (app.audio.locked) { + // The replay hint appears only once the sequence has played out, so it + // never competes with the cinematic itself. + if (app.audio.locked || this.finished) { + this.tapPrompt.text = app.audio.locked ? 'Click or press any key to start the cinematic' : 'Press R or click to replay'; context.render(this.tapPrompt, { view: context.screenView }); } } @@ -137,7 +135,6 @@ class BossIntroCinematicScene extends Scene { this.app.audio.onUnlock.remove(this.playSequence); this.app.input.onPointerDown.remove(this.replay); this.musicVoice?.stop(); - this.hud?.dispose(); super.destroy(); } } diff --git a/examples/showcase/boss-intro-cinematic.ts b/examples/showcase/boss-intro-cinematic.ts index d608986d4..b50ac8579 100644 --- a/examples/showcase/boss-intro-cinematic.ts +++ b/examples/showcase/boss-intro-cinematic.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -16,7 +15,7 @@ import { View, type Voice, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const titleText = 'VOID EMPEROR'; @@ -30,7 +29,7 @@ class BossIntroCinematicScene extends Scene { private boss!: Sprite; private music!: AudioStream; private musicVoice!: Voice & Seekable & Pausable; - private hud!: ReturnType; + private finished = false; private tapPrompt!: Text; private width = 0; private height = 0; @@ -50,22 +49,13 @@ class BossIntroCinematicScene extends Scene { this.title = new Text('', { fillColor: Color.white, fontSize: 56, fontWeight: 'bold' }); this.title.setPosition(width * 0.12, height * 0.2); this.titleState = { count: 0 }; - this.boss = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.boss = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.4) .setPosition(width * 0.62, height / 2) .setTint(new Color(255, 130, 130)); - // AudioStream is a non-leaf resource kind (no seamless placeholder), so it - // is loaded directly through `Asset.type('music', ...)` and awaited rather - // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.music.loopMain)); - - this.hud = mountControls({ - title: 'Boss Intro Cinematic', - controls: [{ keys: ['R', 'Click'], action: 'replay sequence' }], - status: 'Playing…', - hint: 'Push-in, letterbox bars, a typewriter title reveal, and a screen shake punched on the reveal beat.', - }); + // Music has no placeholder handle, so it is awaited with `load()`. + this.music = await this.loader.load(assets.musicLoopMain); // Shown while the browser still blocks audio (`app.audio.locked`); the // first click or keypress unlocks it and the sting + cinematic start. @@ -98,7 +88,6 @@ class BossIntroCinematicScene extends Scene { } this.playSequence(); - this.hud.setStatus('Replaying…'); }; private readonly playSequence = (): void => { @@ -114,6 +103,7 @@ class BossIntroCinematicScene extends Scene { this.barSize.v = 0; this.titleState.count = 0; this.title.text = ''; + this.finished = false; this.boss.setScale(0.4); // Letterbox bars slam in. @@ -139,10 +129,13 @@ class BossIntroCinematicScene extends Scene { .onUpdate(() => { this.title.text = titleText.slice(0, this.titleState.count | 0); }) + .onComplete(() => { + this.finished = true; + }) .start(), ); // Music swells up under the reveal. - this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2).start()); + this.sequenceTweens.push(this.tweens.create(this.musicVoice).to({ volume: 0.6 }, 2).start()); }; override draw(context: RenderingContext): void { @@ -162,7 +155,10 @@ class BossIntroCinematicScene extends Scene { this.bars.drawRectangle(0, height - this.barSize.v, width, this.barSize.v); context.render(this.bars, { view: context.screenView }); - if (app.audio.locked) { + // The replay hint appears only once the sequence has played out, so it + // never competes with the cinematic itself. + if (app.audio.locked || this.finished) { + this.tapPrompt.text = app.audio.locked ? 'Click or press any key to start the cinematic' : 'Press R or click to replay'; context.render(this.tapPrompt, { view: context.screenView }); } } @@ -175,7 +171,6 @@ class BossIntroCinematicScene extends Scene { this.app.audio.onUnlock.remove(this.playSequence); this.app.input.onPointerDown.remove(this.replay); this.musicVoice?.stop(); - this.hud?.dispose(); super.destroy(); } } diff --git a/examples/showcase/color-grading.js b/examples/showcase/color-grading.js index a15fbee96..b93720577 100644 --- a/examples/showcase/color-grading.js +++ b/examples/showcase/color-grading.js @@ -1,5 +1,6 @@ // Auto-generated from color-grading.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Keyboard, LutFilter, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const LUT_SIZE = 17; const buildLut3D = transform => { @@ -59,7 +60,7 @@ const LOOKS = [ }, { name: 'Identity (off)', transform: (r, g, b) => [r, g, b] }, ]; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; class ColorGradingScene extends Scene { luts; filter; diff --git a/examples/showcase/color-grading.ts b/examples/showcase/color-grading.ts index 5eb023b5c..21d8ab4c2 100644 --- a/examples/showcase/color-grading.ts +++ b/examples/showcase/color-grading.ts @@ -9,6 +9,7 @@ import { Sprite, type Texture, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const LUT_SIZE = 17; @@ -83,7 +84,7 @@ const LOOKS = [ { name: 'Identity (off)', transform: (r: number, g: number, b: number): [number, number, number] => [r, g, b] }, ]; -const PRIMARY_RAMP = assets.technical.color.primaryRamp; +const PRIMARY_RAMP = assets.primaryRamp; class ColorGradingScene extends Scene { private luts!: Texture[]; diff --git a/examples/showcase/damage-flash.js b/examples/showcase/damage-flash.js index e1381b583..1b49aa7b8 100644 --- a/examples/showcase/damage-flash.js +++ b/examples/showcase/damage-flash.js @@ -1,5 +1,6 @@ // Auto-generated from damage-flash.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Signal, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class DamageFlashScene extends Scene { hit; @@ -11,7 +12,7 @@ class DamageFlashScene extends Scene { const app = this.app; const { width, height } = app; this.hit = new Signal(); - this.ship = new Sprite(this.loader.get('image/ship-a.png')) + this.ship = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2.2) .setPosition(width / 2, height / 2); @@ -51,8 +52,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(DamageFlashScene); diff --git a/examples/showcase/damage-flash.ts b/examples/showcase/damage-flash.ts index 3270fc9ea..e6df966bb 100644 --- a/examples/showcase/damage-flash.ts +++ b/examples/showcase/damage-flash.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Signal, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class DamageFlashScene extends Scene { @@ -13,7 +14,7 @@ class DamageFlashScene extends Scene { const { width, height } = app; this.hit = new Signal(); - this.ship = new Sprite(this.loader.get('image/ship-a.png')) + this.ship = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2.2) .setPosition(width / 2, height / 2); @@ -58,9 +59,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(DamageFlashScene); diff --git a/examples/showcase/dialog-system.js b/examples/showcase/dialog-system.js index 8ae1953be..3a8b6424c 100644 --- a/examples/showcase/dialog-system.js +++ b/examples/showcase/dialog-system.js @@ -1,6 +1,6 @@ // Auto-generated from dialog-system.ts - edit the .ts source, not this file. -import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { Application, Button, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const lines = [ { speaker: 'Commander Vale', text: 'Commander, the anomaly has entered low orbit.' }, { speaker: 'Commander Vale', text: 'All wings hold formation and await my signal.' }, @@ -12,13 +12,13 @@ class DialogSystemScene extends Scene { box; choicePrompt; beep; - hud; - panel; + replyButtons = []; lineIndex = 0; chars = 0; timer = 0; done = false; awaitingChoice = false; + restartPending = false; init() { const app = this.app; const { width, height } = app; @@ -29,19 +29,14 @@ class DialogSystemScene extends Scene { this.box.setPosition(textX, height * 0.56); this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); this.choicePrompt.setPosition(textX, height * 0.78); - this.beep = this.loader.get(assets.demo.sound.uiConfirm); - this.hud = mountControls({ - title: 'Dialogue and Choices', - controls: [{ keys: 'Click', action: 'advance / reveal' }], - hint: 'Click anywhere to skip the typewriter, then click again to continue.', + this.beep = this.loader.get(assets.soundUiConfirm); + this.replyButtons = choices.map((reply, index) => { + const button = new Button({ label: reply, width: 220, height: 48 }); + button.anchorIn(this.ui, 'top-left', textX + index * 240, height * 0.78); + button.onClick.add(() => this.choose(reply)); + this.ui.addChild(button); + return button; }); - // Choice buttons live in a predictable DOM panel so they never compete - // with the canvas pointer for "advance" taps. They stay hidden until the - // final line finishes typing. - this.panel = mountControlPanel({ title: 'Your reply', corner: 'bottom-left' }); - for (const choice of choices) { - this.panel.addButton({ label: choice, onClick: () => this.choose(choice) }); - } this.setChoicesVisible(false); app.input.onPointerTap.add(() => this.advance()); } @@ -74,19 +69,25 @@ class DialogSystemScene extends Scene { const app = this.app; app.audio.play(this.beep, { playbackRate: 1.2, volume: 0.3 }); this.choicePrompt.text = `You chose: ${choice}`; - this.hud.setStatus(`Reply: ${choice}`); - this.awaitingChoice = false; this.setChoicesVisible(false); - // Loop back to the top so the demo can be replayed without a reload. - this.lineIndex = 0; - this.startLine(); + // The tap that pressed the button must not also advance the restarted + // dialogue, so the loop back to the top waits for the next update. + this.restartPending = true; } setChoicesVisible(visible) { - this.panel.element.style.display = visible ? '' : 'none'; + for (const button of this.replyButtons) { + button.visible = visible; + } this.choicePrompt.visible = !visible && this.choicePrompt.text.length > 0; } update(delta) { const app = this.app; + if (this.restartPending) { + this.restartPending = false; + this.awaitingChoice = false; + this.lineIndex = 0; + this.startLine(); + } if (!this.done && !this.awaitingChoice) { this.timer += delta; while (this.timer > 0.035 && this.chars < lines[this.lineIndex].text.length) { diff --git a/examples/showcase/dialog-system.ts b/examples/showcase/dialog-system.ts index a96c6fcc0..9f8500c3d 100644 --- a/examples/showcase/dialog-system.ts +++ b/examples/showcase/dialog-system.ts @@ -1,5 +1,6 @@ import { Application, + Button, Color, FixedResolutionCanvasSizing, type RenderingContext, @@ -8,7 +9,7 @@ import { type Sound, Text, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; interface DialogLine { speaker: string; @@ -28,13 +29,13 @@ class DialogSystemScene extends Scene { private box!: Text; private choicePrompt!: Text; private beep!: Sound; - private hud!: ReturnType; - private panel!: ReturnType; + private replyButtons: Button[] = []; private lineIndex = 0; private chars = 0; private timer = 0; private done = false; private awaitingChoice = false; + private restartPending = false; override init(): void { const app = this.app; @@ -51,22 +52,17 @@ class DialogSystemScene extends Scene { this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); this.choicePrompt.setPosition(textX, height * 0.78); - this.beep = this.loader.get(assets.demo.sound.uiConfirm); + this.beep = this.loader.get(assets.soundUiConfirm); - this.hud = mountControls({ - title: 'Dialogue and Choices', - controls: [{ keys: 'Click', action: 'advance / reveal' }], - hint: 'Click anywhere to skip the typewriter, then click again to continue.', - }); + this.replyButtons = choices.map((reply, index) => { + const button = new Button({ label: reply, width: 220, height: 48 }); - // Choice buttons live in a predictable DOM panel so they never compete - // with the canvas pointer for "advance" taps. They stay hidden until the - // final line finishes typing. - this.panel = mountControlPanel({ title: 'Your reply', corner: 'bottom-left' }); + button.anchorIn(this.ui, 'top-left', textX + index * 240, height * 0.78); + button.onClick.add(() => this.choose(reply)); + this.ui.addChild(button); - for (const choice of choices) { - this.panel.addButton({ label: choice, onClick: () => this.choose(choice) }); - } + return button; + }); this.setChoicesVisible(false); @@ -109,22 +105,30 @@ class DialogSystemScene extends Scene { const app = this.app; app.audio.play(this.beep, { playbackRate: 1.2, volume: 0.3 }); this.choicePrompt.text = `You chose: ${choice}`; - this.hud.setStatus(`Reply: ${choice}`); - this.awaitingChoice = false; this.setChoicesVisible(false); - // Loop back to the top so the demo can be replayed without a reload. - this.lineIndex = 0; - this.startLine(); + // The tap that pressed the button must not also advance the restarted + // dialogue, so the loop back to the top waits for the next update. + this.restartPending = true; } private setChoicesVisible(visible: boolean): void { - this.panel.element.style.display = visible ? '' : 'none'; + for (const button of this.replyButtons) { + button.visible = visible; + } + this.choicePrompt.visible = !visible && this.choicePrompt.text.length > 0; } override update(delta: Seconds): void { const app = this.app; + if (this.restartPending) { + this.restartPending = false; + this.awaitingChoice = false; + this.lineIndex = 0; + this.startLine(); + } + if (!this.done && !this.awaitingChoice) { this.timer += delta; diff --git a/examples/showcase/gamepad-spaceship.js b/examples/showcase/gamepad-spaceship.js index 60fd56b24..3ef2d7a8d 100644 --- a/examples/showcase/gamepad-spaceship.js +++ b/examples/showcase/gamepad-spaceship.js @@ -33,7 +33,8 @@ import { ParticleSystem, Range, ScaleOverLifetime, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const random = new Random(7); class GamepadSpaceshipScene extends Scene { @@ -75,7 +76,7 @@ class GamepadSpaceshipScene extends Scene { this.stars.fillColor = new Color(200, 215, 255, brightness); this.stars.drawCircle(random.next(0, width), random.next(0, height), random.next(0.6, 1.8)); } - this.ship = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.ship = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.75) .setPosition(width / 2, height / 2); @@ -91,7 +92,7 @@ class GamepadSpaceshipScene extends Scene { // already unlocked. app.audio.onUnlock.add(this.startEngine); this.fx = new Graphics(); - this.particles = new ParticleSystem(this.loader.get(assets.demo.textures.particleSpark), { capacity: 4000 }); + this.particles = new ParticleSystem(this.loader.get(assets.particleSpark), { capacity: 4000 }); this.particles.setBlendMode(BlendModes.Additive); this.systems.add(this.particles); this.burst = new BurstSpawn({ @@ -132,14 +133,7 @@ class GamepadSpaceshipScene extends Scene { }) .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 40); - this.hud = mountControls({ - title: 'Fly a Spaceship', - controls: [ - { keys: 'WASD / Arrows / Left stick', action: 'steer and thrust' }, - { keys: 'Space / Right trigger', action: 'fire' }, - ], - status: 'Score: 0', - }); + this.hud = mountControls({ status: 'Score: 0' }); } startEngine = () => { this.engine ??= this.app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 55 }), { bus: this.engineBus, volume: 0 }); diff --git a/examples/showcase/gamepad-spaceship.ts b/examples/showcase/gamepad-spaceship.ts index 56db2ad7e..5c7434bc4 100644 --- a/examples/showcase/gamepad-spaceship.ts +++ b/examples/showcase/gamepad-spaceship.ts @@ -36,7 +36,8 @@ import { ParticleSystem, Range, ScaleOverLifetime, -} from '@codexo/exojs-particles'; +} from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; interface Bullet { @@ -105,7 +106,7 @@ class GamepadSpaceshipScene extends Scene { this.stars.drawCircle(random.next(0, width), random.next(0, height), random.next(0.6, 1.8)); } - this.ship = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.ship = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(0.75) .setPosition(width / 2, height / 2); @@ -125,7 +126,7 @@ class GamepadSpaceshipScene extends Scene { this.fx = new Graphics(); - this.particles = new ParticleSystem(this.loader.get(assets.demo.textures.particleSpark), { capacity: 4000 }); + this.particles = new ParticleSystem(this.loader.get(assets.particleSpark), { capacity: 4000 }); this.particles.setBlendMode(BlendModes.Additive); this.systems.add(this.particles); this.burst = new BurstSpawn({ @@ -169,14 +170,7 @@ class GamepadSpaceshipScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 40); - this.hud = mountControls({ - title: 'Fly a Spaceship', - controls: [ - { keys: 'WASD / Arrows / Left stick', action: 'steer and thrust' }, - { keys: 'Space / Right trigger', action: 'fire' }, - ], - status: 'Score: 0', - }); + this.hud = mountControls({ status: 'Score: 0' }); } private readonly startEngine = (): void => { diff --git a/examples/showcase/loading-progress-with-shader.js b/examples/showcase/loading-progress-with-shader.js index bcb071ef8..de2fef34b 100644 --- a/examples/showcase/loading-progress-with-shader.js +++ b/examples/showcase/loading-progress-with-shader.js @@ -1,5 +1,6 @@ // Auto-generated from loading-progress-with-shader.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, ShaderFilter, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const glsl = `#version 300 es precision mediump float; uniform float uProgress; in vec2 vUv; out vec4 fragColor; void main(){ vec2 p=vUv-0.5; float r=length(p); float a=atan(p.y,p.x); float t=(a+3.1415926)/(6.2831852); @@ -26,7 +27,7 @@ class LoadingProgressWithShaderScene extends Scene { this.progress = { v: 0 }; this.label = new Text('0%', { fillColor: Color.white, fontSize: 42, align: 'center' }); this.label.setAnchor(0.5, 0.5).setPosition(width / 2, height / 2); - this.ring = new Sprite(this.loader.get('image/uv-grid-256.png')) + this.ring = new Sprite(this.loader.get(assets.uvGrid256)) .setAnchor(0.5) .setScale(2.4) .setPosition(width / 2, height / 2); @@ -53,8 +54,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 18, 28), - loader: { - basePath: 'assets/', - }, }); await app.start(LoadingProgressWithShaderScene); diff --git a/examples/showcase/loading-progress-with-shader.ts b/examples/showcase/loading-progress-with-shader.ts index fb6da61f9..40da1498d 100644 --- a/examples/showcase/loading-progress-with-shader.ts +++ b/examples/showcase/loading-progress-with-shader.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, ShaderFilter, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const glsl = `#version 300 es precision mediump float; uniform float uProgress; in vec2 vUv; out vec4 fragColor; @@ -29,7 +30,7 @@ class LoadingProgressWithShaderScene extends Scene { this.progress = { v: 0 }; this.label = new Text('0%', { fillColor: Color.white, fontSize: 42, align: 'center' }); this.label.setAnchor(0.5, 0.5).setPosition(width / 2, height / 2); - this.ring = new Sprite(this.loader.get('image/uv-grid-256.png')) + this.ring = new Sprite(this.loader.get(assets.uvGrid256)) .setAnchor(0.5) .setScale(2.4) .setPosition(width / 2, height / 2); @@ -60,9 +61,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(14, 18, 28), - loader: { - basePath: 'assets/', - }, }); await app.start(LoadingProgressWithShaderScene); diff --git a/examples/showcase/low-band-camera-shake.js b/examples/showcase/low-band-camera-shake.js index d86f8cb11..6e35af26f 100644 --- a/examples/showcase/low-band-camera-shake.js +++ b/examples/showcase/low-band-camera-shake.js @@ -1,6 +1,7 @@ // Auto-generated from low-band-camera-shake.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Time, View } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite, Text, Time, View } from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class LowBandCameraShakeScene extends Scene { music; @@ -16,10 +17,10 @@ class LowBandCameraShakeScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); this.analyser = new AudioAnalyser({ fftSize: 1024, source: app.audio.music }); this.view = new View(width / 2, height / 2, width, height); - this.sprite = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(3) .setPosition(width / 2, height / 2); @@ -36,7 +37,7 @@ class LowBandCameraShakeScene extends Scene { .setPosition(width / 2, height - 64); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } update() { const low = this.analyser.getBandEnergy(20, 180); diff --git a/examples/showcase/low-band-camera-shake.ts b/examples/showcase/low-band-camera-shake.ts index 683b70d7f..97d9957cb 100644 --- a/examples/showcase/low-band-camera-shake.ts +++ b/examples/showcase/low-band-camera-shake.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -12,7 +11,8 @@ import { View, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class LowBandCameraShakeScene extends Scene { @@ -31,10 +31,10 @@ class LowBandCameraShakeScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); this.analyser = new AudioAnalyser({ fftSize: 1024, source: app.audio.music }); this.view = new View(width / 2, height / 2, width, height); - this.sprite = new Sprite(this.loader.get(assets.demo.textures.shipA)) + this.sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(3) .setPosition(width / 2, height / 2); @@ -54,7 +54,7 @@ class LowBandCameraShakeScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(): void { diff --git a/examples/showcase/minimap-with-mask.js b/examples/showcase/minimap-with-mask.js index 3e7637ef1..2d882eff9 100644 --- a/examples/showcase/minimap-with-mask.js +++ b/examples/showcase/minimap-with-mask.js @@ -106,8 +106,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MinimapWithMaskScene); diff --git a/examples/showcase/minimap-with-mask.ts b/examples/showcase/minimap-with-mask.ts index 6e72a595b..ba211559e 100644 --- a/examples/showcase/minimap-with-mask.ts +++ b/examples/showcase/minimap-with-mask.ts @@ -121,9 +121,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MinimapWithMaskScene); diff --git a/examples/showcase/mouse-parallax.js b/examples/showcase/mouse-parallax.js index 4bb9f00ad..99a02f6b7 100644 --- a/examples/showcase/mouse-parallax.js +++ b/examples/showcase/mouse-parallax.js @@ -55,8 +55,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 22, 34), - loader: { - basePath: 'assets/', - }, }); await app.start(MouseParallaxScene); diff --git a/examples/showcase/mouse-parallax.ts b/examples/showcase/mouse-parallax.ts index 26b47989c..da7ec05c8 100644 --- a/examples/showcase/mouse-parallax.ts +++ b/examples/showcase/mouse-parallax.ts @@ -67,9 +67,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 22, 34), - loader: { - basePath: 'assets/', - }, }); await app.start(MouseParallaxScene); diff --git a/examples/showcase/pause-blur.js b/examples/showcase/pause-blur.js index 14aaea6c7..d38eff2ad 100644 --- a/examples/showcase/pause-blur.js +++ b/examples/showcase/pause-blur.js @@ -11,6 +11,7 @@ import { SceneAvailability, Sprite, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const PAUSE_BLUR_STRENGTH = 3; const PAUSE_FADE_SECONDS = 0.35; @@ -26,7 +27,7 @@ class GameScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')) + this.sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2) .setPosition(width * 0.72, height / 2); @@ -40,12 +41,7 @@ class GameScene extends Scene { this.pauseLabel.anchorIn(this.ui, 'center'); this.pauseLabel.visible = false; this.ui.addChild(this.pauseLabel); - this.hud = mountControls({ - title: 'Pause Menu', - controls: [{ keys: 'Esc / Click', action: 'pause / resume' }], - status: 'Running', - hint: 'Scene updates stop while paused; the application tween still fades in the blur.', - }); + this.hud = mountControls({ status: 'Running' }); this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause(), { when: SceneAvailability.Always }); app.input.onPointerDown.add(this.onPointerDown); } @@ -90,8 +86,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(20, 24, 34), - loader: { - basePath: 'assets/', - }, }); void app.start(GameScene); diff --git a/examples/showcase/pause-blur.ts b/examples/showcase/pause-blur.ts index c16badbf2..fc48bce00 100644 --- a/examples/showcase/pause-blur.ts +++ b/examples/showcase/pause-blur.ts @@ -12,6 +12,7 @@ import { Sprite, type Tween, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const PAUSE_BLUR_STRENGTH = 3; @@ -31,7 +32,7 @@ class GameScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')) + this.sprite = new Sprite(this.loader.get(assets.ship)) .setAnchor(0.5) .setScale(2) .setPosition(width * 0.72, height / 2); @@ -48,12 +49,7 @@ class GameScene extends Scene { this.pauseLabel.visible = false; this.ui.addChild(this.pauseLabel); - this.hud = mountControls({ - title: 'Pause Menu', - controls: [{ keys: 'Esc / Click', action: 'pause / resume' }], - status: 'Running', - hint: 'Scene updates stop while paused; the application tween still fades in the blur.', - }); + this.hud = mountControls({ status: 'Running' }); this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause(), { when: SceneAvailability.Always }); app.input.onPointerDown.add(this.onPointerDown); @@ -106,9 +102,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(20, 24, 34), - loader: { - basePath: 'assets/', - }, }); void app.start(GameScene); diff --git a/examples/showcase/rectangles-collision.js b/examples/showcase/rectangles-collision.js index 0be6d0aa7..0f23990b3 100644 --- a/examples/showcase/rectangles-collision.js +++ b/examples/showcase/rectangles-collision.js @@ -1,5 +1,6 @@ // Auto-generated from rectangles-collision.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const CLEAR_TINT = new Color(120, 200, 255); const OVERLAP_TINT = new Color(255, 90, 90); @@ -11,7 +12,7 @@ class RectanglesCollisionScene extends Scene { init() { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/hue-ramp.png'); + const texture = this.loader.get(assets.hueRamp); // Two axis-aligned rectangles (no rotation) so collision is a true AABB // test. Explicit width/height drive the sprite scale; anchor 0.5 keeps the // drag offset and the bounds centred on the pointer-grabbed point. @@ -24,12 +25,7 @@ class RectanglesCollisionScene extends Scene { this.addChild(this.boxA); this.addChild(this.boxB); this.overlap = new Graphics(); - this.hud = mountControls({ - title: 'Rectangles Collision', - controls: [{ keys: 'Drag', action: 'move a rectangle' }], - status: 'No overlap', - hint: 'Drag either rectangle over the other — the AABB overlap region lights up.', - }); + this.hud = mountControls({ status: 'No overlap' }); } makeBox(texture, w, h) { const box = new Sprite(texture).setAnchor(0.5); @@ -76,8 +72,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 22, 30), - loader: { - basePath: 'assets/', - }, }); await app.start(RectanglesCollisionScene); diff --git a/examples/showcase/rectangles-collision.ts b/examples/showcase/rectangles-collision.ts index 0cf6f3ef5..43163a56e 100644 --- a/examples/showcase/rectangles-collision.ts +++ b/examples/showcase/rectangles-collision.ts @@ -8,6 +8,7 @@ import { Sprite, type Texture, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const CLEAR_TINT = new Color(120, 200, 255); @@ -22,7 +23,7 @@ class RectanglesCollisionScene extends Scene { override init(): void { const app = this.app; const { width, height } = app; - const texture = this.loader.get('image/hue-ramp.png'); + const texture = this.loader.get(assets.hueRamp); // Two axis-aligned rectangles (no rotation) so collision is a true AABB // test. Explicit width/height drive the sprite scale; anchor 0.5 keeps the @@ -40,12 +41,7 @@ class RectanglesCollisionScene extends Scene { this.overlap = new Graphics(); - this.hud = mountControls({ - title: 'Rectangles Collision', - controls: [{ keys: 'Drag', action: 'move a rectangle' }], - status: 'No overlap', - hint: 'Drag either rectangle over the other — the AABB overlap region lights up.', - }); + this.hud = mountControls({ status: 'No overlap' }); } private makeBox(texture: Texture, w: number, h: number): Sprite { @@ -101,9 +97,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 22, 30), - loader: { - basePath: 'assets/', - }, }); await app.start(RectanglesCollisionScene); diff --git a/examples/showcase/screen-shake-on-explosion.js b/examples/showcase/screen-shake-on-explosion.js index 51170029a..cbf9d7c00 100644 --- a/examples/showcase/screen-shake-on-explosion.js +++ b/examples/showcase/screen-shake-on-explosion.js @@ -1,7 +1,7 @@ // Auto-generated from screen-shake-on-explosion.ts - edit the .ts source, not this file. 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'; +import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ScreenShakeOnExplosionScene extends Scene { view; ps; @@ -9,22 +9,15 @@ class ScreenShakeOnExplosionScene extends Scene { 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')) + this.ship = new Sprite(this.loader.get(assets.ship)) .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.ps = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 5000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); this.burstPos = new Vector(0, 0); @@ -41,8 +34,6 @@ 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(); }); @@ -64,9 +55,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); await app.start(ScreenShakeOnExplosionScene); diff --git a/examples/showcase/screen-shake-on-explosion.ts b/examples/showcase/screen-shake-on-explosion.ts index 67d7d7e0e..e3c7a4823 100644 --- a/examples/showcase/screen-shake-on-explosion.ts +++ b/examples/showcase/screen-shake-on-explosion.ts @@ -1,6 +1,6 @@ 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'; +import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class ScreenShakeOnExplosionScene extends Scene { private view!: View; @@ -9,24 +9,17 @@ class ScreenShakeOnExplosionScene extends Scene { 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')) + this.ship = new Sprite(this.loader.get(assets.ship)) .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.ps = new ParticleSystem(this.loader.get(assets.particleLight), { capacity: 5000 }); this.systems.add(this.ps); this.ps.setPosition(width / 2, height / 2); this.burstPos = new Vector(0, 0); @@ -43,8 +36,6 @@ 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(); }); @@ -69,9 +60,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, extensions: [particlesExtension], }); diff --git a/examples/showcase/typewriter-text.js b/examples/showcase/typewriter-text.js index be63c8733..a67c82d40 100644 --- a/examples/showcase/typewriter-text.js +++ b/examples/showcase/typewriter-text.js @@ -1,5 +1,6 @@ // Auto-generated from typewriter-text.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const message = 'ExoJS gives you explicit rendering control with a compact scene and asset workflow.'; class TypewriterTextScene extends Scene { sound; @@ -10,7 +11,7 @@ class TypewriterTextScene extends Scene { init() { const app = this.app; const { width, height } = app; - this.sound = this.loader.get('audio/ui-click.ogg'); + this.sound = this.loader.get(assets.soundUiClick); this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 1.4, maxWidth: 900 }); this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2); this.progress = { count: 0 }; @@ -53,8 +54,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TypewriterTextScene); diff --git a/examples/showcase/typewriter-text.ts b/examples/showcase/typewriter-text.ts index 60f79aefe..805c1874a 100644 --- a/examples/showcase/typewriter-text.ts +++ b/examples/showcase/typewriter-text.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const message = 'ExoJS gives you explicit rendering control with a compact scene and asset workflow.'; @@ -13,7 +14,7 @@ class TypewriterTextScene extends Scene { const app = this.app; const { width, height } = app; - this.sound = this.loader.get('audio/ui-click.ogg'); + this.sound = this.loader.get(assets.soundUiClick); this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 1.4, maxWidth: 900 }); this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2); this.progress = { count: 0 }; @@ -62,9 +63,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TypewriterTextScene); diff --git a/examples/showcase/vinyl-record.js b/examples/showcase/vinyl-record.js index c78f6afa0..c0e437369 100644 --- a/examples/showcase/vinyl-record.js +++ b/examples/showcase/vinyl-record.js @@ -1,6 +1,7 @@ // Auto-generated from vinyl-record.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class VinylRecordScene extends Scene { music; @@ -18,7 +19,7 @@ class VinylRecordScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); this.analyser = new AudioAnalyser({ fftSize: 1024, source: app.audio.music }); this.disc = new Graphics(); this.bars = new Graphics(); @@ -35,7 +36,7 @@ class VinylRecordScene extends Scene { .setPosition(width / 2, height - 64); // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } update(delta) { // Spin speed is driven by live audio energy, not a constant fallback BPM. diff --git a/examples/showcase/vinyl-record.ts b/examples/showcase/vinyl-record.ts index dfde49af8..7a2586b91 100644 --- a/examples/showcase/vinyl-record.ts +++ b/examples/showcase/vinyl-record.ts @@ -1,6 +1,5 @@ import { Application, - Asset, type AudioStream, Color, FixedResolutionCanvasSizing, @@ -11,7 +10,8 @@ import { Text, type Voice, } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class VinylRecordScene extends Scene { @@ -32,7 +32,7 @@ class VinylRecordScene extends Scene { // AudioStream is a non-leaf resource kind (no seamless placeholder), so it // is loaded directly through `Asset.type('music', ...)` and awaited rather // than fetched synchronously via `get()`. - this.music = await this.loader.load(Asset.type('music', assets.demo.audio.musicLoop)); + this.music = await this.loader.load(assets.musicLoopMain); this.analyser = new AudioAnalyser({ fftSize: 1024, source: app.audio.music }); this.disc = new Graphics(); this.bars = new Graphics(); @@ -52,7 +52,7 @@ class VinylRecordScene extends Scene { // Core defers playback until the AudioContext unlocks on the first // gesture, then starts automatically - play() returns the Voice now. - this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.8 }); + this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.6 }); } override update(delta: Seconds): void { diff --git a/examples/spatial-audio/falloff-curves.js b/examples/spatial-audio/falloff-curves.js index 5f24b1782..13625d724 100644 --- a/examples/spatial-audio/falloff-curves.js +++ b/examples/spatial-audio/falloff-curves.js @@ -1,5 +1,6 @@ // Auto-generated from falloff-curves.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Horizontal placement (0..1 of canvas width) for each source; absolute pixel // positions are resolved against the canvas in init(). @@ -45,7 +46,7 @@ class FalloffCurvesScene extends Scene { // Each derived Sound below reads .audioBuffer synchronously, so the // shared source must be fully decoded first - await load() instead of // the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/impact-light.ogg')); + const source = await this.loader.load(assets.soundImpactLight); this.sounds = this.sources.map(() => new Sound(source.audioBuffer)); this.graphics = new Graphics(); this.labels = this.sources.map(({ model, x, y }) => { @@ -137,8 +138,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(FalloffCurvesScene); diff --git a/examples/spatial-audio/falloff-curves.ts b/examples/spatial-audio/falloff-curves.ts index c0448fbbb..3cdee51ca 100644 --- a/examples/spatial-audio/falloff-curves.ts +++ b/examples/spatial-audio/falloff-curves.ts @@ -1,4 +1,5 @@ -import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; type FalloffModel = 'linear' | 'inverse' | 'exponential'; @@ -67,7 +68,7 @@ class FalloffCurvesScene extends Scene { // Each derived Sound below reads .audioBuffer synchronously, so the // shared source must be fully decoded first - await load() instead of // the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/impact-light.ogg')); + const source = await this.loader.load(assets.soundImpactLight); this.sounds = this.sources.map(() => new Sound(source.audioBuffer)); this.graphics = new Graphics(); @@ -178,9 +179,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(FalloffCurvesScene); diff --git a/examples/spatial-audio/listener-and-source.js b/examples/spatial-audio/listener-and-source.js index 9441c3d0b..c93076252 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, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the // wide 1280px canvas. These mirror the Web Audio distance models (see // DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what @@ -56,11 +56,7 @@ class ListenerAndSourceScene extends Scene { graphics; readout; tapPrompt; - hud; panel; - mode = 'drag'; - model = 'linear'; - playing = true; angle = 0; init() { const app = this.app; @@ -77,44 +73,25 @@ class ListenerAndSourceScene extends Scene { 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.hud = mountControls({ - title: 'Spatial Audio', - controls: [{ keys: 'Drag', action: 'move the source in drag mode' }], - status: 'Click or press any key to start…', - hint: 'Best with headphones. Left/right follows the direction to the source; loudness follows the falloff model.', - }); - this.panel = mountControlPanel({ title: 'Source and falloff' }); - this.panel.addCycle({ - label: 'Movement', - options: ['Drag', 'Orbit'], - onChange: index => { - this.mode = index === 0 ? 'drag' : 'orbit'; - this.dragging = false; - this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); - }, - }); - this.panel.addCycle({ - label: 'Falloff', - options: [...MODELS], - onChange: index => { - this.model = MODELS[index]; - if (this.voice) { - this.voice.distanceModel = this.model; - } + this.source.x = width / 2 + 220; + this.source.y = height / 2; + this.panel = demoPanel({ + title: 'Source and falloff', + controls: { + movement: choice('Movement', ['Drag', 'Orbit']), + falloff: choice('Falloff', MODELS), + sound: toggle('Sound', true), }, - }); - this.panel.addToggle({ - label: 'Sound', - value: true, - onChange: value => { - this.playing = value; - if (this.voice) { - this.voice.volume = value ? SOURCE_VOLUME : 0; + onChange: (key, _value, values) => { + if (key === 'movement') { + this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + } else if (this.voice) { + this.voice.distanceModel = values.falloff; + this.voice.volume = values.sound ? SOURCE_VOLUME : 0; } }, }); - this.source.x = width / 2 + 220; - this.source.y = height / 2; app.input.onPointerDown.add(this.onPointerDown); app.input.onPointerMove.add(this.onPointerMove); app.input.onPointerUp.add(this.onPointerUp); @@ -130,7 +107,7 @@ class ListenerAndSourceScene extends Scene { const dx = x - this.source.x; const dy = y - this.source.y; // Generous grab radius so the source is easy to pick up. - if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { + if (this.panel.values.movement === 'Drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { this.dragging = true; } }; @@ -150,17 +127,16 @@ class ListenerAndSourceScene extends Scene { start = () => { this.voice ??= this.app.audio.play(this.sound, { loop: true, - volume: this.playing ? SOURCE_VOLUME : 0, + volume: this.panel.values.sound ? SOURCE_VOLUME : 0, position: this.source, - distanceModel: this.model, + distanceModel: this.panel.values.falloff, refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, rolloffFactor: ROLLOFF, }); - this.hud.setStatus(this.mode === 'drag' ? 'Drag the source around the listener' : 'The source orbits the listener'); }; update(delta) { - if (this.mode !== 'orbit') { + if (this.panel.values.movement !== 'Orbit') { return; } this.angle += delta * 1.1; @@ -176,12 +152,12 @@ class ListenerAndSourceScene extends Scene { const dx = source.x - listener.x; const dy = source.y - listener.y; const dist = Math.sqrt(dx * dx + dy * dy); - const volume = attenuation(this.model, dist); + const volume = attenuation(this.panel.values.falloff, dist); // Equalpower pans by direction, not by horizontal offset: a source on the // listener's horizontal line is hard left/right, one straight above or below it is centred. const pan = dist > 0 ? Math.asin(dx / dist) / (Math.PI / 2) : 0; const panText = pan < -0.05 ? `left ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `right ${pan.toFixed(2)}` : 'centre'; - this.readout.text = `${this.model} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; + this.readout.text = `${this.panel.values.falloff} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; const g = this.graphics.clear(); // Reference and maximum distance rings around the listener. g.lineWidth = 2; @@ -222,8 +198,10 @@ class ListenerAndSourceScene extends Scene { input.onPointerUp.remove(this.onPointerUp); this.app.audio.onUnlock.remove(this.start); this.voice?.stop(); - this.panel.dispose(); - this.hud.dispose(); + this.panel?.dispose(); + this.graphics?.destroy(); + this.readout?.destroy(); + this.tapPrompt?.destroy(); super.destroy(); } } @@ -236,8 +214,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(8, 10, 16), - loader: { - basePath: 'assets/', - }, }); await app.start(ListenerAndSourceScene); diff --git a/examples/spatial-audio/listener-and-source.ts b/examples/spatial-audio/listener-and-source.ts index 78d957d09..6dde6f3a2 100644 --- a/examples/spatial-audio/listener-and-source.ts +++ b/examples/spatial-audio/listener-and-source.ts @@ -1,6 +1,6 @@ import type { RenderingContext, Seconds, Spatializable, Voice } from '@codexo/exojs'; import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; // Spatial parameters tuned to the canvas so attenuation is visible across the // wide 1280px canvas. These mirror the Web Audio distance models (see @@ -66,11 +66,7 @@ class ListenerAndSourceScene extends Scene { private graphics!: Graphics; private readout!: Text; private tapPrompt!: Text; - private hud!: ReturnType; - private panel!: ReturnType; - private mode: 'drag' | 'orbit' = 'drag'; - private model: FalloffModel = 'linear'; - private playing = true; + private panel!: DemoPanel<{ movement: ChoiceSpec<'Drag' | 'Orbit'>; falloff: ChoiceSpec; sound: ToggleSpec }>; private angle = 0; override init(): void { @@ -92,48 +88,27 @@ class ListenerAndSourceScene extends Scene { .setAnchor(0.5, 0.5) .setPosition(width / 2, height - 48); - this.hud = mountControls({ - title: 'Spatial Audio', - controls: [{ keys: 'Drag', action: 'move the source in drag mode' }], - status: 'Click or press any key to start…', - hint: 'Best with headphones. Left/right follows the direction to the source; loudness follows the falloff model.', - }); - this.panel = mountControlPanel({ title: 'Source and falloff' }); - this.panel.addCycle({ - label: 'Movement', - options: ['Drag', 'Orbit'], - onChange: index => { - this.mode = index === 0 ? 'drag' : 'orbit'; - this.dragging = false; - this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); - }, - }); - this.panel.addCycle({ - label: 'Falloff', - options: [...MODELS], - onChange: index => { - this.model = MODELS[index]; - - if (this.voice) { - this.voice.distanceModel = this.model; - } + this.source.x = width / 2 + 220; + this.source.y = height / 2; + + this.panel = demoPanel({ + title: 'Source and falloff', + controls: { + movement: choice('Movement', ['Drag', 'Orbit']), + falloff: choice('Falloff', MODELS), + sound: toggle('Sound', true), }, - }); - this.panel.addToggle({ - label: 'Sound', - value: true, - onChange: value => { - this.playing = value; - - if (this.voice) { - this.voice.volume = value ? SOURCE_VOLUME : 0; + onChange: (key, _value, values) => { + if (key === 'movement') { + this.dragging = false; + this.angle = Math.atan2((this.source.y - this.listener.y) / 160, (this.source.x - this.listener.x) / 220); + } else if (this.voice) { + this.voice.distanceModel = values.falloff; + this.voice.volume = values.sound ? SOURCE_VOLUME : 0; } }, }); - this.source.x = width / 2 + 220; - this.source.y = height / 2; - app.input.onPointerDown.add(this.onPointerDown); app.input.onPointerMove.add(this.onPointerMove); app.input.onPointerUp.add(this.onPointerUp); @@ -152,7 +127,7 @@ class ListenerAndSourceScene extends Scene { const dy = y - this.source.y; // Generous grab radius so the source is easy to pick up. - if (this.mode === 'drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { + if (this.panel.values.movement === 'Drag' && dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) { this.dragging = true; } }; @@ -177,18 +152,17 @@ class ListenerAndSourceScene extends Scene { private readonly start = (): void => { this.voice ??= this.app.audio.play(this.sound, { loop: true, - volume: this.playing ? SOURCE_VOLUME : 0, + volume: this.panel.values.sound ? SOURCE_VOLUME : 0, position: this.source, - distanceModel: this.model, + distanceModel: this.panel.values.falloff, refDistance: REF_DISTANCE, maxDistance: MAX_DISTANCE, rolloffFactor: ROLLOFF, }) as Voice & Spatializable; - this.hud.setStatus(this.mode === 'drag' ? 'Drag the source around the listener' : 'The source orbits the listener'); }; override update(delta: Seconds): void { - if (this.mode !== 'orbit') { + if (this.panel.values.movement !== 'Orbit') { return; } @@ -207,12 +181,12 @@ class ListenerAndSourceScene extends Scene { const dx = source.x - listener.x; const dy = source.y - listener.y; const dist = Math.sqrt(dx * dx + dy * dy); - const volume = attenuation(this.model, dist); + const volume = attenuation(this.panel.values.falloff, dist); // Equalpower pans by direction, not by horizontal offset: a source on the // listener's horizontal line is hard left/right, one straight above or below it is centred. const pan = dist > 0 ? Math.asin(dx / dist) / (Math.PI / 2) : 0; const panText = pan < -0.05 ? `left ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `right ${pan.toFixed(2)}` : 'centre'; - this.readout.text = `${this.model} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; + this.readout.text = `${this.panel.values.falloff} falloff · distance ${dist.toFixed(0)} px · gain ${(volume * 100).toFixed(0)}% · pan ${panText}`; const g = this.graphics.clear(); @@ -262,8 +236,10 @@ class ListenerAndSourceScene extends Scene { input.onPointerUp.remove(this.onPointerUp); this.app.audio.onUnlock.remove(this.start); this.voice?.stop(); - this.panel.dispose(); - this.hud.dispose(); + this.panel?.dispose(); + this.graphics?.destroy(); + this.readout?.destroy(); + this.tapPrompt?.destroy(); super.destroy(); } } @@ -277,9 +253,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(8, 10, 16), - loader: { - basePath: 'assets/', - }, }); await app.start(ListenerAndSourceScene); diff --git a/examples/spatial-audio/moving-source.js b/examples/spatial-audio/moving-source.js index 92998944e..c7fd61551 100644 --- a/examples/spatial-audio/moving-source.js +++ b/examples/spatial-audio/moving-source.js @@ -1,5 +1,6 @@ // Auto-generated from moving-source.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Orbit + attenuation tuned to the wide canvas so the readout reflects what you // hear. @@ -31,7 +32,7 @@ class MovingSourceScene extends Scene { // audible while there is sustained signal to pan/attenuate. The derived // Sound below reads .audioBuffer synchronously, so await load() instead // of the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/demo-loop-main.ogg')); + const source = await this.loader.load(Asset.type('sound', assets.musicLoopMain.source)); this.sound = new Sound(source.audioBuffer); this.listener = { x: width / 2, y: height / 2 }; app.audio.listener.target = this.listener; @@ -118,8 +119,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MovingSourceScene); diff --git a/examples/spatial-audio/moving-source.ts b/examples/spatial-audio/moving-source.ts index e51509649..ed564b8fe 100644 --- a/examples/spatial-audio/moving-source.ts +++ b/examples/spatial-audio/moving-source.ts @@ -1,5 +1,6 @@ import type { RenderingContext, Spatializable, Voice } from '@codexo/exojs'; import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, type Seconds, Sound, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; // Orbit + attenuation tuned to the wide canvas so the readout reflects what you @@ -38,7 +39,7 @@ class MovingSourceScene extends Scene { // audible while there is sustained signal to pan/attenuate. The derived // Sound below reads .audioBuffer synchronously, so await load() instead // of the deferred get() (whose placeholder audioBuffer is null until fill). - const source = await this.loader.load(Asset.type('sound', 'audio/demo-loop-main.ogg')); + const source = await this.loader.load(Asset.type('sound', assets.musicLoopMain.source)); this.sound = new Sound(source.audioBuffer); this.listener = { x: width / 2, y: height / 2 }; app.audio.listener.target = this.listener; @@ -140,9 +141,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(MovingSourceScene); diff --git a/examples/sprites-textures/asset-catalogs.js b/examples/sprites-textures/asset-catalogs.js index 8811d508a..6388092e8 100644 --- a/examples/sprites-textures/asset-catalogs.js +++ b/examples/sprites-textures/asset-catalogs.js @@ -1,13 +1,15 @@ // Auto-generated from asset-catalogs.ts - edit the .ts source, not this file. import { Application, Asset, Assets, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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 -// the file extension; anything else takes an explicit `Asset.type(...)`. +// A catalog is a named, typed group of assets. Its entries are descriptors - +// here from the demo catalog; in your own project usually a bare path, whose +// file extension infers the type, or an explicit `Asset.type(...)`. const SharedAssets = Assets.from({ - logo: 'image/uv-grid-256.png', - click: Asset.type('sound', 'audio/ui-click.ogg'), - atlas: Asset.type('json', 'json/buttons.json'), + logo: assets.uvGrid256, + click: assets.soundUiClick, + atlas: assets.platformerCharactersAtlas, }); // #endregion guide:catalog-declare // #region guide:catalog-compose @@ -15,8 +17,8 @@ const SharedAssets = Assets.from({ // its inputs' handles instead of copying them, so `LevelAssets.logo` is the very // same Texture object as `SharedAssets.logo`. const LevelLocalAssets = Assets.from({ - ship: 'image/ship-a.png', - ground: 'image/hue-ramp.png', + ship: assets.ship, + ground: assets.hueRamp, }); const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets); // #endregion guide:catalog-compose @@ -25,8 +27,8 @@ const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets); // ones are added. The base is never mutated - `LevelLocalAssets.ground` keeps // pointing at its own texture. const NightAssets = Assets.extend(LevelLocalAssets, { - ground: 'image/particle-light.png', // deliberate override - star: 'image/buttons.png', // new key + ground: assets.particleLight, // deliberate override + star: assets.particleStar, // new key }); // #endregion guide:catalog-extend class AssetCatalogsScene extends Scene { @@ -42,16 +44,7 @@ class AssetCatalogsScene extends Scene { loadError = ''; night = false; 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.', - }); + this.hud = mountControls({ status: 'Loading the shared and day catalogs...' }); // #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 @@ -73,7 +66,7 @@ class AssetCatalogsScene extends Scene { // Non-leaf types (`music`, `video`, `bmFont`, `font`, ...) have no // bare-path form and no placeholder to hand back, even for a literal // path - they are always loaded by reference and awaited. - this.theme = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.theme = await this.loader.load(assets.musicLoopMain); // #endregion guide:non-leaf-load // #region guide:catalog-failure // Awaiting a catalog rejects if any leaf fails. Every leaf still carries @@ -117,16 +110,16 @@ class AssetCatalogsScene extends Scene { app.audio.play(this.theme, { volume: 0.5 }); }); this.inputs.onTrigger(Keyboard.G, () => { - void this.useVariant('hue-ramp'); + void this.useVariant(assets.hueRamp.source); }); } // #region guide:dynamic-path /** Replaces the ground texture with a variant chosen at runtime. */ - async useVariant(variant) { + async useVariant(url) { const app = this.app; - // The path is computed rather than a literal, so its type cannot be - // inferred from the extension - name it with `Asset.type(...)`. - const texture = await app.loader.load(Asset.type('texture', `image/${variant}.png`)); + // A URL chosen at runtime has no literal file extension to infer the type + // from - name it with `Asset.type(...)`. + const texture = await app.loader.load(Asset.type('texture', url)); this.ground.setTexture(texture); } // #endregion guide:dynamic-path @@ -154,8 +147,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(AssetCatalogsScene); diff --git a/examples/sprites-textures/asset-catalogs.ts b/examples/sprites-textures/asset-catalogs.ts index 4754cd63e..5c6f0bce6 100644 --- a/examples/sprites-textures/asset-catalogs.ts +++ b/examples/sprites-textures/asset-catalogs.ts @@ -12,15 +12,17 @@ import { Text, type Texture, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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 -// the file extension; anything else takes an explicit `Asset.type(...)`. +// A catalog is a named, typed group of assets. Its entries are descriptors - +// here from the demo catalog; in your own project usually a bare path, whose +// file extension infers the type, or an explicit `Asset.type(...)`. const SharedAssets = Assets.from({ - logo: 'image/uv-grid-256.png', - click: Asset.type('sound', 'audio/ui-click.ogg'), - atlas: Asset.type<{ frames: Record }>('json', 'json/buttons.json'), + logo: assets.uvGrid256, + click: assets.soundUiClick, + atlas: assets.platformerCharactersAtlas, }); // #endregion guide:catalog-declare @@ -29,8 +31,8 @@ const SharedAssets = Assets.from({ // its inputs' handles instead of copying them, so `LevelAssets.logo` is the very // same Texture object as `SharedAssets.logo`. const LevelLocalAssets = Assets.from({ - ship: 'image/ship-a.png', - ground: 'image/hue-ramp.png', + ship: assets.ship, + ground: assets.hueRamp, }); const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets); @@ -41,8 +43,8 @@ const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets); // ones are added. The base is never mutated - `LevelLocalAssets.ground` keeps // pointing at its own texture. const NightAssets = Assets.extend(LevelLocalAssets, { - ground: 'image/particle-light.png', // deliberate override - star: 'image/buttons.png', // new key + ground: assets.particleLight, // deliberate override + star: assets.particleStar, // new key }); // #endregion guide:catalog-extend @@ -62,16 +64,7 @@ class AssetCatalogsScene extends Scene { private night = false; 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.', - }); + this.hud = mountControls({ status: 'Loading the shared and day catalogs...' }); // #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 @@ -97,7 +90,7 @@ class AssetCatalogsScene extends Scene { // Non-leaf types (`music`, `video`, `bmFont`, `font`, ...) have no // bare-path form and no placeholder to hand back, even for a literal // path - they are always loaded by reference and awaited. - this.theme = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg')); + this.theme = await this.loader.load(assets.musicLoopMain); // #endregion guide:non-leaf-load // #region guide:catalog-failure @@ -150,18 +143,18 @@ class AssetCatalogsScene extends Scene { }); this.inputs.onTrigger(Keyboard.G, () => { - void this.useVariant('hue-ramp'); + void this.useVariant(assets.hueRamp.source); }); } // #region guide:dynamic-path /** Replaces the ground texture with a variant chosen at runtime. */ - private async useVariant(variant: string): Promise { + private async useVariant(url: string): Promise { const app = this.app; - // The path is computed rather than a literal, so its type cannot be - // inferred from the extension - name it with `Asset.type(...)`. - const texture = await app.loader.load(Asset.type('texture', `image/${variant}.png`)); + // A URL chosen at runtime has no literal file extension to infer the type + // from - name it with `Asset.type(...)`. + const texture = await app.loader.load(Asset.type('texture', url)); this.ground.setTexture(texture); } @@ -194,9 +187,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(AssetCatalogsScene); diff --git a/examples/sprites-textures/blendmodes.js b/examples/sprites-textures/blendmodes.js index 1321a3fe7..dc53e798e 100644 --- a/examples/sprites-textures/blendmodes.js +++ b/examples/sprites-textures/blendmodes.js @@ -1,7 +1,8 @@ // Auto-generated from blendmodes.ts - edit the .ts source, not this file. -import { Application, Asset, BlendModes, Color, FixedResolutionCanvasSizing, ScaleModes, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; -const ALPHA_RINGS = assets.technical.alpha.alphaGradientRings; +import { Application, Asset, BlendModes, Color, FixedResolutionCanvasSizing, Keyboard, ScaleModes, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel } from '@examples/runtime'; +const ALPHA_RINGS = assets.alphaGradientRings; // Blending runs in linear light on premultiplied colour, so a mode reads // brighter and less muddy than the same equation on encoded values would. The // result alpha follows source-over coverage for the coverage-preserving modes @@ -34,24 +35,14 @@ class BlendmodesScene extends Scene { background; left; right; - index = 0; ticker = 0; - hud; - // Note: passing `options` as a 3rd argument to `loader.get(...)` or - // `loader.load(Asset.type('texture', ...))` alongside a non-Json type currently mis-resolves - // the overload (falls through to the `Json` generic and types the result as - // `unknown`) - see the flagged deviation in the migration report. `load()` - // is awaited here purely to seed the fetch with `scaleMode: Nearest`; its - // return value is intentionally unused. The subsequent 2-argument `get()` - // calls for the same sources are unaffected and stay seamless. async load() { const app = this.app; const { width, height } = app; + // Nearest sampling keeps the ring edges and the ship's pixels crisp at 5x. const textureOptions = { scaleMode: ScaleModes.Nearest }; - await this.loader.load(Asset.type('texture', ALPHA_RINGS, { textureOptions })); - await this.loader.load(Asset.type('texture', assets.demo.textures.shipA, { textureOptions })); - const backgroundTexture = this.loader.get(ALPHA_RINGS); - const shipTexture = this.loader.get(assets.demo.textures.shipA); + const backgroundTexture = await this.loader.load(Asset.type('texture', ALPHA_RINGS.source, { textureOptions })); + const shipTexture = await this.loader.load(Asset.type('texture', assets.ship.source, { textureOptions })); this.background = new Sprite(backgroundTexture); this.background.setPosition(width / 2, height / 2); this.background.setAnchor(0.5, 0.5); @@ -67,23 +58,23 @@ class BlendmodesScene extends Scene { this.right.setAnchor(0.5, 0.5); this.right.setScale(5); this.right.setTint(new Color(255, 96, 200)); - this.hud = mountControls({ - title: 'Blend Modes', - controls: [{ keys: 'Click', action: 'next blend mode' }], + // Clicks, arrow keys and the panel buttons all move the same value. + const panel = demoPanel({ + controls: { + mode: choice( + 'Blend mode', + BLEND_MODES.map(entry => entry.name), + ), + }, + onChange: (_key, name) => { + const { mode } = BLEND_MODES.find(entry => entry.name === name); + this.left.setBlendMode(mode); + this.right.setBlendMode(mode); + }, }); - app.input.onPointerDown.add(() => this.setIndex((this.index + 1) % BLEND_MODES.length)); - // Apply the initial mode (Normal) without skipping it. - this.applyBlendMode(); - } - setIndex(index) { - this.index = ((index % BLEND_MODES.length) + BLEND_MODES.length) % BLEND_MODES.length; - this.applyBlendMode(); - } - applyBlendMode() { - const { mode, name } = BLEND_MODES[this.index]; - this.left.setBlendMode(mode); - this.right.setBlendMode(mode); - this.hud.setStatus(`${name} (${this.index + 1}/${BLEND_MODES.length})`); + app.input.onPointerDown.add(() => panel.step('mode', 1)); + this.inputs.onTrigger(Keyboard.Right, () => panel.step('mode', 1)); + this.inputs.onTrigger(Keyboard.Left, () => panel.step('mode', -1)); } update(delta) { const app = this.app; diff --git a/examples/sprites-textures/blendmodes.ts b/examples/sprites-textures/blendmodes.ts index 431ff6db5..22ede45b7 100644 --- a/examples/sprites-textures/blendmodes.ts +++ b/examples/sprites-textures/blendmodes.ts @@ -4,15 +4,17 @@ import { BlendModes, Color, FixedResolutionCanvasSizing, + Keyboard, type RenderingContext, ScaleModes, Scene, type Seconds, Sprite, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel } from '@examples/runtime'; -const ALPHA_RINGS = assets.technical.alpha.alphaGradientRings; +const ALPHA_RINGS = assets.alphaGradientRings; // Blending runs in linear light on premultiplied colour, so a mode reads // brighter and less muddy than the same equation on encoded values would. The @@ -47,27 +49,16 @@ class BlendmodesScene extends Scene { private background!: Sprite; private left!: Sprite; private right!: Sprite; - private index = 0; private ticker = 0; - private hud!: ReturnType; - - // Note: passing `options` as a 3rd argument to `loader.get(...)` or - // `loader.load(Asset.type('texture', ...))` alongside a non-Json type currently mis-resolves - // the overload (falls through to the `Json` generic and types the result as - // `unknown`) - see the flagged deviation in the migration report. `load()` - // is awaited here purely to seed the fetch with `scaleMode: Nearest`; its - // return value is intentionally unused. The subsequent 2-argument `get()` - // calls for the same sources are unaffected and stay seamless. + override async load(): Promise { const app = this.app; const { width, height } = app; + // Nearest sampling keeps the ring edges and the ship's pixels crisp at 5x. const textureOptions = { scaleMode: ScaleModes.Nearest }; - await this.loader.load(Asset.type('texture', ALPHA_RINGS, { textureOptions })); - await this.loader.load(Asset.type('texture', assets.demo.textures.shipA, { textureOptions })); - - const backgroundTexture = this.loader.get(ALPHA_RINGS); - const shipTexture = this.loader.get(assets.demo.textures.shipA); + const backgroundTexture = await this.loader.load(Asset.type('texture', ALPHA_RINGS.source, { textureOptions })); + const shipTexture = await this.loader.load(Asset.type('texture', assets.ship.source, { textureOptions })); this.background = new Sprite(backgroundTexture); this.background.setPosition(width / 2, height / 2); @@ -87,28 +78,25 @@ class BlendmodesScene extends Scene { this.right.setScale(5); this.right.setTint(new Color(255, 96, 200)); - this.hud = mountControls({ - title: 'Blend Modes', - controls: [{ keys: 'Click', action: 'next blend mode' }], + // Clicks, arrow keys and the panel buttons all move the same value. + const panel = demoPanel({ + controls: { + mode: choice( + 'Blend mode', + BLEND_MODES.map(entry => entry.name), + ), + }, + onChange: (_key, name) => { + const { mode } = BLEND_MODES.find(entry => entry.name === name)!; + + this.left.setBlendMode(mode); + this.right.setBlendMode(mode); + }, }); - app.input.onPointerDown.add(() => this.setIndex((this.index + 1) % BLEND_MODES.length)); - - // Apply the initial mode (Normal) without skipping it. - this.applyBlendMode(); - } - - private setIndex(index: number): void { - this.index = ((index % BLEND_MODES.length) + BLEND_MODES.length) % BLEND_MODES.length; - this.applyBlendMode(); - } - - private applyBlendMode(): void { - const { mode, name } = BLEND_MODES[this.index]; - - this.left.setBlendMode(mode); - this.right.setBlendMode(mode); - this.hud.setStatus(`${name} (${this.index + 1}/${BLEND_MODES.length})`); + app.input.onPointerDown.add(() => panel.step('mode', 1)); + this.inputs.onTrigger(Keyboard.Right, () => panel.step('mode', 1)); + this.inputs.onTrigger(Keyboard.Left, () => panel.step('mode', -1)); } override update(delta: Seconds): void { diff --git a/examples/sprites-textures/sprite-basics.js b/examples/sprites-textures/sprite-basics.js index 37e80a8a3..c2486671a 100644 --- a/examples/sprites-textures/sprite-basics.js +++ b/examples/sprites-textures/sprite-basics.js @@ -1,25 +1,19 @@ // Auto-generated from sprite-basics.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SpriteBasicsScene extends Scene { ship; // A single reusable tint colour whose alpha channel we animate each frame. tint = new Color(120, 200, 255, 1); elapsed = 0; - hud; init() { const app = this.app; const { width, height } = app; - this.ship = new Sprite(this.loader.get('image/ship-a.png')); + this.ship = new Sprite(this.loader.get(assets.ship)); this.ship.setPosition((width / 2) | 0, (height / 2) | 0); this.ship.setAnchor(0.5); this.ship.setScale(3); this.ship.setTint(this.tint); - this.hud = mountControls({ - title: 'Sprite Basics', - hint: 'One Sprite, four transforms: position drifts, rotation spins, scale pulses, alpha fades.', - status: 'alpha 1.00', - }); } update(delta) { const app = this.app; @@ -37,7 +31,6 @@ class SpriteBasicsScene extends Scene { const alpha = 0.6 + Math.sin(this.elapsed * 2) * 0.4; this.tint.a = alpha; this.ship.setTint(this.tint); - this.hud.setStatus(`alpha ${alpha.toFixed(2)}`); } draw(context) { context.render(this.ship); @@ -52,8 +45,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 20, 28), - loader: { - basePath: 'assets/', - }, }); await app.start(SpriteBasicsScene); diff --git a/examples/sprites-textures/sprite-basics.ts b/examples/sprites-textures/sprite-basics.ts index 1062f7432..f2a33c273 100644 --- a/examples/sprites-textures/sprite-basics.ts +++ b/examples/sprites-textures/sprite-basics.ts @@ -1,28 +1,21 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SpriteBasicsScene extends Scene { private ship!: Sprite; // A single reusable tint colour whose alpha channel we animate each frame. private tint = new Color(120, 200, 255, 1); private elapsed = 0; - private hud!: ReturnType; override init(): void { const app = this.app; const { width, height } = app; - this.ship = new Sprite(this.loader.get('image/ship-a.png')); + this.ship = new Sprite(this.loader.get(assets.ship)); this.ship.setPosition((width / 2) | 0, (height / 2) | 0); this.ship.setAnchor(0.5); this.ship.setScale(3); this.ship.setTint(this.tint); - - this.hud = mountControls({ - title: 'Sprite Basics', - hint: 'One Sprite, four transforms: position drifts, rotation spins, scale pulses, alpha fades.', - status: 'alpha 1.00', - }); } override update(delta: Seconds): void { @@ -46,8 +39,6 @@ class SpriteBasicsScene extends Scene { const alpha = 0.6 + Math.sin(this.elapsed * 2) * 0.4; this.tint.a = alpha; this.ship.setTint(this.tint); - - this.hud.setStatus(`alpha ${alpha.toFixed(2)}`); } override draw(context: RenderingContext): void { @@ -64,9 +55,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(18, 20, 28), - loader: { - basePath: 'assets/', - }, }); await app.start(SpriteBasicsScene); diff --git a/examples/sprites-textures/spritesheet-frames.js b/examples/sprites-textures/spritesheet-frames.js index d5511c922..bde119814 100644 --- a/examples/sprites-textures/spritesheet-frames.js +++ b/examples/sprites-textures/spritesheet-frames.js @@ -1,5 +1,6 @@ // Auto-generated from spritesheet-frames.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Spritesheet } from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Scene, Spritesheet } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow']; class SpritesheetFramesScene extends Scene { @@ -13,8 +14,8 @@ class SpritesheetFramesScene extends Scene { 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 texture = this.loader.get(assets.platformerCharactersImage); + const data = await this.loader.load(assets.platformerCharactersAtlas); this.spritesheet = new Spritesheet(texture, data); // The spritesheet caches one Sprite per named frame; configure them all // once so any frame we draw is centred and scaled up for visibility. @@ -71,8 +72,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(24, 28, 38), - loader: { - basePath: 'assets/', - }, }); await app.start(SpritesheetFramesScene); diff --git a/examples/sprites-textures/spritesheet-frames.ts b/examples/sprites-textures/spritesheet-frames.ts index 11b0317e0..21cf11a85 100644 --- a/examples/sprites-textures/spritesheet-frames.ts +++ b/examples/sprites-textures/spritesheet-frames.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, @@ -9,6 +8,7 @@ import { Spritesheet, type SpritesheetData, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow']; @@ -25,8 +25,8 @@ class SpritesheetFramesScene extends Scene { 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 texture = this.loader.get(assets.platformerCharactersImage); + const data = (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData; this.spritesheet = new Spritesheet(texture, data); @@ -99,9 +99,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(24, 28, 38), - loader: { - basePath: 'assets/', - }, }); await app.start(SpritesheetFramesScene); diff --git a/examples/sprites-textures/svg-drawable.js b/examples/sprites-textures/svg-drawable.js index 4cc6f43a7..bb46c8e8e 100644 --- a/examples/sprites-textures/svg-drawable.js +++ b/examples/sprites-textures/svg-drawable.js @@ -1,5 +1,6 @@ // Auto-generated from svg-drawable.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Sprite, Texture } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SvgDrawableScene extends Scene { texture; sprite; @@ -12,13 +13,7 @@ class SvgDrawableScene extends Scene { // it would rasterise to a 0x0 image. Request an explicit pixel size - // the SVG is vector, so it stays crisp at any rasterised resolution. // - // The cast below works around a pre-existing overload-resolution gap: - // every value-asset dispatch token (Json/TextAsset/SvgAsset/...) is an - // empty marker class, so they're structurally identical to `load()`'s - // `typeof Json` overload - which is declared first and wins, typing - // the result as `unknown` instead of `HTMLImageElement`. See the - // flagged deviation in the migration report. - const mark = await this.loader.load(Asset.type('svg', 'svg/exo-wordmark.svg', { width: 850, height: 324 })); + const mark = await this.loader.load(Asset.type('svg', assets.exoWordmark.source, { width: 850, height: 324 })); this.texture = new Texture(mark); this.sprite = new Sprite(this.texture); this.sprite.setAnchor(0.5); @@ -37,8 +32,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SvgDrawableScene); diff --git a/examples/sprites-textures/svg-drawable.ts b/examples/sprites-textures/svg-drawable.ts index f615f9212..b1df27fbd 100644 --- a/examples/sprites-textures/svg-drawable.ts +++ b/examples/sprites-textures/svg-drawable.ts @@ -1,4 +1,5 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Texture } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class SvgDrawableScene extends Scene { private texture!: Texture; @@ -14,13 +15,7 @@ class SvgDrawableScene extends Scene { // it would rasterise to a 0x0 image. Request an explicit pixel size - // the SVG is vector, so it stays crisp at any rasterised resolution. // - // The cast below works around a pre-existing overload-resolution gap: - // every value-asset dispatch token (Json/TextAsset/SvgAsset/...) is an - // empty marker class, so they're structurally identical to `load()`'s - // `typeof Json` overload - which is declared first and wins, typing - // the result as `unknown` instead of `HTMLImageElement`. See the - // flagged deviation in the migration report. - const mark = (await this.loader.load(Asset.type('svg', 'svg/exo-wordmark.svg', { width: 850, height: 324 }))) as HTMLImageElement; + const mark = (await this.loader.load(Asset.type('svg', assets.exoWordmark.source, { width: 850, height: 324 }))) as HTMLImageElement; this.texture = new Texture(mark); this.sprite = new Sprite(this.texture); @@ -42,9 +37,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(SvgDrawableScene); diff --git a/examples/sprites-textures/texture-loader.js b/examples/sprites-textures/texture-loader.js index 631ecbb59..2473b6669 100644 --- a/examples/sprites-textures/texture-loader.js +++ b/examples/sprites-textures/texture-loader.js @@ -1,5 +1,6 @@ // Auto-generated from texture-loader.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sprite, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TextureLoaderScene extends Scene { sprites; textures; @@ -14,7 +15,7 @@ class TextureLoaderScene extends Scene { const { width, height } = app; // Seamless get() returns placeholder handles immediately; each pops in // (loadState → 'ready') as its fetch completes, polled in update(). - this.textures = [this.loader.get('image/ship-a.png'), this.loader.get('image/hue-ramp.png'), this.loader.get('image/uv-grid-256.png')]; + this.textures = [this.loader.get(assets.ship), this.loader.get(assets.hueRamp), this.loader.get(assets.uvGrid256)]; // Spread the three textures evenly across the width, one per third. this.sprites = this.textures.map((texture, index) => { const sprite = new Sprite(texture); @@ -58,8 +59,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TextureLoaderScene); diff --git a/examples/sprites-textures/texture-loader.ts b/examples/sprites-textures/texture-loader.ts index 4abda79e3..eede369cb 100644 --- a/examples/sprites-textures/texture-loader.ts +++ b/examples/sprites-textures/texture-loader.ts @@ -9,6 +9,7 @@ import { Text, type Texture, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TextureLoaderScene extends Scene { private sprites!: Sprite[]; @@ -26,7 +27,7 @@ class TextureLoaderScene extends Scene { // Seamless get() returns placeholder handles immediately; each pops in // (loadState → 'ready') as its fetch completes, polled in update(). - this.textures = [this.loader.get('image/ship-a.png'), this.loader.get('image/hue-ramp.png'), this.loader.get('image/uv-grid-256.png')]; + this.textures = [this.loader.get(assets.ship), this.loader.get(assets.hueRamp), this.loader.get(assets.uvGrid256)]; // Spread the three textures evenly across the width, one per third. this.sprites = this.textures.map((texture, index) => { @@ -78,9 +79,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TextureLoaderScene); diff --git a/examples/sprites-textures/texture-sampling.js b/examples/sprites-textures/texture-sampling.js index 92905c252..9c999585e 100644 --- a/examples/sprites-textures/texture-sampling.js +++ b/examples/sprites-textures/texture-sampling.js @@ -11,7 +11,7 @@ import { Text, Texture, } from '@codexo/exojs'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, slider, toggle } from '@examples/runtime'; // Colour textures are stored as sRGB and filtered in linear light, so the mips // of a checker average to the correct mid-grey rather than a darkened one. // Data textures (`colorSpace: 'none'`) filter their raw numeric values instead. @@ -66,25 +66,21 @@ const makeZonePlate = () => { ctx.putImageData(image, 0, 0); return canvas; }; +const samplingControls = { + magnify: slider('Magnify', { min: 1, max: 8, value: 6, step: 0.1, format: value => `${value.toFixed(1)}x` }), + minify: slider('Minify', { min: 0.15, max: 0.7, value: 0.35, step: 0.01, format: value => `${value.toFixed(2)}x` }), + snap: toggle('Pixel snap', false), + motion: toggle('Motion', true), +}; class TextureSamplingScene extends Scene { textures = []; magnified = []; minified = []; - hud; panel; - zoom = 6; - minify = 0.35; - snap = false; - moving = true; elapsed = 0; onWheel = (_deltaX, deltaY) => { - this.zoom = Math.max(1, Math.min(8, this.zoom * Math.exp(-deltaY * 0.002))); - this.zoomControl.set(this.zoom); - this.updateHud(); + this.panel.set('magnify', Math.max(1, Math.min(8, this.panel.values.magnify * Math.exp(-deltaY * 0.002)))); }; - zoomControl; - snapControl; - motionControl; init() { const pixelArt = makePixelArt(); const zonePlate = makeZonePlate(); @@ -104,63 +100,25 @@ class TextureSamplingScene extends Scene { .setPosition(COLUMN_X[column], 46), ); } - this.hud = mountControls({ - title: 'Texture Sampling and Pixel Snapping', - controls: [ - { keys: 'Wheel', action: 'magnify the top row' }, - { keys: 'P', action: 'toggle pixel snapping' }, - { keys: 'Space', action: 'pause the motion' }, - ], - hint: 'Top row magnified, bottom row minified. Same texture, transform and time in every column; only the sampler differs.', - corner: 'top-right', - }); - this.panel = mountControlPanel({ title: 'Sampling lab', corner: 'bottom-right' }); - this.zoomControl = this.panel.addSlider({ - label: 'Magnify', - min: 1, - max: 8, - step: 0.1, - value: this.zoom, - onChange: value => { - this.zoom = value; - this.updateHud(); - }, - }); - this.panel.addSlider({ - label: 'Minify', - min: 0.15, - max: 0.7, - step: 0.01, - value: this.minify, - onChange: value => { - this.minify = value; - this.updateHud(); + this.panel = demoPanel({ + title: 'Sampling lab', + corner: 'bottom-right', + controls: samplingControls, + onChange: (key, value) => { + if (key === 'snap') { + for (const sprite of this.magnified) { + sprite.pixelSnapMode = value ? PixelSnapMode.Geometry : PixelSnapMode.None; + } + } }, }); - this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); - this.motionControl = this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => this.setMoving(value) }); - this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); - this.inputs.onTrigger(Keyboard.Space, () => this.setMoving(!this.moving)); + this.inputs.onTrigger(Keyboard.P, () => this.panel.step('snap')); + this.inputs.onTrigger(Keyboard.Space, () => this.panel.step('motion')); this.app.input.onMouseWheel.add(this.onWheel); - this.updateHud(); - } - setMoving(moving) { - this.moving = moving; - this.motionControl.set(moving); - } - setSnap(snap) { - this.snap = snap; - this.snapControl.set(snap); - for (const sprite of this.magnified) { - sprite.pixelSnapMode = snap ? PixelSnapMode.Geometry : PixelSnapMode.None; - } - this.updateHud(); - } - updateHud() { - this.hud.setStatus(`Magnify ${this.zoom.toFixed(1)}x · minify ${this.minify.toFixed(2)}x · snap ${this.snap ? 'on' : 'off'}`); } update(delta) { - if (this.moving) { + const { magnify, minify, motion } = this.panel.values; + if (motion) { this.elapsed += delta; } // A slow sub-pixel drift shows snapping in the top row; a gentle scale @@ -168,14 +126,13 @@ class TextureSamplingScene extends Scene { const drift = Math.sin(this.elapsed * 1.8) * 0.45; const pulse = 1 + Math.sin(this.elapsed * 0.7) * 0.12; for (let column = 0; column < MODES.length; column++) { - this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(this.zoom); - this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(this.minify * pulse); + this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(magnify); + this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(minify * pulse); } } destroy() { this.app.input.onMouseWheel.remove(this.onWheel); this.panel.dispose(); - this.hud.dispose(); super.destroy(); for (const texture of this.textures) { texture.destroy(); diff --git a/examples/sprites-textures/texture-sampling.ts b/examples/sprites-textures/texture-sampling.ts index 6721287e2..d9aec54d2 100644 --- a/examples/sprites-textures/texture-sampling.ts +++ b/examples/sprites-textures/texture-sampling.ts @@ -11,7 +11,7 @@ import { Text, Texture, } from '@codexo/exojs'; -import { type ControlBinding, mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel, slider, toggle } from '@examples/runtime'; // Colour textures are stored as sRGB and filtered in linear light, so the mips // of a checker average to the correct mid-grey rather than a darkened one. @@ -76,25 +76,24 @@ const makeZonePlate = (): HTMLCanvasElement => { return canvas; }; +const samplingControls = { + magnify: slider('Magnify', { min: 1, max: 8, value: 6, step: 0.1, format: value => `${value.toFixed(1)}x` }), + minify: slider('Minify', { min: 0.15, max: 0.7, value: 0.35, step: 0.01, format: value => `${value.toFixed(2)}x` }), + snap: toggle('Pixel snap', false), + motion: toggle('Motion', true), +}; + +type SamplingControls = typeof samplingControls; + class TextureSamplingScene extends Scene { private textures: Texture[] = []; private magnified: Sprite[] = []; private minified: Sprite[] = []; - private hud!: ReturnType; - private panel!: ReturnType; - private zoom = 6; - private minify = 0.35; - private snap = false; - private moving = true; + private panel!: ReturnType>; private elapsed = 0; private readonly onWheel = (_deltaX: number, deltaY: number): void => { - this.zoom = Math.max(1, Math.min(8, this.zoom * Math.exp(-deltaY * 0.002))); - this.zoomControl.set(this.zoom); - this.updateHud(); + this.panel.set('magnify', Math.max(1, Math.min(8, this.panel.values.magnify * Math.exp(-deltaY * 0.002)))); }; - private zoomControl!: ControlBinding; - private snapControl!: ControlBinding; - private motionControl!: ControlBinding; override init(): void { const pixelArt = makePixelArt(); @@ -118,70 +117,28 @@ class TextureSamplingScene extends Scene { ); } - this.hud = mountControls({ - title: 'Texture Sampling and Pixel Snapping', - controls: [ - { keys: 'Wheel', action: 'magnify the top row' }, - { keys: 'P', action: 'toggle pixel snapping' }, - { keys: 'Space', action: 'pause the motion' }, - ], - hint: 'Top row magnified, bottom row minified. Same texture, transform and time in every column; only the sampler differs.', - corner: 'top-right', - }); - this.panel = mountControlPanel({ title: 'Sampling lab', corner: 'bottom-right' }); - this.zoomControl = this.panel.addSlider({ - label: 'Magnify', - min: 1, - max: 8, - step: 0.1, - value: this.zoom, - onChange: value => { - this.zoom = value; - this.updateHud(); + this.panel = demoPanel({ + title: 'Sampling lab', + corner: 'bottom-right', + controls: samplingControls, + onChange: (key, value) => { + if (key === 'snap') { + for (const sprite of this.magnified) { + sprite.pixelSnapMode = value ? PixelSnapMode.Geometry : PixelSnapMode.None; + } + } }, }); - this.panel.addSlider({ - label: 'Minify', - min: 0.15, - max: 0.7, - step: 0.01, - value: this.minify, - onChange: value => { - this.minify = value; - this.updateHud(); - }, - }); - this.snapControl = this.panel.addToggle({ label: 'Pixel snap', value: this.snap, onChange: value => this.setSnap(value) }); - this.motionControl = this.panel.addToggle({ label: 'Motion', value: this.moving, onChange: value => this.setMoving(value) }); - this.inputs.onTrigger(Keyboard.P, () => this.setSnap(!this.snap)); - this.inputs.onTrigger(Keyboard.Space, () => this.setMoving(!this.moving)); + this.inputs.onTrigger(Keyboard.P, () => this.panel.step('snap')); + this.inputs.onTrigger(Keyboard.Space, () => this.panel.step('motion')); this.app.input.onMouseWheel.add(this.onWheel); - this.updateHud(); - } - - private setMoving(moving: boolean): void { - this.moving = moving; - this.motionControl.set(moving); - } - - private setSnap(snap: boolean): void { - this.snap = snap; - this.snapControl.set(snap); - - for (const sprite of this.magnified) { - sprite.pixelSnapMode = snap ? PixelSnapMode.Geometry : PixelSnapMode.None; - } - - this.updateHud(); - } - - private updateHud(): void { - this.hud.setStatus(`Magnify ${this.zoom.toFixed(1)}x · minify ${this.minify.toFixed(2)}x · snap ${this.snap ? 'on' : 'off'}`); } override update(delta: Seconds): void { - if (this.moving) { + const { magnify, minify, motion } = this.panel.values; + + if (motion) { this.elapsed += delta; } @@ -191,15 +148,14 @@ class TextureSamplingScene extends Scene { const pulse = 1 + Math.sin(this.elapsed * 0.7) * 0.12; for (let column = 0; column < MODES.length; column++) { - this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(this.zoom); - this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(this.minify * pulse); + this.magnified[column].setPosition(COLUMN_X[column] + drift, MAGNIFIED_Y + drift).setScale(magnify); + this.minified[column].setPosition(COLUMN_X[column], MINIFIED_Y).setScale(minify * pulse); } } override destroy(): void { this.app.input.onMouseWheel.remove(this.onWheel); this.panel.dispose(); - this.hud.dispose(); super.destroy(); for (const texture of this.textures) { diff --git a/examples/sprites-textures/video-drawable.js b/examples/sprites-textures/video-drawable.js index a28359fc2..a69311d70 100644 --- a/examples/sprites-textures/video-drawable.js +++ b/examples/sprites-textures/video-drawable.js @@ -1,6 +1,7 @@ // Auto-generated from video-drawable.ts - edit the .ts source, not this file. -import { Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Every video in the asset catalog, switchable at runtime with the number // keys. Only the first entry is fetched up front - the others lazy-load on // first selection so startup stays fast. @@ -9,54 +10,63 @@ import { mountControls } from '@examples/runtime'; // in the browser's own display encoding and sampled as sRGB colour; the engine // makes no HDR-video promise, so no HDR clip is offered. const VIDEOS = [ - { name: 'demoLoop', label: 'Demo loop (webm)', url: assets.demo.video.demoLoop }, - { name: 'highRes', label: 'High-res (mp4)', url: assets.demo.video.highRes }, - { name: 'highFps', label: 'High-fps (webm)', url: assets.demo.video.highFps }, + { name: 'demoLoop', label: 'Demo loop (webm)', asset: assets.videoDemoLoop }, + { name: 'highRes', label: '4K, 24 fps (mp4)', asset: assets.videoHighRes }, + { name: 'highFps', label: '1080p, 60 fps (webm)', asset: assets.videoHighFps }, ]; class VideoDrawableScene extends Scene { video; overlay; elapsed = 0; - hud; + panel; videoIdx = 0; loadedVideos = new Set(); switching = false; async load() { const app = this.app; const { width, height } = app; - // Video is a non-leaf resource kind (no seamless placeholder, unlike - // Texture/Sound), so it is loaded directly through `Asset.type('video', ...)` - // and awaited rather than fetched synchronously via `get()`. - this.video = await this.loader.load(Asset.type('video', VIDEOS[0].url)); + // Video has no placeholder handle (unlike Texture and Sound), so it is + // awaited with `load()` rather than claimed synchronously with `get()`. + this.video = await this.loader.load(VIDEOS[0].asset); this.loadedVideos.add(VIDEOS[0].name); this.configureVideo(); // A sprite composited on top of the live video texture - the same scene // graph draws video frames and regular sprites side by side. Texture IS // seamless, so it is fetched directly by source with no preload step. - this.overlay = new Sprite(this.loader.get(assets.demo.textures.shipA)); + this.overlay = new Sprite(this.loader.get(assets.ship)); this.overlay.setAnchor(0.5); this.overlay.setScale(3); this.overlay.setPosition(width / 2, height / 2); - this.hud = mountControls({ - title: 'Video Drawable', - controls: [ - { keys: 'Tap', action: 'play / pause' }, - { keys: '1–3', action: 'switch video' }, - ], - status: `Playing — ${VIDEOS[0].label}`, - hint: 'The video streams as a live GPU texture with a sprite composited over it.', - }); - app.input.onPointerTap.add(() => { - this.video.toggle(); - this.hud.setStatus(this.video.playing ? `Playing — ${VIDEOS[this.videoIdx].label}` : 'Paused'); + this.video.play(); + // Clicks and the number keys move the same panel state. + this.panel = demoPanel({ + title: 'Video', + controls: { + video: choice( + 'Clip', + VIDEOS.map(entry => entry.label), + ), + paused: toggle('Paused'), + }, + onChange: (key, _value, values) => { + if (key === 'paused') { + if (values.paused) { + this.video.pause(); + } else { + this.video.play(); + } + } else { + void this.switchVideo(VIDEOS.findIndex(entry => entry.label === values.video)); + } + }, }); + app.input.onPointerTap.add(() => this.panel.step('paused')); app.input.onKeyDown.add(channel => { const idx = [Keyboard.One, Keyboard.Two, Keyboard.Three].indexOf(channel); if (idx !== -1) { - void this.switchVideo(idx); + this.panel.set('video', VIDEOS[idx].label); } }); - this.video.play(); } /** Sizing + playback options shared by every video the example swaps in. */ configureVideo() { @@ -74,21 +84,28 @@ class VideoDrawableScene extends Scene { } const entry = VIDEOS[idx]; this.switching = true; - this.hud.setStatus(`Loading — ${entry.label}…`); + this.panel.status(`Loading ${entry.label}...`); try { - const loaded = await this.loader.load(Asset.type('video', entry.url)); + const loaded = await this.loader.load(entry.asset); this.loadedVideos.add(entry.name); this.video.pause(); this.videoIdx = idx; this.video = loaded; this.configureVideo(); + this.panel.set('paused', false); this.video.play(); - this.hud.setStatus(`Playing — ${entry.label}`); + this.panel.status(''); } catch { - this.hud.setStatus(`Failed to load — ${entry.label}`); + this.panel.status(`Failed to load ${entry.label}.`); + this.panel.set('video', VIDEOS[this.videoIdx].label); } finally { this.switching = false; } + // A choice made while this one was loading is honoured once it is done. + const wanted = VIDEOS.findIndex(candidate => candidate.label === this.panel.values.video); + if (wanted !== this.videoIdx) { + void this.switchVideo(wanted); + } } update(delta) { const app = this.app; diff --git a/examples/sprites-textures/video-drawable.ts b/examples/sprites-textures/video-drawable.ts index 955a68f58..253ecee54 100644 --- a/examples/sprites-textures/video-drawable.ts +++ b/examples/sprites-textures/video-drawable.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Color, FixedResolutionCanvasSizing, Keyboard, @@ -10,7 +9,8 @@ import { Sprite, type Video, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, type ChoiceSpec, type DemoPanel, demoPanel, toggle, type ToggleSpec } from '@examples/runtime'; // Every video in the asset catalog, switchable at runtime with the number // keys. Only the first entry is fetched up front - the others lazy-load on @@ -20,16 +20,16 @@ import { mountControls } from '@examples/runtime'; // in the browser's own display encoding and sampled as sRGB colour; the engine // makes no HDR-video promise, so no HDR clip is offered. const VIDEOS = [ - { name: 'demoLoop', label: 'Demo loop (webm)', url: assets.demo.video.demoLoop }, - { name: 'highRes', label: 'High-res (mp4)', url: assets.demo.video.highRes }, - { name: 'highFps', label: 'High-fps (webm)', url: assets.demo.video.highFps }, + { name: 'demoLoop', label: 'Demo loop (webm)', asset: assets.videoDemoLoop }, + { name: 'highRes', label: '4K, 24 fps (mp4)', asset: assets.videoHighRes }, + { name: 'highFps', label: '1080p, 60 fps (webm)', asset: assets.videoHighFps }, ]; class VideoDrawableScene extends Scene { private video!: Video; private overlay!: Sprite; private elapsed = 0; - private hud!: ReturnType; + private panel!: DemoPanel<{ video: ChoiceSpec; paused: ToggleSpec }>; private videoIdx = 0; private readonly loadedVideos = new Set(); private switching = false; @@ -38,10 +38,9 @@ class VideoDrawableScene extends Scene { const app = this.app; const { width, height } = app; - // Video is a non-leaf resource kind (no seamless placeholder, unlike - // Texture/Sound), so it is loaded directly through `Asset.type('video', ...)` - // and awaited rather than fetched synchronously via `get()`. - this.video = await this.loader.load(Asset.type('video', VIDEOS[0].url)); + // Video has no placeholder handle (unlike Texture and Sound), so it is + // awaited with `load()` rather than claimed synchronously with `get()`. + this.video = await this.loader.load(VIDEOS[0].asset); this.loadedVideos.add(VIDEOS[0].name); this.configureVideo(); @@ -49,35 +48,44 @@ class VideoDrawableScene extends Scene { // A sprite composited on top of the live video texture - the same scene // graph draws video frames and regular sprites side by side. Texture IS // seamless, so it is fetched directly by source with no preload step. - this.overlay = new Sprite(this.loader.get(assets.demo.textures.shipA)); + this.overlay = new Sprite(this.loader.get(assets.ship)); this.overlay.setAnchor(0.5); this.overlay.setScale(3); this.overlay.setPosition(width / 2, height / 2); - this.hud = mountControls({ - title: 'Video Drawable', - controls: [ - { keys: 'Tap', action: 'play / pause' }, - { keys: '1–3', action: 'switch video' }, - ], - status: `Playing — ${VIDEOS[0].label}`, - hint: 'The video streams as a live GPU texture with a sprite composited over it.', - }); + this.video.play(); - app.input.onPointerTap.add(() => { - this.video.toggle(); - this.hud.setStatus(this.video.playing ? `Playing — ${VIDEOS[this.videoIdx].label}` : 'Paused'); + // Clicks and the number keys move the same panel state. + this.panel = demoPanel({ + title: 'Video', + controls: { + video: choice( + 'Clip', + VIDEOS.map(entry => entry.label), + ), + paused: toggle('Paused'), + }, + onChange: (key, _value, values) => { + if (key === 'paused') { + if (values.paused) { + this.video.pause(); + } else { + this.video.play(); + } + } else { + void this.switchVideo(VIDEOS.findIndex(entry => entry.label === values.video)); + } + }, }); + app.input.onPointerTap.add(() => this.panel.step('paused')); app.input.onKeyDown.add(channel => { const idx = [Keyboard.One, Keyboard.Two, Keyboard.Three].indexOf(channel); if (idx !== -1) { - void this.switchVideo(idx); + this.panel.set('video', VIDEOS[idx]!.label); } }); - - this.video.play(); } /** Sizing + playback options shared by every video the example swaps in. */ @@ -98,22 +106,31 @@ class VideoDrawableScene extends Scene { const entry = VIDEOS[idx]; this.switching = true; - this.hud.setStatus(`Loading — ${entry.label}…`); + this.panel.status(`Loading ${entry.label}...`); try { - const loaded = await this.loader.load(Asset.type('video', entry.url)); + const loaded = await this.loader.load(entry.asset); this.loadedVideos.add(entry.name); this.video.pause(); this.videoIdx = idx; this.video = loaded; this.configureVideo(); + this.panel.set('paused', false); this.video.play(); - this.hud.setStatus(`Playing — ${entry.label}`); + this.panel.status(''); } catch { - this.hud.setStatus(`Failed to load — ${entry.label}`); + this.panel.status(`Failed to load ${entry.label}.`); + this.panel.set('video', VIDEOS[this.videoIdx]!.label); } finally { this.switching = false; } + + // A choice made while this one was loading is honoured once it is done. + const wanted = VIDEOS.findIndex(candidate => candidate.label === this.panel.values.video); + + if (wanted !== this.videoIdx) { + void this.switchVideo(wanted); + } } override update(delta: Seconds): void { diff --git a/examples/text-fonts/basic-text.js b/examples/text-fonts/basic-text.js index 674402edd..3ac314434 100644 --- a/examples/text-fonts/basic-text.js +++ b/examples/text-fonts/basic-text.js @@ -1,11 +1,12 @@ // Auto-generated from basic-text.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BasicTextScene extends Scene { elapsed = 0; text; async load() { const app = this.app; - await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); const { width, height } = app; this.text = new Text('Hello World!', { align: 'left', @@ -36,8 +37,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BasicTextScene); diff --git a/examples/text-fonts/basic-text.ts b/examples/text-fonts/basic-text.ts index eb0a9fe3d..04a4b5370 100644 --- a/examples/text-fonts/basic-text.ts +++ b/examples/text-fonts/basic-text.ts @@ -1,4 +1,5 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BasicTextScene extends Scene { private elapsed = 0; @@ -6,7 +7,7 @@ class BasicTextScene extends Scene { override async load(): Promise { const app = this.app; - await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); const { width, height } = app; @@ -43,9 +44,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(BasicTextScene); diff --git a/examples/text-fonts/bitmap-text-basic.js b/examples/text-fonts/bitmap-text-basic.js index f3b3fe50c..72c3d0015 100644 --- a/examples/text-fonts/bitmap-text-basic.js +++ b/examples/text-fonts/bitmap-text-basic.js @@ -1,5 +1,6 @@ // Auto-generated from bitmap-text-basic.ts - edit the .ts source, not this file. -import { Application, Asset, BitmapText, Color, FixedResolutionCanvasSizing, Scene } from '@codexo/exojs'; +import { Application, BitmapText, Color, FixedResolutionCanvasSizing, Scene } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BitmapTextBasicScene extends Scene { font; title; @@ -9,7 +10,7 @@ class BitmapTextBasicScene extends Scene { frame = 0; async load() { const app = this.app; - this.font = await this.loader.load(Asset.type('bmFont', assets.demo.fonts.kenneyBlocksFnt)); + this.font = await this.loader.load(assets.fontKenneyBlocksFnt); const font = this.font; const { width, height } = app; const marginX = width * 0.08; diff --git a/examples/text-fonts/bitmap-text-basic.ts b/examples/text-fonts/bitmap-text-basic.ts index d55b213a1..39cae89a1 100644 --- a/examples/text-fonts/bitmap-text-basic.ts +++ b/examples/text-fonts/bitmap-text-basic.ts @@ -1,13 +1,5 @@ -import { - Application, - Asset, - BitmapText, - type BmFont, - Color, - FixedResolutionCanvasSizing, - type RenderingContext, - Scene, -} from '@codexo/exojs'; +import { Application, BitmapText, type BmFont, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class BitmapTextBasicScene extends Scene { private font!: BmFont; @@ -19,7 +11,7 @@ class BitmapTextBasicScene extends Scene { override async load(): Promise { const app = this.app; - this.font = await this.loader.load(Asset.type('bmFont', assets.demo.fonts.kenneyBlocksFnt)); + this.font = await this.loader.load(assets.fontKenneyBlocksFnt); const font = this.font; const { width, height } = app; diff --git a/examples/text-fonts/stroke-and-shadow.js b/examples/text-fonts/stroke-and-shadow.js index a119c9e6a..fda28f3b9 100644 --- a/examples/text-fonts/stroke-and-shadow.js +++ b/examples/text-fonts/stroke-and-shadow.js @@ -32,8 +32,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(24, 28, 42), - loader: { - basePath: 'assets/', - }, }); await app.start(StrokeAndShadowScene); diff --git a/examples/text-fonts/stroke-and-shadow.ts b/examples/text-fonts/stroke-and-shadow.ts index 2bd96e258..f03428e46 100644 --- a/examples/text-fonts/stroke-and-shadow.ts +++ b/examples/text-fonts/stroke-and-shadow.ts @@ -36,9 +36,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: new Color(24, 28, 42), - loader: { - basePath: 'assets/', - }, }); await app.start(StrokeAndShadowScene); diff --git a/examples/text-fonts/text-glitch.js b/examples/text-fonts/text-glitch.js index 562f3a7d3..2e0b7ffc2 100644 --- a/examples/text-fonts/text-glitch.js +++ b/examples/text-fonts/text-glitch.js @@ -54,8 +54,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TextGlitchScene); diff --git a/examples/text-fonts/text-glitch.ts b/examples/text-fonts/text-glitch.ts index 9df2c6ab3..43ff48862 100644 --- a/examples/text-fonts/text-glitch.ts +++ b/examples/text-fonts/text-glitch.ts @@ -61,9 +61,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TextGlitchScene); diff --git a/examples/text-fonts/typographic-styling.js b/examples/text-fonts/typographic-styling.js index 022dd80b9..39f6870f8 100644 --- a/examples/text-fonts/typographic-styling.js +++ b/examples/text-fonts/typographic-styling.js @@ -1,9 +1,10 @@ // Auto-generated from typographic-styling.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TextStylesScene extends Scene { labels = []; async load() { - await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); } init() { const { width } = this.app; @@ -60,6 +61,5 @@ const app = new Application({ scenes: { TextStylesScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(18, 22, 34), - loader: { basePath: 'assets/' }, }); await app.start(TextStylesScene); diff --git a/examples/text-fonts/typographic-styling.ts b/examples/text-fonts/typographic-styling.ts index e0f07a4ef..a4de350d6 100644 --- a/examples/text-fonts/typographic-styling.ts +++ b/examples/text-fonts/typographic-styling.ts @@ -1,10 +1,11 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); } override init(): void { @@ -68,7 +69,6 @@ const app = new Application({ scenes: { TextStylesScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(18, 22, 34), - loader: { basePath: 'assets/' }, }); await app.start(TextStylesScene); diff --git a/examples/text-fonts/web-fonts.js b/examples/text-fonts/web-fonts.js index 67c72ae5e..4f931cb2f 100644 --- a/examples/text-fonts/web-fonts.js +++ b/examples/text-fonts/web-fonts.js @@ -1,11 +1,12 @@ // Auto-generated from web-fonts.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class WebFontsScene extends Scene { default; loaded; async load() { const app = this.app; - await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); const { width, height } = app; this.default = new Text('Default Font', { fillColor: Color.white, fontSize: 52, align: 'center' }); this.default.setAnchor(0.5, 0.5); @@ -28,8 +29,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(WebFontsScene); diff --git a/examples/text-fonts/web-fonts.ts b/examples/text-fonts/web-fonts.ts index 4100c23e9..cb07e7f03 100644 --- a/examples/text-fonts/web-fonts.ts +++ b/examples/text-fonts/web-fonts.ts @@ -1,4 +1,5 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class WebFontsScene extends Scene { private default!: Text; @@ -6,7 +7,7 @@ class WebFontsScene extends Scene { override async load(): Promise { const app = this.app; - await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' })); + await this.loader.load(Asset.type('font', assets.fontKenneyFuture.source, { family: 'Kenney Future' })); const { width, height } = app; @@ -33,9 +34,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(WebFontsScene); diff --git a/examples/tilemap/editor-objects-gameplay.js b/examples/tilemap/editor-objects-gameplay.js index 8c648c0f2..376ad2ef1 100644 --- a/examples/tilemap/editor-objects-gameplay.js +++ b/examples/tilemap/editor-objects-gameplay.js @@ -1,7 +1,8 @@ // 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 { MapObjectSpawner } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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(); @@ -20,7 +21,7 @@ class EditorObjectsScene extends Scene { doorOpen = false; collected = false; async load() { - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); + const source = await this.loader.load(Asset.type('tiledSource', assets.harborPlazaMap.source)); const map = source.toTileMap(); this.mapNode = new TileMapNode(map); const spawn = (object, color) => { @@ -36,12 +37,7 @@ class EditorObjectsScene extends Scene { 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.hud = mountControls({ status: 'The authored switch controls the door. Open it to collect the gem.' }); this.app.input.onPointerTap.add(this.onTap); } onTap = pointer => { @@ -93,6 +89,5 @@ const app = new Application({ 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 index 6ca4a7ff4..aa5c47b30 100644 --- a/examples/tilemap/editor-objects-gameplay.ts +++ b/examples/tilemap/editor-objects-gameplay.ts @@ -1,6 +1,7 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { type MapObjectDescriptor, MapObjectSpawner, type MapSpawnSession } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; 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 => { @@ -25,7 +26,7 @@ class EditorObjectsScene extends Scene { private collected = false; override async load(): Promise { - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/harbor-plaza.tmj')); + const source = await this.loader.load(Asset.type('tiledSource', assets.harborPlazaMap.source)); const map = source.toTileMap(); this.mapNode = new TileMapNode(map); @@ -45,12 +46,7 @@ class EditorObjectsScene extends Scene { }); 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.hud = mountControls({ status: 'The authored switch controls the door. Open it to collect the gem.' }); this.app.input.onPointerTap.add(this.onTap); } @@ -115,7 +111,6 @@ const app = new Application({ 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/hexagonal-grid.js b/examples/tilemap/hexagonal-grid.js index ae54f6d9f..97207b61c 100644 --- a/examples/tilemap/hexagonal-grid.js +++ b/examples/tilemap/hexagonal-grid.js @@ -1,7 +1,7 @@ // Auto-generated from hexagonal-grid.ts - edit the .ts source, not this file. import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene, Texture, TextureRegion } from '@codexo/exojs'; -import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileProjection, TileSet } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileProjection, TileSet } from '@codexo/exojs/tilemap'; +import { choice, demoPanel } from '@examples/runtime'; // Staggered and hexagonal grids plus a collection-of-images tileset, all // built in code - no map file and no image assets. // @@ -73,33 +73,24 @@ const paintTile = (projection, fill, edge, tree) => { class HexagonalGridScene extends Scene { worldRoot = new Container(); highlight = new Graphics(); - layoutIndex = 0; map; node; textures = []; - hud; + panel; init() { - this.hud = mountControls({ - title: 'Hexagonal & Staggered Grids', - controls: [ - { keys: 'Pointer', action: 'pick the cell under it' }, - { keys: 'panel', action: 'switch the grid layout' }, - ], - status: 'Move the pointer over the map.', - hint: 'Each tile is its own canvas texture in an image-collection TileSet; trees overhang the row above, as in Tiled.', + this.panel = demoPanel({ + title: 'Grid', + controls: { + layout: choice( + 'Layout', + layouts.map(layout => layout.name), + ), + }, + onChange: (_key, name) => this.build(layouts.findIndex(layout => layout.name === name)), }); - const panel = mountControlPanel({ title: 'Grid' }); - panel.addCycle({ - label: 'Layout', - options: layouts.map(layout => layout.name), - index: 0, - onChange: index => this.build(index), - }); - this.build(0); this.app.input.onPointerMove.add(this.onPointerMove); } build(index) { - this.layoutIndex = index; this.node?.destroy(); this.map?.destroy(); for (const texture of this.textures) { @@ -162,7 +153,7 @@ class HexagonalGridScene extends Scene { const { tx, ty } = this.map.pixelToTile(pointer.x - this.worldRoot.x, pointer.y - this.worldRoot.y); this.highlight.clear(); if (tx < 0 || ty < 0 || tx >= COLUMNS || ty >= ROWS) { - this.hud.setStatus(`Outside the map - nearest cell (${tx}, ${ty}).`); + this.panel.status(`Outside the map - nearest cell (${tx}, ${ty}).`); return; } const corner = projection.tileToPixel(tx, ty); @@ -173,14 +164,14 @@ class HexagonalGridScene extends Scene { this.highlight.drawPolygon(outline.map((value, i) => value + (i % 2 === 0 ? corner.x : corner.y))); const tile = this.map.getTileAt(1, tx, ty); const terrain = tile?.localTileId === TREE ? 'tree' : (['grass', 'water', 'sand'][tile?.localTileId ?? 0] ?? 'grass'); - this.hud.setStatus(`${layouts[this.layoutIndex].name}: cell (${tx}, ${ty}) - ${terrain}`); + this.panel.status(`Cell (${tx}, ${ty}) - ${terrain}`); }; draw(context) { context.render(this.worldRoot); } destroy() { this.app.input.onPointerMove.remove(this.onPointerMove); - this.hud?.dispose(); + this.panel?.dispose(); this.worldRoot.destroy(); this.map?.destroy(); for (const texture of this.textures) { diff --git a/examples/tilemap/hexagonal-grid.ts b/examples/tilemap/hexagonal-grid.ts index 034e16a16..22ea183be 100644 --- a/examples/tilemap/hexagonal-grid.ts +++ b/examples/tilemap/hexagonal-grid.ts @@ -19,8 +19,8 @@ import { TileProjection, type TileProjectionOptions, TileSet, -} from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/tilemap'; +import { choice, demoPanel } from '@examples/runtime'; // Staggered and hexagonal grids plus a collection-of-images tileset, all // built in code - no map file and no image assets. @@ -116,38 +116,26 @@ const paintTile = (projection: TileProjection, fill: string, edge: string, tree: class HexagonalGridScene extends Scene { private readonly worldRoot = new Container(); private readonly highlight = new Graphics(); - private layoutIndex = 0; private map!: TileMap; private node!: TileMapNode; private textures: Texture[] = []; - private hud!: ReturnType; + private panel!: ReturnType; override init(): void { - this.hud = mountControls({ - title: 'Hexagonal & Staggered Grids', - controls: [ - { keys: 'Pointer', action: 'pick the cell under it' }, - { keys: 'panel', action: 'switch the grid layout' }, - ], - status: 'Move the pointer over the map.', - hint: 'Each tile is its own canvas texture in an image-collection TileSet; trees overhang the row above, as in Tiled.', + this.panel = demoPanel({ + title: 'Grid', + controls: { + layout: choice( + 'Layout', + layouts.map(layout => layout.name), + ), + }, + onChange: (_key, name) => this.build(layouts.findIndex(layout => layout.name === name)), }); - - const panel = mountControlPanel({ title: 'Grid' }); - - panel.addCycle({ - label: 'Layout', - options: layouts.map(layout => layout.name), - index: 0, - onChange: index => this.build(index), - }); - - this.build(0); this.app.input.onPointerMove.add(this.onPointerMove); } private build(index: number): void { - this.layoutIndex = index; this.node?.destroy(); this.map?.destroy(); @@ -223,7 +211,7 @@ class HexagonalGridScene extends Scene { this.highlight.clear(); if (tx < 0 || ty < 0 || tx >= COLUMNS || ty >= ROWS) { - this.hud.setStatus(`Outside the map - nearest cell (${tx}, ${ty}).`); + this.panel.status(`Outside the map - nearest cell (${tx}, ${ty}).`); return; } @@ -239,7 +227,7 @@ class HexagonalGridScene extends Scene { const tile = this.map.getTileAt(1, tx, ty); const terrain = tile?.localTileId === TREE ? 'tree' : (['grass', 'water', 'sand'][tile?.localTileId ?? 0] ?? 'grass'); - this.hud.setStatus(`${layouts[this.layoutIndex]!.name}: cell (${tx}, ${ty}) - ${terrain}`); + this.panel.status(`Cell (${tx}, ${ty}) - ${terrain}`); }; override draw(context: RenderingContext): void { @@ -248,7 +236,7 @@ class HexagonalGridScene extends Scene { override destroy(): void { this.app.input.onPointerMove.remove(this.onPointerMove); - this.hud?.dispose(); + this.panel?.dispose(); this.worldRoot.destroy(); this.map?.destroy(); diff --git a/examples/tilemap/infinite-terrain.js b/examples/tilemap/infinite-terrain.js index a307d57f0..653949286 100644 --- a/examples/tilemap/infinite-terrain.js +++ b/examples/tilemap/infinite-terrain.js @@ -1,7 +1,6 @@ // Auto-generated from infinite-terrain.ts - edit the .ts source, not this file. import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, @@ -19,7 +18,8 @@ import { TileMap, tilemapExtension, TileSet, -} from '@codexo/exojs-tilemap'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // An infinite, procedurally generated world: the TileLayer has NO width/height // (unbounded), and a ChunkStreamer keeps only the chunks near the camera @@ -105,7 +105,7 @@ class InfiniteTerrainScene extends Scene { hudTimer = 0; hud; async load() { - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'biomes', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -126,8 +126,8 @@ class InfiniteTerrainScene extends Scene { }); this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), - await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)), + await this.loader.load(assets.platformerCharactersImage), + await this.loader.load(assets.platformerCharactersAtlas), ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.explorer.setPosition(0, 0); diff --git a/examples/tilemap/infinite-terrain.ts b/examples/tilemap/infinite-terrain.ts index 0cd609dac..1a329f118 100644 --- a/examples/tilemap/infinite-terrain.ts +++ b/examples/tilemap/infinite-terrain.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, @@ -23,7 +22,8 @@ import { tilemapExtension, type TileMapView, TileSet, -} from '@codexo/exojs-tilemap'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControlPanel, mountControls } from '@examples/runtime'; // An infinite, procedurally generated world: the TileLayer has NO width/height @@ -127,7 +127,7 @@ class InfiniteTerrainScene extends Scene { private hud!: ReturnType; override async load(): Promise { - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'biomes', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -150,8 +150,8 @@ class InfiniteTerrainScene extends Scene { this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), - (await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, + await this.loader.load(assets.platformerCharactersImage), + (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData, ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); diff --git a/examples/tilemap/ldtk-world-import.js b/examples/tilemap/ldtk-world-import.js index 3d0098e05..aaebb3bdd 100644 --- a/examples/tilemap/ldtk-world-import.js +++ b/examples/tilemap/ldtk-world-import.js @@ -1,8 +1,9 @@ // Auto-generated from ldtk-world-import.ts - edit the .ts source, not this file. import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { getLdtkIntGridValueAt, ldtkExtension } from '@codexo/exojs-ldtk'; -import { TileMapNode } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Loading an LDtk world (`.ldtk`) through @codexo/exojs-ldtk. // // - `loader.load(Asset.type('ldtkMap', url))` fetches the world, loads every referenced @@ -19,7 +20,7 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // `TileMapObject` shape a Tiled object layer produces, with entity // fields (`Int`, `String`, ...) carried as `properties`. // -// harbor-world.ldtk (examples/assets/json/maps/) defines two levels sharing +// harbor-world.ldtk (`assets.harborWorldProject`) defines two levels sharing // one tileset: "Level_Harbor" and "Level_Lighthouse", each with a Ground // tile layer, a Walls IntGrid layer (Wall / Water), and an Entities layer. /** Parse an LDtk `"#rrggbb"` colour string into an engine `Color` with the given alpha. */ @@ -30,60 +31,44 @@ const hexToColor = (hex, alpha) => { class LdtkWorldImportScene extends Scene { world; content = new Container(); - hud; panel; - showIntGrid = true; + summary = ''; async load() { - // The .ldtk source must be an absolute URL: @codexo/exojs-ldtk resolves - // tileset relPaths with `new URL(relPath, source)`, which throws for a - // relative `source` (unlike the Tiled adapter's relative-base-aware - // resolver). Resolve against the page URL until the adapter handles - // relative bases itself. - const worldUrl = new URL('assets/json/maps/harbor-world.ldtk', window.location.href).href; - this.world = await this.loader.load(Asset.type('ldtkMap', worldUrl)); - this.hud = mountControls({ - title: 'LDtk World Import', - controls: [{ keys: 'panel', action: 'switch level / toggle the IntGrid overlay' }], - status: `${this.world.levels.length} levels loaded`, - hint: 'Coloured squares come from getLdtkIntGridValueAt() — the raw Walls IntGrid cell values, resolved to their LDtk-authored name + colour.', - }); - this.panel = mountControlPanel({ title: 'LDtk' }); - this.panel.addCycle({ - label: 'Level', - options: this.world.levels.map(level => level.name), - index: 0, - onChange: index => this.showLevel(index), - }); - this.panel.addToggle({ - label: 'IntGrid overlay', - value: true, - onChange: on => { - this.showIntGrid = on; - this.showLevel(this.currentLevelIndex); + // The LDtk adapter resolves tileset paths with `new URL(relPath, source)`, + // so the world needs an absolute source; the demo catalog's URLs are. + this.world = await this.loader.load(Asset.type('ldtkMap', assets.harborWorldProject.source)); + this.panel = demoPanel({ + title: 'LDtk', + controls: { + level: choice( + 'Level', + this.world.levels.map(level => level.name), + ), + intGrid: toggle('IntGrid overlay', true), }, + onChange: (_key, _value, values) => this.showLevel(values.level, values.intGrid), }); - this.showLevel(0); + this.panel.status(this.summary); } - currentLevelIndex = 0; - showLevel(index) { - this.currentLevelIndex = index; + showLevel(name, showIntGrid) { // The level view owns these nodes; detaching alone would leave each // TileLayerNode subscribed to its (loader-owned) layer. for (const child of [...this.content.children]) { child.destroy(); } - const level = this.world.levels[index]; + const level = this.world.levels.find(candidate => candidate.name === name); if (!level) { return; } const mapNode = new TileMapNode(level); this.content.addChild(mapNode); - if (this.showIntGrid) { + if (showIntGrid) { this.content.addChild(this.buildIntGridOverlay(level)); } this.content.addChild(this.buildEntityLabels(level)); const entities = level.getObjectLayer('Entities'); - this.hud.setStatus(`${level.name}: ${entities?.objects.length ?? 0} entities, ${level.width}x${level.height} tiles`); + this.summary = `${level.name}: ${entities?.objects.length ?? 0} entities, ${level.width}x${level.height} tiles`; + this.panel?.status(this.summary); } /** Tint every IntGrid cell using its LDtk-authored name + colour (Wall / Water). */ buildIntGridOverlay(level) { @@ -125,7 +110,6 @@ class LdtkWorldImportScene extends Scene { } destroy() { this.content.destroy(); - this.hud?.dispose(); this.panel?.dispose(); super.destroy(); } diff --git a/examples/tilemap/ldtk-world-import.ts b/examples/tilemap/ldtk-world-import.ts index 53f23d2e2..f04e19938 100644 --- a/examples/tilemap/ldtk-world-import.ts +++ b/examples/tilemap/ldtk-world-import.ts @@ -9,9 +9,10 @@ import { Scene, Text, } from '@codexo/exojs'; +import { TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { getLdtkIntGridValueAt, ldtkExtension, type LdtkMap } from '@codexo/exojs-ldtk'; -import { TileMapNode } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Loading an LDtk world (`.ldtk`) through @codexo/exojs-ldtk. // @@ -29,7 +30,7 @@ import { mountControlPanel, mountControls } from '@examples/runtime'; // `TileMapObject` shape a Tiled object layer produces, with entity // fields (`Int`, `String`, ...) carried as `properties`. // -// harbor-world.ldtk (examples/assets/json/maps/) defines two levels sharing +// harbor-world.ldtk (`assets.harborWorldProject`) defines two levels sharing // one tileset: "Level_Harbor" and "Level_Lighthouse", each with a Ground // tile layer, a Walls IntGrid layer (Wall / Water), and an Entities layer. @@ -43,58 +44,36 @@ const hexToColor = (hex: string, alpha: number): Color => { class LdtkWorldImportScene extends Scene { private world!: LdtkMap; private content = new Container(); - private hud!: ReturnType; - private panel!: ReturnType; - private showIntGrid = true; + private panel?: ReturnType; + private summary = ''; override async load(): Promise { - // The .ldtk source must be an absolute URL: @codexo/exojs-ldtk resolves - // tileset relPaths with `new URL(relPath, source)`, which throws for a - // relative `source` (unlike the Tiled adapter's relative-base-aware - // resolver). Resolve against the page URL until the adapter handles - // relative bases itself. - const worldUrl = new URL('assets/json/maps/harbor-world.ldtk', window.location.href).href; - - this.world = await this.loader.load(Asset.type('ldtkMap', worldUrl)); - - this.hud = mountControls({ - title: 'LDtk World Import', - controls: [{ keys: 'panel', action: 'switch level / toggle the IntGrid overlay' }], - status: `${this.world.levels.length} levels loaded`, - hint: 'Coloured squares come from getLdtkIntGridValueAt() — the raw Walls IntGrid cell values, resolved to their LDtk-authored name + colour.', - }); - - this.panel = mountControlPanel({ title: 'LDtk' }); - this.panel.addCycle({ - label: 'Level', - options: this.world.levels.map(level => level.name), - index: 0, - onChange: index => this.showLevel(index), - }); - this.panel.addToggle({ - label: 'IntGrid overlay', - value: true, - onChange: on => { - this.showIntGrid = on; - this.showLevel(this.currentLevelIndex); + // The LDtk adapter resolves tileset paths with `new URL(relPath, source)`, + // so the world needs an absolute source; the demo catalog's URLs are. + this.world = await this.loader.load(Asset.type('ldtkMap', assets.harborWorldProject.source)); + + this.panel = demoPanel({ + title: 'LDtk', + controls: { + level: choice( + 'Level', + this.world.levels.map(level => level.name), + ), + intGrid: toggle('IntGrid overlay', true), }, + onChange: (_key, _value, values) => this.showLevel(values.level, values.intGrid), }); - - this.showLevel(0); + this.panel.status(this.summary); } - private currentLevelIndex = 0; - - private showLevel(index: number): void { - this.currentLevelIndex = index; - + private showLevel(name: string, showIntGrid: boolean): void { // The level view owns these nodes; detaching alone would leave each // TileLayerNode subscribed to its (loader-owned) layer. for (const child of [...this.content.children]) { child.destroy(); } - const level = this.world.levels[index]; + const level = this.world.levels.find(candidate => candidate.name === name); if (!level) { return; @@ -103,7 +82,7 @@ class LdtkWorldImportScene extends Scene { const mapNode = new TileMapNode(level); this.content.addChild(mapNode); - if (this.showIntGrid) { + if (showIntGrid) { this.content.addChild(this.buildIntGridOverlay(level)); } @@ -111,11 +90,12 @@ class LdtkWorldImportScene extends Scene { const entities = level.getObjectLayer('Entities'); - this.hud.setStatus(`${level.name}: ${entities?.objects.length ?? 0} entities, ${level.width}x${level.height} tiles`); + this.summary = `${level.name}: ${entities?.objects.length ?? 0} entities, ${level.width}x${level.height} tiles`; + this.panel?.status(this.summary); } /** Tint every IntGrid cell using its LDtk-authored name + colour (Wall / Water). */ - private buildIntGridOverlay(level: import('@codexo/exojs-tilemap').TileMap): Graphics { + private buildIntGridOverlay(level: import('@codexo/exojs/tilemap').TileMap): Graphics { const overlay = new Graphics(); const walls = level.layers.find(layer => layer.name === 'Walls'); @@ -142,7 +122,7 @@ class LdtkWorldImportScene extends Scene { } /** One small label per entity, naming it and its first custom field. */ - private buildEntityLabels(level: import('@codexo/exojs-tilemap').TileMap): Container { + private buildEntityLabels(level: import('@codexo/exojs/tilemap').TileMap): Container { const labels = new Container(); const entities = level.getObjectLayer('Entities'); @@ -167,7 +147,6 @@ class LdtkWorldImportScene extends Scene { override destroy(): void { this.content.destroy(); - this.hud?.dispose(); this.panel?.dispose(); super.destroy(); } diff --git a/examples/tilemap/level-loading-and-ownership.js b/examples/tilemap/level-loading-and-ownership.js index 4f56f129f..b3757206d 100644 --- a/examples/tilemap/level-loading-and-ownership.js +++ b/examples/tilemap/level-loading-and-ownership.js @@ -1,8 +1,9 @@ // 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 { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel } from '@examples/runtime'; const ROOM_SIZE = { x: 0, y: 0, width: 1280, height: 768 }; const world = new MapWorld({ name: 'Two Rooms', @@ -28,8 +29,8 @@ const world = new MapWorld({ ], }); const roomPath = { - harbor: 'json/maps/harbor-plaza.tmj', - workshop: 'json/maps/ownership-workshop.tmj', + harbor: assets.harborPlazaMap.source, + workshop: assets.ownershipWorkshopMap.source, }; const waitForTransition = signal => new Promise((resolve, reject) => { @@ -53,7 +54,6 @@ class LevelOwnershipScene extends Scene { activeId = null; pending = null; roomLabel = new Text('No room loaded', { fillColor: new Color(31, 49, 54), fontSize: 34 }); - hud; panel; init() { this.roomLabel.position.set(470, 80); @@ -65,27 +65,20 @@ class LevelOwnershipScene extends Scene { 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 = demoPanel({ + title: 'Rooms', + controls: {}, + actions: { + 'Enter Harbor': () => { + void this.enter('harbor'); + }, + 'Enter Workshop': () => { + void this.enter('workshop'); + }, + 'Cancel pending': () => this.cancelPending(), + 'Unload room': () => this.unloadActive(), }, }); - 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) { @@ -97,7 +90,8 @@ class LevelOwnershipScene extends Scene { // settles after its replacement started, and must not touch its state. const request = { id }; this.pending = request; - this.hud.setStatus(`Loading ${world.getLevel(id)?.name}... ${this.describeResidency()}`); + this.roomLabel.text = `Loading ${world.getLevel(id)?.name}...`; + this.panel.status(this.describeResidency()); try { const level = await this.runtime.loadLevel(id); if (this.pending !== request) { @@ -113,10 +107,10 @@ class LevelOwnershipScene extends Scene { if (previousId) { this.runtime.unloadLevel(previousId); } - this.hud.setStatus(`${level.level.name} ready. ${this.describeResidency()}`); + this.panel.status(this.describeResidency()); } catch (error) { if (this.pending === request) { - this.hud.setStatus(error instanceof Error ? error.message : String(error)); + this.panel.status(error instanceof Error ? error.message : String(error)); } } finally { if (this.pending === request) { @@ -131,7 +125,8 @@ class LevelOwnershipScene extends Scene { const { id } = this.pending; this.pending = null; this.runtime.unloadLevel(id); - this.hud.setStatus(`${world.getLevel(id)?.name} cancelled. ${this.describeResidency()}`); + this.roomLabel.text = this.activeId ? `${world.getLevel(this.activeId)?.name} loaded` : 'No room loaded'; + this.panel.status(this.describeResidency()); } unloadActive() { if (!this.activeId) { @@ -143,7 +138,7 @@ class LevelOwnershipScene extends Scene { this.activeId = null; this.runtime.unloadLevel(id); this.roomLabel.text = 'No room loaded'; - this.hud.setStatus(`${world.getLevel(id)?.name} unloaded. ${this.describeResidency()}`); + this.panel.status(this.describeResidency()); } describeResidency() { const atlas = this.app.loader.inspect().find(row => row.aliases.some(alias => alias.includes('mapPack_tilesheet'))); @@ -160,7 +155,6 @@ class LevelOwnershipScene extends Scene { this.node?.destroy(); this.runtime?.destroy(); this.roomLabel.destroy(); - this.hud?.dispose(); this.panel?.dispose(); super.destroy(); } @@ -170,6 +164,5 @@ const app = new Application({ 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 index 5aa713e44..9404e157c 100644 --- a/examples/tilemap/level-loading-and-ownership.ts +++ b/examples/tilemap/level-loading-and-ownership.ts @@ -1,7 +1,8 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { MapLevelSide, MapWorld, MapWorldRuntime } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { demoPanel } from '@examples/runtime'; const ROOM_SIZE = { x: 0, y: 0, width: 1280, height: 768 }; const world = new MapWorld({ @@ -29,8 +30,8 @@ const world = new MapWorld({ }); const roomPath: Record = { - harbor: 'json/maps/harbor-plaza.tmj', - workshop: 'json/maps/ownership-workshop.tmj', + harbor: assets.harborPlazaMap.source, + workshop: assets.ownershipWorkshopMap.source, }; const waitForTransition = (signal: AbortSignal): Promise => @@ -59,8 +60,7 @@ class LevelOwnershipScene extends Scene { private activeId: string | null = null; private pending: { readonly id: 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; + private panel!: ReturnType; override init(): void { this.roomLabel.position.set(470, 80); @@ -73,27 +73,20 @@ class LevelOwnershipScene extends Scene { 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 = demoPanel({ + title: 'Rooms', + controls: {}, + actions: { + 'Enter Harbor': () => { + void this.enter('harbor'); + }, + 'Enter Workshop': () => { + void this.enter('workshop'); + }, + 'Cancel pending': () => this.cancelPending(), + 'Unload room': () => this.unloadActive(), }, }); - this.panel.addButton({ label: 'Cancel pending', onClick: () => this.cancelPending() }); - this.panel.addButton({ label: 'Unload room', onClick: () => this.unloadActive() }); void this.enter('harbor'); } @@ -109,7 +102,8 @@ class LevelOwnershipScene extends Scene { const request = { id }; this.pending = request; - this.hud.setStatus(`Loading ${world.getLevel(id)?.name}... ${this.describeResidency()}`); + this.roomLabel.text = `Loading ${world.getLevel(id)?.name}...`; + this.panel.status(this.describeResidency()); try { const level = await this.runtime.loadLevel(id); @@ -131,10 +125,10 @@ class LevelOwnershipScene extends Scene { this.runtime.unloadLevel(previousId); } - this.hud.setStatus(`${level.level.name} ready. ${this.describeResidency()}`); + this.panel.status(this.describeResidency()); } catch (error) { if (this.pending === request) { - this.hud.setStatus(error instanceof Error ? error.message : String(error)); + this.panel.status(error instanceof Error ? error.message : String(error)); } } finally { if (this.pending === request) { @@ -152,7 +146,8 @@ class LevelOwnershipScene extends Scene { this.pending = null; this.runtime.unloadLevel(id); - this.hud.setStatus(`${world.getLevel(id)?.name} cancelled. ${this.describeResidency()}`); + this.roomLabel.text = this.activeId ? `${world.getLevel(this.activeId)?.name} loaded` : 'No room loaded'; + this.panel.status(this.describeResidency()); } private unloadActive(): void { @@ -166,7 +161,7 @@ class LevelOwnershipScene extends Scene { this.activeId = null; this.runtime.unloadLevel(id); this.roomLabel.text = 'No room loaded'; - this.hud.setStatus(`${world.getLevel(id)?.name} unloaded. ${this.describeResidency()}`); + this.panel.status(this.describeResidency()); } private describeResidency(): string { @@ -188,7 +183,6 @@ class LevelOwnershipScene extends Scene { this.node?.destroy(); this.runtime?.destroy(); this.roomLabel.destroy(); - this.hud?.dispose(); this.panel?.dispose(); super.destroy(); } @@ -199,7 +193,6 @@ const app = new Application({ 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 index ad5586fb9..7eb1027dc 100644 --- a/examples/tilemap/paint-autotiled-room.js +++ b/examples/tilemap/paint-autotiled-room.js @@ -1,5 +1,5 @@ // 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 { Application, Color, FixedResolutionCanvasSizing, Graphics, Scene, TextureRegion } from '@codexo/exojs'; import { refreshCell, TILE_TRANSFORM_IDENTITY, @@ -9,8 +9,9 @@ import { TileMapNode, TileSet, WangSet, -} from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, toggle } from '@examples/runtime'; const TILE = 64; const WIDTH = 16; const HEIGHT = 11; @@ -52,7 +53,6 @@ class AutoTiledRoomScene extends Scene { tileset; node; grid = new Graphics(); - hud; panel; wang = new WangSet({ tilesetIndex: 0, @@ -62,7 +62,7 @@ class AutoTiledRoomScene extends Scene { erase = false; painting = false; async load() { - const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const texture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'map-pack', texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), @@ -110,17 +110,10 @@ class AutoTiledRoomScene extends Scene { 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.panel = demoPanel({ + title: 'Brush', + controls: { erase: toggle('Erase', false) }, + onChange: (_key, value) => { this.erase = value; }, }); @@ -141,7 +134,6 @@ class AutoTiledRoomScene extends Scene { 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; @@ -164,7 +156,6 @@ class AutoTiledRoomScene extends Scene { 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(); @@ -176,6 +167,5 @@ const app = new Application({ 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 index e2e64c608..10e9618bc 100644 --- a/examples/tilemap/paint-autotiled-room.ts +++ b/examples/tilemap/paint-autotiled-room.ts @@ -1,13 +1,4 @@ -import { - Application, - Asset, - Color, - FixedResolutionCanvasSizing, - Graphics, - type RenderingContext, - Scene, - TextureRegion, -} from '@codexo/exojs'; +import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, TextureRegion } from '@codexo/exojs'; import { refreshCell, TILE_TRANSFORM_IDENTITY, @@ -17,8 +8,9 @@ import { TileMapNode, TileSet, WangSet, -} from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { demoPanel, toggle } from '@examples/runtime'; const TILE = 64; const WIDTH = 16; @@ -72,8 +64,7 @@ class AutoTiledRoomScene extends Scene { private tileset!: TileSet; private node!: TileMapNode; private readonly grid = new Graphics(); - private hud!: ReturnType; - private panel!: ReturnType; + private panel!: ReturnType; private readonly wang = new WangSet({ tilesetIndex: 0, type: 'edge', @@ -83,7 +74,7 @@ class AutoTiledRoomScene extends Scene { private painting = false; override async load(): Promise { - const texture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const texture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'map-pack', texture: new TextureRegion(texture, { x: 0, y: 0, width: texture.width, height: texture.height }), @@ -138,17 +129,10 @@ class AutoTiledRoomScene extends Scene { 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.panel = demoPanel({ + title: 'Brush', + controls: { erase: toggle('Erase', false) }, + onChange: (_key, value) => { this.erase = value; }, }); @@ -173,7 +157,6 @@ class AutoTiledRoomScene extends Scene { } 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; @@ -198,7 +181,6 @@ class AutoTiledRoomScene extends Scene { 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(); @@ -211,7 +193,6 @@ const app = new Application({ 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/tile-chunks-and-bands.js b/examples/tilemap/tile-chunks-and-bands.js index 707348dc9..ccfb3634c 100644 --- a/examples/tilemap/tile-chunks-and-bands.js +++ b/examples/tilemap/tile-chunks-and-bands.js @@ -1,7 +1,6 @@ // Auto-generated from tile-chunks-and-bands.ts - edit the .ts source, not this file. import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, @@ -13,8 +12,9 @@ import { TextureRegion, View, } from '@codexo/exojs'; -import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Core @codexo/exojs-tilemap API, with no on-disk map format involved: a // procedural TileMap built entirely from code. // @@ -48,14 +48,15 @@ class TileChunksAndBandsScene extends Scene { explorer; worldRoot; mapView; - moveX = 0; - moveY = 0; - hud; + up; + down; + left; + right; async load() { - // ── Tileset + two layers, built entirely in code ────────────────── + // Tileset + two layers, built entirely in code // Await the atlas load: TileSet needs a TextureRegion with real // dimensions, so a not-yet-hydrated `loader.get()` handle is not enough. - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); const tileset = new TileSet({ name: 'map', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -108,11 +109,11 @@ class TileChunksAndBandsScene extends Scene { tilesets: [tileset], layers: [ground, canopy], }); - // ── TileMapView: group layers into bands, place actors between ──── + // TileMapView: group layers into bands, place actors between this.mapView = map.createView({ bands: { ground: ['ground'], canopy: ['canopy'] } }); const characters = new Spritesheet( - this.loader.get(assets.demo.spritesheets.platformerCharacters.image), - await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)), + this.loader.get(assets.platformerCharactersImage), + await this.loader.load(assets.platformerCharactersAtlas), ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.explorer.setPosition(MAP_WIDTH / 2, MAP_HEIGHT / 2); @@ -123,7 +124,7 @@ class TileChunksAndBandsScene extends Scene { // Render order: ground band -> actors -> canopy band. The canopy // tiles visually cover the explorer whenever they overlap. this.worldRoot.addChild(this.mapView.band('ground'), actorLayer, this.mapView.band('canopy')); - // ── Camera: follows the explorer, clamped to the map bounds ─────── + // Camera: follows the explorer, clamped to the map bounds const { width, height } = app; this.camera = new View(this.explorer.x, this.explorer.y, width, height); this.camera.follow(this.explorer, { lerp: 0.12 }); @@ -132,70 +133,34 @@ class TileChunksAndBandsScene extends Scene { this.setupHud(); } setupInput() { - this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); - this.inputs.onStop(Keyboard.A, () => { - if (this.moveX < 0) { - this.moveX = 0; - } - }); - this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); - this.inputs.onStop(Keyboard.D, () => { - if (this.moveX > 0) { - this.moveX = 0; - } - }); - this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); - this.inputs.onStop(Keyboard.W, () => { - if (this.moveY < 0) { - this.moveY = 0; - } - }); - this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); - this.inputs.onStop(Keyboard.S, () => { - if (this.moveY > 0) { - this.moveY = 0; - } - }); + 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]); } setupHud() { - const chunkCount = name => { - const layer = this.mapView.getLayerNodesByName(name)[0]; - return layer ? layer.children.length : 0; - }; - this.hud = mountControls({ - title: 'Tilemap Chunks & Layer Bands', - controls: [ - { keys: 'WASD', action: 'move the explorer' }, - { keys: 'panel', action: 'toggle canopy band / pixel snapping' }, - ], - status: `${chunkCount('ground')} ground chunks, ${chunkCount('canopy')} canopy chunks loaded`, - hint: 'The explorer renders between the ground and canopy TileMapBands — canopy tiles draw over it. Camera culling of off-screen chunks is automatic.', - }); - const panel = mountControlPanel({ title: 'Tilemap' }); - panel.addToggle({ - label: 'Canopy band', - value: true, - onChange: visible => { - this.mapView.band('canopy').visible = visible; + // Starts on the view's default, `geometry`. Stepping to `none` shows what + // it buys: every boundary then samples between two device pixels and the + // seams shimmer as the camera moves. + const snapModes = { geometry: PixelSnapMode.Geometry, position: PixelSnapMode.Position, none: PixelSnapMode.None }; + demoPanel({ + title: 'Tilemap', + controls: { + canopy: toggle('Canopy band', true), + snap: choice('Pixel snap', ['geometry', 'position', 'none']), }, - }); - // Starts on the view's default, `geometry`. Cycling down to `none` shows - // what it buys: every boundary then samples between two device pixels and - // the seams shimmer as the camera moves. - panel.addCycle({ - label: 'Pixel snap', - options: ['geometry', 'position', 'none'], - index: 0, - onChange: index => { - const modes = [PixelSnapMode.Geometry, PixelSnapMode.Position, PixelSnapMode.None]; - this.mapView.pixelSnapMode = modes[index] ?? PixelSnapMode.Geometry; + onChange: (_key, _value, values) => { + this.mapView.band('canopy').visible = values.canopy; + this.mapView.pixelSnapMode = snapModes[values.snap]; }, }); } update(delta) { - if (this.moveX !== 0 || this.moveY !== 0) { - const length = Math.hypot(this.moveX, this.moveY) || 1; - this.explorer.move((this.moveX / length) * MOVE_SPEED * delta, (this.moveY / length) * MOVE_SPEED * delta); + const moveX = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0); + const moveY = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0); + if (moveX !== 0 || moveY !== 0) { + const length = Math.hypot(moveX, moveY); + this.explorer.move((moveX / length) * MOVE_SPEED * delta, (moveY / length) * MOVE_SPEED * delta); this.explorer.x = Math.min(Math.max(this.explorer.x, 0), MAP_WIDTH); this.explorer.y = Math.min(Math.max(this.explorer.y, 0), MAP_HEIGHT); } @@ -216,7 +181,5 @@ const app = new Application({ // The tilemap extension wires the per-backend chunk renderer so // TileLayerNode (generated by TileMapView) can draw. extensions: [tilemapExtension], - // No loader.basePath here: every asset below comes from the shared - // `assets` catalog, whose URLs are already fully resolved. }); await app.start(TileChunksAndBandsScene); diff --git a/examples/tilemap/tile-chunks-and-bands.ts b/examples/tilemap/tile-chunks-and-bands.ts index f0f2abfc5..8fde5a997 100644 --- a/examples/tilemap/tile-chunks-and-bands.ts +++ b/examples/tilemap/tile-chunks-and-bands.ts @@ -1,9 +1,9 @@ import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, + type InputBinding, Keyboard, PixelSnapMode, Rectangle, @@ -16,8 +16,9 @@ import { TextureRegion, View, } from '@codexo/exojs'; -import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, type TileMapView, TileSet } from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, type TileMapView, TileSet } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, toggle } from '@examples/runtime'; // Core @codexo/exojs-tilemap API, with no on-disk map format involved: a // procedural TileMap built entirely from code. @@ -55,15 +56,16 @@ class TileChunksAndBandsScene extends Scene { private explorer!: Sprite; private worldRoot!: Container; private mapView!: TileMapView; - private moveX = 0; - private moveY = 0; - private hud!: ReturnType; + private up!: InputBinding; + private down!: InputBinding; + private left!: InputBinding; + private right!: InputBinding; override async load(): Promise { - // ── Tileset + two layers, built entirely in code ────────────────── + // Tileset + two layers, built entirely in code // Await the atlas load: TileSet needs a TextureRegion with real // dimensions, so a not-yet-hydrated `loader.get()` handle is not enough. - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); const tileset = new TileSet({ name: 'map', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -121,12 +123,12 @@ class TileChunksAndBandsScene extends Scene { layers: [ground, canopy], }); - // ── TileMapView: group layers into bands, place actors between ──── + // TileMapView: group layers into bands, place actors between this.mapView = map.createView({ bands: { ground: ['ground'], canopy: ['canopy'] } }); const characters = new Spritesheet( - this.loader.get(assets.demo.spritesheets.platformerCharacters.image), - (await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, + this.loader.get(assets.platformerCharactersImage), + (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData, ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); @@ -141,7 +143,7 @@ class TileChunksAndBandsScene extends Scene { // tiles visually cover the explorer whenever they overlap. this.worldRoot.addChild(this.mapView.band('ground'), actorLayer, this.mapView.band('canopy')); - // ── Camera: follows the explorer, clamped to the map bounds ─────── + // Camera: follows the explorer, clamped to the map bounds const { width, height } = app; this.camera = new View(this.explorer.x, this.explorer.y, width, height); @@ -153,77 +155,39 @@ class TileChunksAndBandsScene extends Scene { } private setupInput(): void { - this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); - this.inputs.onStop(Keyboard.A, () => { - if (this.moveX < 0) { - this.moveX = 0; - } - }); - this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); - this.inputs.onStop(Keyboard.D, () => { - if (this.moveX > 0) { - this.moveX = 0; - } - }); - this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); - this.inputs.onStop(Keyboard.W, () => { - if (this.moveY < 0) { - this.moveY = 0; - } - }); - this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); - this.inputs.onStop(Keyboard.S, () => { - if (this.moveY > 0) { - this.moveY = 0; - } - }); + 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]); } private setupHud(): void { - const chunkCount = (name: string): number => { - const layer = this.mapView.getLayerNodesByName(name)[0]; - - return layer ? layer.children.length : 0; - }; - - this.hud = mountControls({ - title: 'Tilemap Chunks & Layer Bands', - controls: [ - { keys: 'WASD', action: 'move the explorer' }, - { keys: 'panel', action: 'toggle canopy band / pixel snapping' }, - ], - status: `${chunkCount('ground')} ground chunks, ${chunkCount('canopy')} canopy chunks loaded`, - hint: 'The explorer renders between the ground and canopy TileMapBands — canopy tiles draw over it. Camera culling of off-screen chunks is automatic.', - }); - - const panel = mountControlPanel({ title: 'Tilemap' }); - panel.addToggle({ - label: 'Canopy band', - value: true, - onChange: visible => { - this.mapView.band('canopy').visible = visible; + // Starts on the view's default, `geometry`. Stepping to `none` shows what + // it buys: every boundary then samples between two device pixels and the + // seams shimmer as the camera moves. + const snapModes = { geometry: PixelSnapMode.Geometry, position: PixelSnapMode.Position, none: PixelSnapMode.None } as const; + + demoPanel({ + title: 'Tilemap', + controls: { + canopy: toggle('Canopy band', true), + snap: choice('Pixel snap', ['geometry', 'position', 'none']), }, - }); - // Starts on the view's default, `geometry`. Cycling down to `none` shows - // what it buys: every boundary then samples between two device pixels and - // the seams shimmer as the camera moves. - panel.addCycle({ - label: 'Pixel snap', - options: ['geometry', 'position', 'none'], - index: 0, - onChange: index => { - const modes = [PixelSnapMode.Geometry, PixelSnapMode.Position, PixelSnapMode.None] as const; - - this.mapView.pixelSnapMode = modes[index] ?? PixelSnapMode.Geometry; + onChange: (_key, _value, values) => { + this.mapView.band('canopy').visible = values.canopy; + this.mapView.pixelSnapMode = snapModes[values.snap]; }, }); } override update(delta: Seconds): void { - if (this.moveX !== 0 || this.moveY !== 0) { - const length = Math.hypot(this.moveX, this.moveY) || 1; + const moveX = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0); + const moveY = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0); + + if (moveX !== 0 || moveY !== 0) { + const length = Math.hypot(moveX, moveY); - this.explorer.move((this.moveX / length) * MOVE_SPEED * delta, (this.moveY / length) * MOVE_SPEED * delta); + this.explorer.move((moveX / length) * MOVE_SPEED * delta, (moveY / length) * MOVE_SPEED * delta); this.explorer.x = Math.min(Math.max(this.explorer.x, 0), MAP_WIDTH); this.explorer.y = Math.min(Math.max(this.explorer.y, 0), MAP_HEIGHT); } @@ -246,8 +210,6 @@ const app = new Application({ // The tilemap extension wires the per-backend chunk renderer so // TileLayerNode (generated by TileMapView) can draw. extensions: [tilemapExtension], - // No loader.basePath here: every asset below comes from the shared - // `assets` catalog, whose URLs are already fully resolved. }); await app.start(TileChunksAndBandsScene); diff --git a/examples/tilemap/tiled-infinite-map.js b/examples/tilemap/tiled-infinite-map.js index aba083e0a..daf0263d1 100644 --- a/examples/tilemap/tiled-infinite-map.js +++ b/examples/tilemap/tiled-infinite-map.js @@ -1,7 +1,8 @@ // Auto-generated from tiled-infinite-map.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, View } from '@codexo/exojs'; +import { ChunkStreamer, TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension } from '@codexo/exojs-tiled'; -import { ChunkStreamer, TileMapNode } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; // A hand-authored Tiled `.tmj` infinite map, streamed the same way the // procedural-terrain examples stream generated worlds: TiledMap.toTileMap() @@ -29,7 +30,7 @@ class TiledInfiniteMapScene extends Scene { hudTimer = 0; hud; async load() { - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/drift-fields.tmj')); + const source = await this.loader.load(Asset.type('tiledSource', assets.driftFieldsMap.source)); const runtimeMap = source.toTileMap(); this.mapNode = new TileMapNode(runtimeMap); const ground = runtimeMap.getTileLayer('Ground'); @@ -81,12 +82,7 @@ class TiledInfiniteMapScene extends Scene { }); } setupHud() { - this.hud = mountControls({ - title: 'Tiled Infinite Map Streaming', - controls: [{ keys: 'WASD', action: 'fly the free camera across the streamed map' }], - status: '', - hint: '"Ground" and "Props" each stream through their own ChunkStreamer, sourced from TiledMap.getChunkSource(layer.id) — Tiled\'s on-disk 16x16 chunks are re-sliced onto the runtime chunk grid on demand, one requested chunk at a time.', - }); + this.hud = mountControls(); } update(delta) { if (this.moveX !== 0 || this.moveY !== 0) { @@ -116,8 +112,5 @@ const app = new Application({ // tiledExtension depends on tilemapExtension, so registering it alone is // enough for both loading (.tmj) and rendering (TileMapNode). extensions: [tiledExtension], - loader: { - basePath: 'assets/', - }, }); await app.start(TiledInfiniteMapScene); diff --git a/examples/tilemap/tiled-infinite-map.ts b/examples/tilemap/tiled-infinite-map.ts index e6616ca49..697dadbf6 100644 --- a/examples/tilemap/tiled-infinite-map.ts +++ b/examples/tilemap/tiled-infinite-map.ts @@ -9,8 +9,9 @@ import { type Seconds, View, } from '@codexo/exojs'; +import { ChunkStreamer, TileMapNode } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension } from '@codexo/exojs-tiled'; -import { ChunkStreamer, TileMapNode } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; // A hand-authored Tiled `.tmj` infinite map, streamed the same way the @@ -42,7 +43,7 @@ class TiledInfiniteMapScene extends Scene { private hud!: ReturnType; override async load(): Promise { - const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/drift-fields.tmj')); + const source = await this.loader.load(Asset.type('tiledSource', assets.driftFieldsMap.source)); const runtimeMap = source.toTileMap(); this.mapNode = new TileMapNode(runtimeMap); @@ -105,12 +106,7 @@ class TiledInfiniteMapScene extends Scene { } private setupHud(): void { - this.hud = mountControls({ - title: 'Tiled Infinite Map Streaming', - controls: [{ keys: 'WASD', action: 'fly the free camera across the streamed map' }], - status: '', - hint: '"Ground" and "Props" each stream through their own ChunkStreamer, sourced from TiledMap.getChunkSource(layer.id) — Tiled\'s on-disk 16x16 chunks are re-sliced onto the runtime chunk grid on demand, one requested chunk at a time.', - }); + this.hud = mountControls(); } override update(delta: Seconds): void { @@ -147,9 +143,6 @@ const app = new Application({ // tiledExtension depends on tilemapExtension, so registering it alone is // enough for both loading (.tmj) and rendering (TileMapNode). extensions: [tiledExtension], - loader: { - basePath: 'assets/', - }, }); await app.start(TiledInfiniteMapScene); diff --git a/examples/tilemap/tiled-isometric.js b/examples/tilemap/tiled-isometric.js index 89b74e3c5..17682371a 100644 --- a/examples/tilemap/tiled-isometric.js +++ b/examples/tilemap/tiled-isometric.js @@ -1,9 +1,10 @@ // Auto-generated from tiled-isometric.ts - edit the .ts source, not this file. import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, Graphics, Scene, SystemOrder } from '@codexo/exojs'; -import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; +import { TileMapNode } from '@codexo/exojs/tilemap'; +import { buildObjectLayerColliders, TilePhysicsBinding } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension } from '@codexo/exojs-tiled'; -import { TileMapNode } from '@codexo/exojs-tilemap'; -import { buildObjectLayerColliders, TilePhysicsBinding } from '@codexo/exojs-tilemap-physics'; import { mountControls } from '@examples/runtime'; const scale = 0.72; const origin = { x: 165, y: 115 }; @@ -18,7 +19,7 @@ class IsometricScene extends Scene { target = { x: 99, y: 99 }; hud; async load() { - this.map = await this.loader.load(Asset.type('tileMap', assets.demo.isometricLandscape)); + this.map = await this.loader.load(Asset.type('tileMap', assets.isometricLandscape.source)); } init() { this.world = new PhysicsWorld({ gravity: { x: 0, y: 0 }, fixedDelta: this.app.fixedTimeStep }); @@ -65,12 +66,7 @@ class IsometricScene extends Scene { }, { order: SystemOrder.Physics - 1 }, ); - this.hud = mountControls({ - title: 'Isometric Landscape', - controls: [{ keys: 'Click', action: 'pick a diamond and move toward it' }], - status: 'Cross the canal through the central bridge. Cyan outlines show solid banks.', - hint: 'Kenney Isometric Tiles Landscape (CC0). The actor collides in logical space; its position is projected onto the map.', - }); + this.hud = mountControls(); this.app.input.onPointerTap.add(this.onTap); } onTap = pointer => { @@ -99,7 +95,7 @@ class IsometricScene extends Scene { return [point.x, point.y]; }), ); - this.hud.setStatus(`Cell (${tx}, ${ty}) - logical target (${this.target.x}, ${this.target.y}). Click the bridge to pass the banks.`); + this.hud.setStatus(`Cell (${tx}, ${ty}) - logical target (${this.target.x}, ${this.target.y})`); }; draw(context) { this.binding.sync(); @@ -117,6 +113,5 @@ const app = new Application({ canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(30, 43, 57), extensions: [tiledExtension], - loader: { basePath: 'assets/' }, }); await app.start(IsometricScene); diff --git a/examples/tilemap/tiled-isometric.ts b/examples/tilemap/tiled-isometric.ts index 620d1c9e2..9fd237136 100644 --- a/examples/tilemap/tiled-isometric.ts +++ b/examples/tilemap/tiled-isometric.ts @@ -9,10 +9,11 @@ import { Scene, SystemOrder, } from '@codexo/exojs'; -import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; +import { type TileMap, TileMapNode } from '@codexo/exojs/tilemap'; +import { buildObjectLayerColliders, TilePhysicsBinding } from '@codexo/exojs/tilemap-physics'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension } from '@codexo/exojs-tiled'; -import { type TileMap, TileMapNode } from '@codexo/exojs-tilemap'; -import { buildObjectLayerColliders, TilePhysicsBinding } from '@codexo/exojs-tilemap-physics'; import { mountControls } from '@examples/runtime'; const scale = 0.72; @@ -30,7 +31,7 @@ class IsometricScene extends Scene { private hud!: ReturnType; override async load(): Promise { - this.map = await this.loader.load(Asset.type('tileMap', assets.demo.isometricLandscape)); + this.map = await this.loader.load(Asset.type('tileMap', assets.isometricLandscape.source)); } override init(): void { @@ -81,12 +82,7 @@ class IsometricScene extends Scene { }, { order: SystemOrder.Physics - 1 }, ); - this.hud = mountControls({ - title: 'Isometric Landscape', - controls: [{ keys: 'Click', action: 'pick a diamond and move toward it' }], - status: 'Cross the canal through the central bridge. Cyan outlines show solid banks.', - hint: 'Kenney Isometric Tiles Landscape (CC0). The actor collides in logical space; its position is projected onto the map.', - }); + this.hud = mountControls(); this.app.input.onPointerTap.add(this.onTap); } @@ -119,7 +115,7 @@ class IsometricScene extends Scene { return [point.x, point.y]; }), ); - this.hud.setStatus(`Cell (${tx}, ${ty}) - logical target (${this.target.x}, ${this.target.y}). Click the bridge to pass the banks.`); + this.hud.setStatus(`Cell (${tx}, ${ty}) - logical target (${this.target.x}, ${this.target.y})`); }; override draw(context: RenderingContext): void { @@ -140,7 +136,6 @@ const app = new Application({ canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: new Color(30, 43, 57), extensions: [tiledExtension], - loader: { basePath: 'assets/' }, }); await app.start(IsometricScene); diff --git a/examples/tilemap/tiled-map-import.js b/examples/tilemap/tiled-map-import.js index 5a74337d4..fa950005d 100644 --- a/examples/tilemap/tiled-map-import.js +++ b/examples/tilemap/tiled-map-import.js @@ -1,7 +1,8 @@ // Auto-generated from tiled-map-import.ts - edit the .ts source, not this file. import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Text } from '@codexo/exojs'; +import { ObjectKind } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { ObjectKind } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; const contains = (object, x, y) => { if (object.kind === ObjectKind.Point) { @@ -22,7 +23,7 @@ class TiledMapImportScene extends Scene { selectedId = null; hud; async load() { - const map = await this.loader.load(Asset.type('tileMap', 'json/maps/harbor-plaza.tmj')); + const map = await this.loader.load(Asset.type('tileMap', assets.harborPlazaMap.source)); this.mapNode = new TileMapNode(map); this.zones = [...(map.getObjectLayer('Zones')?.objects ?? [])]; for (const object of this.zones) { @@ -31,12 +32,7 @@ class TiledMapImportScene extends Scene { this.labels.push(label); } this.redrawZones(); - this.hud = mountControls({ - 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.hud = mountControls({ status: `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.` }); this.app.input.onPointerTap.add(this.onTap); } onTap = pointer => { @@ -51,7 +47,7 @@ class TiledMapImportScene extends Scene { this.hud.setStatus( selected ? `${selected.name} (${selected.type || selected.kind})${properties ? ` - ${properties}` : ''}` - : 'Click a highlighted zone to inspect its authored data.', + : `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.`, ); }; redrawZones() { @@ -91,6 +87,5 @@ const app = new Application({ 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(TiledMapImportScene); diff --git a/examples/tilemap/tiled-map-import.ts b/examples/tilemap/tiled-map-import.ts index b238c9469..680ea4ad5 100644 --- a/examples/tilemap/tiled-map-import.ts +++ b/examples/tilemap/tiled-map-import.ts @@ -1,6 +1,7 @@ import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs'; +import { ObjectKind, type TileMapObject } from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { tiledExtension, TileMapNode } from '@codexo/exojs-tiled'; -import { ObjectKind, type TileMapObject } from '@codexo/exojs-tilemap'; import { mountControls } from '@examples/runtime'; const contains = (object: TileMapObject, x: number, y: number): boolean => { @@ -27,7 +28,7 @@ class TiledMapImportScene extends Scene { private hud!: ReturnType; override async load(): Promise { - const map = await this.loader.load(Asset.type('tileMap', 'json/maps/harbor-plaza.tmj')); + const map = await this.loader.load(Asset.type('tileMap', assets.harborPlazaMap.source)); this.mapNode = new TileMapNode(map); this.zones = [...(map.getObjectLayer('Zones')?.objects ?? [])]; @@ -38,12 +39,7 @@ class TiledMapImportScene extends Scene { } this.redrawZones(); - this.hud = mountControls({ - 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.hud = mountControls({ status: `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.` }); this.app.input.onPointerTap.add(this.onTap); } @@ -59,7 +55,7 @@ class TiledMapImportScene extends Scene { this.hud.setStatus( selected ? `${selected.name} (${selected.type || selected.kind})${properties ? ` - ${properties}` : ''}` - : 'Click a highlighted zone to inspect its authored data.', + : `Loaded Harbor Plaza: ${this.zones.length} objects in the Zones layer.`, ); }; @@ -106,7 +102,6 @@ const app = new Application({ 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(TiledMapImportScene); diff --git a/examples/tilemap/worker-streamed-terrain.js b/examples/tilemap/worker-streamed-terrain.js index 3eff9c4b3..d816b7a56 100644 --- a/examples/tilemap/worker-streamed-terrain.js +++ b/examples/tilemap/worker-streamed-terrain.js @@ -1,7 +1,6 @@ // Auto-generated from worker-streamed-terrain.ts - edit the .ts source, not this file. import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, @@ -20,8 +19,9 @@ import { TileMap, tilemapExtension, TileSet, -} from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, slider } from '@examples/runtime'; import { fbm } from '@examples/terrain-noise'; const terrainWorkerSource = '"use strict";\n(() => {\n // ../shared/terrain-noise.ts\n var 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 var 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 var 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'; @@ -79,9 +79,9 @@ class WorkerStreamedTerrainScene extends Scene { hudTimer = 0; frameIntervalMs = 0; streamingMs = 0; - hud; + panel; async load() { - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'biomes', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -102,8 +102,8 @@ class WorkerStreamedTerrainScene extends Scene { }); this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), - await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data)), + await this.loader.load(assets.platformerCharactersImage), + await this.loader.load(assets.platformerCharactersAtlas), ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.explorer.setPosition(0, 0); @@ -194,40 +194,31 @@ class WorkerStreamedTerrainScene extends Scene { }); } setupHud() { - this.hud = mountControls({ - title: 'Streamed Terrain', - controls: [ - { keys: 'WASD', action: 'fly across the endless world' }, - { keys: 'panel', action: 'change seed, provider, and sample cost' }, - ], - status: '', - 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: ['sync', 'worker'], - index: 0, - onChange: (_, mode) => { - this.providerMode = mode; - this.rebuildStreamer(); + this.panel = demoPanel({ + title: 'Provider', + controls: { + provider: choice('Provider', ['sync', 'worker']), + cost: slider('Sample cost', { min: 0, max: 500, value: 0, step: 50, format: value => `${value}` }), }, - }); - panel.addSlider({ - label: 'Sample cost', - min: 0, - max: 500, - step: 50, - value: 0, - onChange: value => { - this.extraCost = value; - this.rebuildStreamer(); + actions: { + 'New seed': () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, }, - }); - panel.addButton({ - label: 'New seed', - onClick: () => { - this.seed = (Math.random() * 0x7fffffff) | 0; + onChange: (key, value) => { + // The initial values match the fields, so mounting does not rebuild. + if (key === 'provider') { + if (value === this.providerMode) { + return; + } + this.providerMode = value; + } else { + if (value === this.extraCost) { + return; + } + this.extraCost = value; + } this.rebuildStreamer(); }, }); @@ -253,7 +244,7 @@ class WorkerStreamedTerrainScene extends Scene { const tx = Math.floor(this.explorer.x / TILE); const ty = Math.floor(this.explorer.y / TILE); this.hudTimer = 0; - this.hud.setStatus( + this.panel.status( `${this.providerMode} · ${this.frameIntervalMs.toFixed(1)} ms between frames · ${this.streamingMs.toFixed(1)} ms streaming on the main thread · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · seed ${this.seed} · cost ${this.extraCost}`, ); } @@ -262,6 +253,7 @@ class WorkerStreamedTerrainScene extends Scene { context.render(this.worldRoot, { view: this.camera }); } destroy() { + this.panel?.dispose(); this.streamer?.destroy(); this.workerSourceHandle?.destroy(); this.workerSourceHandle = null; diff --git a/examples/tilemap/worker-streamed-terrain.ts b/examples/tilemap/worker-streamed-terrain.ts index 4aef6ec28..ef285d889 100644 --- a/examples/tilemap/worker-streamed-terrain.ts +++ b/examples/tilemap/worker-streamed-terrain.ts @@ -1,6 +1,5 @@ import { Application, - Asset, Color, Container, FixedResolutionCanvasSizing, @@ -25,8 +24,9 @@ import { tilemapExtension, type TileMapView, TileSet, -} from '@codexo/exojs-tilemap'; -import { mountControlPanel, mountControls } from '@examples/runtime'; +} from '@codexo/exojs/tilemap'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel, slider } from '@examples/runtime'; import { fbm } from '@examples/terrain-noise'; import terrainWorkerSource from './worker-streamed-terrain.worker.ts?worker'; @@ -94,10 +94,10 @@ class WorkerStreamedTerrainScene extends Scene { private hudTimer = 0; private frameIntervalMs = 0; private streamingMs = 0; - private hud!: ReturnType; + private panel!: ReturnType; override async load(): Promise { - const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image)); + const tilesTexture = await this.loader.load(assets.mapPackTilesheet); this.tileset = new TileSet({ name: 'biomes', texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), @@ -120,8 +120,8 @@ class WorkerStreamedTerrainScene extends Scene { this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)), - (await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, + await this.loader.load(assets.platformerCharactersImage), + (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData, ); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); @@ -226,40 +226,34 @@ class WorkerStreamedTerrainScene extends Scene { } private setupHud(): void { - this.hud = mountControls({ - title: 'Streamed Terrain', - controls: [ - { keys: 'WASD', action: 'fly across the endless world' }, - { keys: 'panel', action: 'change seed, provider, and sample cost' }, - ], - status: '', - 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: ['sync', 'worker'], - index: 0, - onChange: (_, mode) => { - this.providerMode = mode as 'worker' | 'sync'; - this.rebuildStreamer(); + this.panel = demoPanel({ + title: 'Provider', + controls: { + provider: choice('Provider', ['sync', 'worker']), + cost: slider('Sample cost', { min: 0, max: 500, value: 0, step: 50, format: value => `${value}` }), }, - }); - panel.addSlider({ - label: 'Sample cost', - min: 0, - max: 500, - step: 50, - value: 0, - onChange: value => { - this.extraCost = value; - this.rebuildStreamer(); + actions: { + 'New seed': () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, }, - }); - panel.addButton({ - label: 'New seed', - onClick: () => { - this.seed = (Math.random() * 0x7fffffff) | 0; + onChange: (key, value) => { + // The initial values match the fields, so mounting does not rebuild. + if (key === 'provider') { + if (value === this.providerMode) { + return; + } + + this.providerMode = value as 'worker' | 'sync'; + } else { + if (value === this.extraCost) { + return; + } + + this.extraCost = value as number; + } + this.rebuildStreamer(); }, }); @@ -295,7 +289,7 @@ class WorkerStreamedTerrainScene extends Scene { const ty = Math.floor(this.explorer.y / TILE); this.hudTimer = 0; - this.hud.setStatus( + this.panel.status( `${this.providerMode} · ${this.frameIntervalMs.toFixed(1)} ms between frames · ${this.streamingMs.toFixed(1)} ms streaming on the main thread · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · seed ${this.seed} · cost ${this.extraCost}`, ); } @@ -306,6 +300,7 @@ class WorkerStreamedTerrainScene extends Scene { } override destroy(): void { + this.panel?.dispose(); this.streamer?.destroy(); this.workerSourceHandle?.destroy(); this.workerSourceHandle = null; diff --git a/examples/tweens-animation/easing-curves.js b/examples/tweens-animation/easing-curves.js index 2bb49d093..e8caa52a1 100644 --- a/examples/tweens-animation/easing-curves.js +++ b/examples/tweens-animation/easing-curves.js @@ -1,6 +1,7 @@ // Auto-generated from easing-curves.ts - edit the .ts source, not this file. import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel } from '@examples/runtime'; // Every built-in Ease function, in source order. const EASINGS = [ ['linear', Ease.linear], @@ -41,33 +42,26 @@ const V_MAX = 1.45; class EasingCurvesScene extends Scene { graphics = new Graphics(); sprite; - hud; - selected = 0; + panel; + ease = EASINGS[0][1]; t = 0; direction = 1; - onTap = pointer => this.select(pointer.x < this.app.width / 2 ? -1 : 1); init() { - 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(2); + this.panel = demoPanel({ + title: 'Easing', + controls: { + easing: choice( + 'Curve', + EASINGS.map(([name]) => name), + ), + }, + onChange: (_key, name) => { + this.ease = EASINGS.find(([easingName]) => easingName === name)[1]; + }, }); - this.refreshHud(); - } - select(direction) { - this.selected = (this.selected + direction + EASINGS.length) % EASINGS.length; - this.refreshHud(); - } - refreshHud() { - this.hud.setStatus(`${this.selected + 1} / ${EASINGS.length} · ${EASINGS[this.selected][0]}`); + this.inputs.onTrigger(Keyboard.Left, () => this.panel.step('easing', -1)); + this.inputs.onTrigger(Keyboard.Right, () => this.panel.step('easing', 1)); } update(delta) { this.t += this.direction * delta * 0.6; @@ -86,7 +80,7 @@ class EasingCurvesScene extends Scene { 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 ease = this.ease; const g = this.graphics; g.clear(); g.lineWidth = 2; @@ -106,8 +100,7 @@ class EasingCurvesScene extends Scene { context.render(this.sprite); } destroy() { - this.app.input.onPointerTap.remove(this.onTap); - this.hud.dispose(); + this.panel.dispose(); super.destroy(); } } @@ -120,6 +113,5 @@ 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 f55b9dfbe..cd165e8af 100644 --- a/examples/tweens-animation/easing-curves.ts +++ b/examples/tweens-animation/easing-curves.ts @@ -10,7 +10,8 @@ import { type Seconds, Sprite, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; +import { choice, demoPanel } from '@examples/runtime'; // Every built-in Ease function, in source order. const EASINGS: Array<[string, (t: number) => number]> = [ @@ -54,36 +55,27 @@ const V_MAX = 1.45; class EasingCurvesScene extends Scene { private graphics = new Graphics(); private sprite!: Sprite; - private hud!: ReturnType; - private selected = 0; + private panel!: ReturnType; + private ease = EASINGS[0][1]; private t = 0; private direction = 1; - private readonly onTap = (pointer: { x: number }): void => this.select(pointer.x < this.app.width / 2 ? -1 : 1); override init(): void { - 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setScale(2); + this.panel = demoPanel({ + title: 'Easing', + controls: { + easing: choice( + 'Curve', + EASINGS.map(([name]) => name), + ), + }, + onChange: (_key, name) => { + this.ease = EASINGS.find(([easingName]) => easingName === name)![1]; + }, }); - this.refreshHud(); - } - - private select(direction: number): void { - this.selected = (this.selected + direction + EASINGS.length) % EASINGS.length; - this.refreshHud(); - } - - private refreshHud(): void { - this.hud.setStatus(`${this.selected + 1} / ${EASINGS.length} · ${EASINGS[this.selected][0]}`); + this.inputs.onTrigger(Keyboard.Left, () => this.panel.step('easing', -1)); + this.inputs.onTrigger(Keyboard.Right, () => this.panel.step('easing', 1)); } override update(delta: Seconds): void { @@ -105,7 +97,7 @@ class EasingCurvesScene extends Scene { 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 ease = this.ease; const g = this.graphics; g.clear(); @@ -130,8 +122,7 @@ class EasingCurvesScene extends Scene { } override destroy(): void { - this.app.input.onPointerTap.remove(this.onTap); - this.hud.dispose(); + this.panel.dispose(); super.destroy(); } } @@ -145,7 +136,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/frame-animation.js b/examples/tweens-animation/frame-animation.js index e9937aa9a..64808d5b8 100644 --- a/examples/tweens-animation/frame-animation.js +++ b/examples/tweens-animation/frame-animation.js @@ -1,16 +1,14 @@ // Auto-generated from frame-animation.ts - edit the .ts source, not this file. -import { AnimatedSprite, Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Spritesheet } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { AnimatedSprite, Application, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Spritesheet, Text } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow']; const WALK_FPS = 8; class FrameAnimationScene extends Scene { - sprite; - hud; - characterIndex = 0; + sprites = []; playing = true; async load() { - const texture = this.loader.get('image/platformer-characters.png'); - const data = await this.loader.load(Asset.type('json', 'json/platformer-characters.json')); + const texture = this.loader.get(assets.platformerCharactersImage); + const data = await this.loader.load(assets.platformerCharactersAtlas); const sheet = new Spritesheet(texture, data); const clips = Object.fromEntries( CHARACTERS.map(character => [ @@ -21,45 +19,30 @@ class FrameAnimationScene extends Scene { }, ]), ); - this.sprite = new AnimatedSprite(texture, clips) - .setAnchor(0.5) - .setScale(3) - .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: '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.', + CHARACTERS.forEach((character, index) => { + const x = (this.app.width * (index + 0.5)) / CHARACTERS.length; + const sprite = new AnimatedSprite(texture, clips) + .setAnchor(0.5) + .setScale(1.6) + .setPosition(x, this.app.height / 2); + const label = new Text(character, { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0) + .setPosition(x, this.app.height / 2 + 130); + this.addChild(sprite); + this.addChild(label); + sprite.play(character); + this.sprites.push(sprite); }); this.inputs.onTrigger(Keyboard.Space, () => { this.playing = !this.playing; - if (this.playing) { - this.sprite.resume(); - } else { - this.sprite.pause(); + for (const sprite of this.sprites) { + if (this.playing) { + sprite.resume(); + } else { + 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'}`); - } - destroy() { - this.hud.dispose(); - super.destroy(); } } const app = new Application({ @@ -71,6 +54,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(FrameAnimationScene); diff --git a/examples/tweens-animation/frame-animation.ts b/examples/tweens-animation/frame-animation.ts index 3d246f9b5..dfa13aa1f 100644 --- a/examples/tweens-animation/frame-animation.ts +++ b/examples/tweens-animation/frame-animation.ts @@ -1,28 +1,26 @@ import { AnimatedSprite, Application, - Asset, Color, FixedResolutionCanvasSizing, Keyboard, Scene, Spritesheet, type SpritesheetData, + Text, } from '@codexo/exojs'; -import { mountControls } from '@examples/runtime'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow'] as const; const WALK_FPS = 8; class FrameAnimationScene extends Scene { - private sprite!: AnimatedSprite; - private hud!: ReturnType; - private characterIndex = 0; + private sprites: AnimatedSprite[] = []; private playing = true; override async load(): Promise { - 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 texture = this.loader.get(assets.platformerCharactersImage); + const data = (await this.loader.load(assets.platformerCharactersAtlas)) as SpritesheetData; const sheet = new Spritesheet(texture, data); const clips = Object.fromEntries( CHARACTERS.map(character => [ @@ -34,52 +32,33 @@ class FrameAnimationScene extends Scene { ]), ); - this.sprite = new AnimatedSprite(texture, clips) - .setAnchor(0.5) - .setScale(3) - .setPosition(this.app.width / 2, this.app.height / 2); - this.addChild(this.sprite); - this.sprite.onFrame.add((_clip, frame) => this.updateHud(frame)); + CHARACTERS.forEach((character, index) => { + const x = (this.app.width * (index + 0.5)) / CHARACTERS.length; + const sprite = new AnimatedSprite(texture, clips) + .setAnchor(0.5) + .setScale(1.6) + .setPosition(x, this.app.height / 2); + const label = new Text(character, { fillColor: Color.white, fontSize: 22, align: 'center' }) + .setAnchor(0.5, 0) + .setPosition(x, this.app.height / 2 + 130); - this.hud = mountControls({ - 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.addChild(sprite); + this.addChild(label); + sprite.play(character); + this.sprites.push(sprite); }); + this.inputs.onTrigger(Keyboard.Space, () => { this.playing = !this.playing; - if (this.playing) { - this.sprite.resume(); - } else { - this.sprite.pause(); + for (const sprite of this.sprites) { + if (this.playing) { + sprite.resume(); + } else { + sprite.pause(); + } } - - this.updateHud(this.sprite.currentFrame); }); - this.inputs.onTrigger(Keyboard.Right, () => this.selectCharacter()); - this.sprite.play(CHARACTERS[0]); - } - - private selectCharacter(): void { - this.characterIndex = (this.characterIndex + 1) % CHARACTERS.length; - this.sprite.play(CHARACTERS[this.characterIndex]); - - if (!this.playing) { - this.sprite.pause(); - } - } - - private updateHud(frame: number): void { - this.hud.setStatus(`${CHARACTERS[this.characterIndex]} · frame ${frame + 1}/2 · ${this.playing ? 'playing' : 'paused'}`); - } - - override destroy(): void { - this.hud.dispose(); - super.destroy(); } } @@ -92,7 +71,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(FrameAnimationScene); diff --git a/examples/tweens-animation/interrupt-and-replace.js b/examples/tweens-animation/interrupt-and-replace.js index 02fecb1c6..18ebed2e9 100644 --- a/examples/tweens-animation/interrupt-and-replace.js +++ b/examples/tweens-animation/interrupt-and-replace.js @@ -1,12 +1,13 @@ // Auto-generated from interrupt-and-replace.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class InterruptAndReplaceScene extends Scene { sprite; moveTween = null; init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); app.input.onPointerTap.add(pointer => { if (this.moveTween !== null) { this.moveTween.stop(); @@ -27,8 +28,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(InterruptAndReplaceScene); diff --git a/examples/tweens-animation/interrupt-and-replace.ts b/examples/tweens-animation/interrupt-and-replace.ts index 720a60a05..73e3b6e5e 100644 --- a/examples/tweens-animation/interrupt-and-replace.ts +++ b/examples/tweens-animation/interrupt-and-replace.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, type Tween } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class InterruptAndReplaceScene extends Scene { private sprite!: Sprite; @@ -8,7 +9,7 @@ class InterruptAndReplaceScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); app.input.onPointerTap.add(pointer => { if (this.moveTween !== null) { this.moveTween.stop(); @@ -32,9 +33,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(InterruptAndReplaceScene); diff --git a/examples/tweens-animation/tween-basics.js b/examples/tweens-animation/tween-basics.js index 21e161327..144a06e9d 100644 --- a/examples/tweens-animation/tween-basics.js +++ b/examples/tweens-animation/tween-basics.js @@ -1,5 +1,6 @@ // Auto-generated from tween-basics.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class InteractiveTweensScene extends Scene { sprite; @@ -10,16 +11,9 @@ class InteractiveTweensScene extends Scene { onPointerDown = (_pointer, x, y) => this.moveTo(x, y); init() { 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.sprite = new Sprite(this.loader.get(assets.ship)).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.hud = mountControls(); this.updateHud('Ready'); this.app.input.onPointerDown.add(this.onPointerDown); this.inputs.onTrigger(Keyboard.Space, () => { @@ -65,6 +59,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(InteractiveTweensScene); diff --git a/examples/tweens-animation/tween-basics.ts b/examples/tweens-animation/tween-basics.ts index 05885369c..b6b14fbe0 100644 --- a/examples/tweens-animation/tween-basics.ts +++ b/examples/tweens-animation/tween-basics.ts @@ -9,6 +9,7 @@ import { Sprite, type Tween, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; class InteractiveTweensScene extends Scene { @@ -21,16 +22,9 @@ class InteractiveTweensScene extends Scene { override init(): void { 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.sprite = new Sprite(this.loader.get(assets.ship)).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.hud = mountControls(); this.updateHud('Ready'); this.app.input.onPointerDown.add(this.onPointerDown); this.inputs.onTrigger(Keyboard.Space, () => { @@ -81,7 +75,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(InteractiveTweensScene); diff --git a/examples/tweens-animation/tween-chains.js b/examples/tweens-animation/tween-chains.js index 41b0ff7b2..58d13de3f 100644 --- a/examples/tweens-animation/tween-chains.js +++ b/examples/tweens-animation/tween-chains.js @@ -1,5 +1,6 @@ // Auto-generated from tween-chains.ts - edit the .ts source, not this file. import { Application, Color, Ease, FixedResolutionCanvasSizing, Graphics, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const WAYPOINTS = [ { x: 0.2, y: 0.3 }, @@ -21,12 +22,8 @@ class TweenSequencesScene extends Scene { 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(points[0].x, points[0].y); + this.hud = mountControls({ status: 'Leg 1 / 4' }); this.steps = points .slice(1) .concat(points[0]) @@ -57,6 +54,5 @@ const app = new Application({ scenes: { TweenSequencesScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(TweenSequencesScene); diff --git a/examples/tweens-animation/tween-chains.ts b/examples/tweens-animation/tween-chains.ts index 20d01b476..533338fff 100644 --- a/examples/tweens-animation/tween-chains.ts +++ b/examples/tweens-animation/tween-chains.ts @@ -9,6 +9,7 @@ import { Sprite, type Tween, } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; import { mountControls } from '@examples/runtime'; const WAYPOINTS = [ @@ -35,12 +36,8 @@ class TweenSequencesScene extends Scene { 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.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(points[0].x, points[0].y); + this.hud = mountControls({ status: 'Leg 1 / 4' }); this.steps = points .slice(1) @@ -77,7 +74,6 @@ const app = new Application({ scenes: { TweenSequencesScene }, canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() }, clearColor: Color.black, - loader: { basePath: 'assets/' }, }); await app.start(TweenSequencesScene); diff --git a/examples/tweens-animation/tween-from-array.js b/examples/tweens-animation/tween-from-array.js index 5b4cee3f0..4a106f4d2 100644 --- a/examples/tweens-animation/tween-from-array.js +++ b/examples/tweens-animation/tween-from-array.js @@ -1,5 +1,6 @@ // Auto-generated from tween-from-array.ts - edit the .ts source, not this file. import { Application, Color, Ease, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A closed loop of waypoints expressed as fractions of the canvas so the path // spreads across the wider 16:9 frame instead of staying in an 800×600 box. const waypointFractions = [ @@ -19,7 +20,7 @@ class TweenFromArrayScene extends Scene { const app = this.app; const { width, height } = app; this.waypoints = waypointFractions.map(({ fx, fy }) => ({ x: fx * width, y: fy * height })); - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); this.buildPath(); } buildPath() { @@ -55,8 +56,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TweenFromArrayScene); diff --git a/examples/tweens-animation/tween-from-array.ts b/examples/tweens-animation/tween-from-array.ts index 1fbfb5207..23042fb96 100644 --- a/examples/tweens-animation/tween-from-array.ts +++ b/examples/tweens-animation/tween-from-array.ts @@ -1,4 +1,5 @@ import { Application, Color, Ease, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, type Tween } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; // A closed loop of waypoints expressed as fractions of the canvas so the path // spreads across the wider 16:9 frame instead of staying in an 800×600 box. @@ -22,7 +23,7 @@ class TweenFromArrayScene extends Scene { const { width, height } = app; this.waypoints = waypointFractions.map(({ fx, fy }) => ({ x: fx * width, y: fy * height })); - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(this.waypoints[0].x, this.waypoints[0].y); this.buildPath(); } @@ -66,9 +67,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TweenFromArrayScene); diff --git a/examples/tweens-animation/tween-with-yoyo.js b/examples/tweens-animation/tween-with-yoyo.js index b0a24bd6e..82fd885b7 100644 --- a/examples/tweens-animation/tween-with-yoyo.js +++ b/examples/tweens-animation/tween-with-yoyo.js @@ -1,11 +1,12 @@ // Auto-generated from tween-with-yoyo.ts - edit the .ts source, not this file. import { Application, Color, FixedResolutionCanvasSizing, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TweenWithYoyoScene extends Scene { sprite; init() { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); app.tweens.create(this.sprite.scale).to({ x: 1.5, y: 1.5 }, 0.8).yoyo(true).repeat(-1).start(); app.tweens.create(this.sprite).to({ rotation: 20 }, 0.8).yoyo(true).repeat(-1).start(); } @@ -22,8 +23,5 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TweenWithYoyoScene); diff --git a/examples/tweens-animation/tween-with-yoyo.ts b/examples/tweens-animation/tween-with-yoyo.ts index cf812ddee..82c611743 100644 --- a/examples/tweens-animation/tween-with-yoyo.ts +++ b/examples/tweens-animation/tween-with-yoyo.ts @@ -1,4 +1,5 @@ import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; class TweenWithYoyoScene extends Scene { private sprite!: Sprite; @@ -7,7 +8,7 @@ class TweenWithYoyoScene extends Scene { const app = this.app; const { width, height } = app; - this.sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setPosition(width / 2, height / 2); + this.sprite = new Sprite(this.loader.get(assets.ship)).setAnchor(0.5).setPosition(width / 2, height / 2); app.tweens.create(this.sprite.scale).to({ x: 1.5, y: 1.5 }, 0.8).yoyo(true).repeat(-1).start(); app.tweens.create(this.sprite).to({ rotation: 20 }, 0.8).yoyo(true).repeat(-1).start(); } @@ -26,9 +27,6 @@ const app = new Application({ sizing: new FixedResolutionCanvasSizing(), }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); await app.start(TweenWithYoyoScene); diff --git a/examples/ui/settings-menu.js b/examples/ui/settings-menu.js index ffffd08f2..48cf78179 100644 --- a/examples/ui/settings-menu.js +++ b/examples/ui/settings-menu.js @@ -82,15 +82,20 @@ class SettingsMenuScene extends Scene { this.settings.volume = value; this.apply(); }); + // Discrete choices are logged to the Output panel; the volume slider is + // not, since it reports every step of a drag. this.motion.onChange.add(value => { this.settings.motion = value; this.apply(); + console.log(`Setting: reduced motion ${value ? 'on' : 'off'}`); }); this.theme.onChange.add(value => { this.settings.theme = value; this.apply(); + console.log(`Setting: theme ${value}`); }); reset.onClick.add(() => { + console.info('Setting: reset to defaults'); this.volume.value = defaults.volume; this.motion.checked = defaults.motion; this.theme.selectedIndex = 0; diff --git a/examples/ui/settings-menu.ts b/examples/ui/settings-menu.ts index 514698ad9..533f12951 100644 --- a/examples/ui/settings-menu.ts +++ b/examples/ui/settings-menu.ts @@ -97,15 +97,20 @@ class SettingsMenuScene extends Scene { this.settings.volume = value; this.apply(); }); + // Discrete choices are logged to the Output panel; the volume slider is + // not, since it reports every step of a drag. this.motion.onChange.add(value => { this.settings.motion = value; this.apply(); + console.log(`Setting: reduced motion ${value ? 'on' : 'off'}`); }); this.theme.onChange.add(value => { this.settings.theme = value; this.apply(); + console.log(`Setting: theme ${value}`); }); reset.onClick.add(() => { + console.info('Setting: reset to defaults'); this.volume.value = defaults.volume; this.motion.checked = defaults.motion; this.theme.selectedIndex = 0; diff --git a/package.json b/package.json index e9bfdc83d..c35c3ec2b 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,6 @@ "dist/exo.esm.js.map", "dist/exo.debug.esm.js", "dist/exo.debug.esm.js.map", - "dist/exo.iife.js", - "dist/exo.iife.js.map", - "dist/exo.iife.min.js", - "dist/exo.iife.min.js.map", - "dist/exo.full.iife.js", - "dist/exo.full.iife.js.map", - "dist/exo.full.iife.min.js", - "dist/exo.full.iife.min.js.map", "README.md", "CHANGELOG.md", "LICENSE" @@ -68,9 +60,49 @@ "import": "./dist/esm/debug/index.js", "default": "./dist/esm/debug/index.js" }, + "./tilemap": { + "types": "./dist/esm/tilemap/index.d.ts", + "import": "./dist/esm/tilemap/index.js", + "default": "./dist/esm/tilemap/index.js" + }, + "./particles": { + "types": "./dist/esm/particles/index.d.ts", + "import": "./dist/esm/particles/index.js", + "default": "./dist/esm/particles/index.js" + }, + "./audio-fx": { + "types": "./dist/esm/audio-fx/index.d.ts", + "import": "./dist/esm/audio-fx/index.js", + "default": "./dist/esm/audio-fx/index.js" + }, + "./physics": { + "types": "./dist/esm/physics/index.d.ts", + "import": "./dist/esm/physics/index.js", + "default": "./dist/esm/physics/index.js" + }, + "./tilemap-physics": { + "types": "./dist/esm/tilemap-physics/index.d.ts", + "import": "./dist/esm/tilemap-physics/index.js", + "default": "./dist/esm/tilemap-physics/index.js" + }, + "./lighting": { + "types": "./dist/esm/lighting/index.d.ts", + "import": "./dist/esm/lighting/index.js", + "default": "./dist/esm/lighting/index.js" + }, + "./pathfinding": { + "types": "./dist/esm/pathfinding/index.d.ts", + "import": "./dist/esm/pathfinding/index.js", + "default": "./dist/esm/pathfinding/index.js" + }, "./package.json": "./package.json" }, "imports": { + "#physics-debug": { + "@codexo/exojs-source": "./packages/exojs-physics/src/debug/index.ts", + "types": "./dist/esm/physics/debug/index.d.ts", + "default": "./dist/esm/physics/debug/index.js" + }, "#*.vert": "./src/*.vert", "#*.frag": "./src/*.frag", "#*.wgsl": "./src/*.wgsl", @@ -133,6 +165,8 @@ "test:alloc": "vitest run --project=rendering-alloc", "test:physics-perf": "vitest run --project=physics-perf", "test:production-stripping": "vitest run --project=exojs test/build-defines/production-stripping.test.ts", + "test:release-dist": "vitest run --project=release-dist", + "test:esm-importmap": "tsx scripts/ci/verify-esm-importmap.ts", "test:skips": "pnpm test --reporter=default --reporter=junit --outputFile.junit=./test-results/unit.junit.xml && tsx scripts/check-skipped-tests.ts", "test:skips:check": "tsx scripts/check-skipped-tests.ts", "test:skips:update-baseline": "tsx scripts/check-skipped-tests.ts --update-baseline", @@ -154,6 +188,7 @@ "test:parity:firefox": "tsx ./scripts/run-parity.ts --project=browser-webgpu-firefox test/rendering/parity", "test:parity:safari": "tsx ./scripts/run-parity.ts --project=browser-parity-safari", "test:examples:smoke": "pnpm --filter @codexo/exojs-examples examples:smoke", + "test:playground:e2e": "pnpm --filter @codexo/exojs-examples playground:e2e", "gates": "tsx ./scripts/ci/gates.ts", "lanes": "tsx ./scripts/lanes.ts", "qualify": "tsx ./scripts/ci/qualify.ts", @@ -174,7 +209,9 @@ "assets:compact": "tsx scripts/compact-example-assets.ts --write", "assets:compact:check": "tsx scripts/compact-example-assets.ts", "examples:sync:check": "tsx scripts/check-examples-sync.ts", - "full-bundle:exports:check": "tsx scripts/check-dist-fresh.ts && tsx scripts/check-full-bundle-exports.ts", + "legacy-shims:generate": "tsx scripts/release/legacy-shims.ts", + "legacy-shims:check": "tsx scripts/release/legacy-shims.ts --check", + "dist:check": "tsx scripts/check-dist-fresh.ts", "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", "site:build": "tsx scripts/check-dist-fresh.ts && pnpm --filter @codexo/exojs-examples build", "size": "size-limit", @@ -196,6 +233,7 @@ "release:publish": "tsx ./scripts/release/run.ts publish", "release:publish-independent": "tsx ./scripts/release/run.ts publish-independent", "release:bootstrap": "tsx ./scripts/release/run.ts bootstrap", + "release:registry-plan": "tsx ./scripts/release/run.ts registry-plan", "release:notes": "tsx ./scripts/generate-release-notes.ts", "release:changelog": "tsx ./scripts/release/generate-changelog.ts" }, @@ -218,42 +256,35 @@ "gzip": true }, { - "path": "dist/exo.iife.min.js", - "limit": "367 KB", - "gzip": true - }, - { - "path": "dist/exo.full.iife.min.js", - "limit": "647 KB", - "gzip": true - }, - { - "name": "@codexo/exojs-lighting", - "path": "packages/exojs-lighting/dist/esm/index.js", + "name": "@codexo/exojs/lighting", + "path": "dist/esm/lighting/index.js", "limit": "34 KB", "gzip": true, "ignore": [ - "@codexo/exojs" + "../index.js", + "../../index.js" ] }, { - "name": "@codexo/exojs-lighting (lightmap, no cascades)", - "path": "packages/exojs-lighting/dist/esm/index.js", + "name": "@codexo/exojs/lighting (lightmap, no cascades)", + "path": "dist/esm/lighting/index.js", "import": "{ LightmapLighting, PointLight, SpotLight, PolygonOccluder }", "limit": "14 KB", "gzip": true, "ignore": [ - "@codexo/exojs" + "../index.js", + "../../index.js" ] }, { - "name": "@codexo/exojs-lighting (forward, no occluder tracing)", - "path": "packages/exojs-lighting/dist/esm/index.js", + "name": "@codexo/exojs/lighting (forward, no occluder tracing)", + "path": "dist/esm/lighting/index.js", "import": "{ ForwardLighting, PointLight, SpotLight, LitMaterial, NormalMap }", "limit": "5 KB", "gzip": true, "ignore": [ - "@codexo/exojs" + "../index.js", + "../../index.js" ] }, { diff --git a/packages/create-exo-app/templates/audio-reactive/package.json b/packages/create-exo-app/templates/audio-reactive/package.json index 334853628..b92272227 100644 --- a/packages/create-exo-app/templates/audio-reactive/package.json +++ b/packages/create-exo-app/templates/audio-reactive/package.json @@ -11,8 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@codexo/exojs": "lockstep", - "@codexo/exojs-audio-fx": "lockstep" + "@codexo/exojs": "lockstep" }, "devDependencies": { "typescript": "^7.0.2", diff --git a/packages/create-exo-app/templates/audio-reactive/src/scenes/AudioReactiveScene.ts b/packages/create-exo-app/templates/audio-reactive/src/scenes/AudioReactiveScene.ts index 904ce98cb..bfe9f6d0f 100644 --- a/packages/create-exo-app/templates/audio-reactive/src/scenes/AudioReactiveScene.ts +++ b/packages/create-exo-app/templates/audio-reactive/src/scenes/AudioReactiveScene.ts @@ -1,6 +1,6 @@ import type { RenderingContext } from '@codexo/exojs'; import { Color, getAudioContext, Graphics, Scene, Text } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; const BAR_COUNT = 32; diff --git a/packages/create-exo-app/templates/platformer/package.json b/packages/create-exo-app/templates/platformer/package.json index 05e791dfe..764950fe6 100644 --- a/packages/create-exo-app/templates/platformer/package.json +++ b/packages/create-exo-app/templates/platformer/package.json @@ -11,8 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@codexo/exojs": "lockstep", - "@codexo/exojs-physics": "lockstep" + "@codexo/exojs": "lockstep" }, "devDependencies": { "typescript": "^7.0.2", diff --git a/packages/create-exo-app/templates/platformer/src/objects/Player.ts b/packages/create-exo-app/templates/platformer/src/objects/Player.ts index 4039d55e2..6d3060665 100644 --- a/packages/create-exo-app/templates/platformer/src/objects/Player.ts +++ b/packages/create-exo-app/templates/platformer/src/objects/Player.ts @@ -1,7 +1,7 @@ import type { Seconds, Spritesheet } from '@codexo/exojs'; import { type Sprite } from '@codexo/exojs'; -import type { PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; -import { BoxShape } from '@codexo/exojs-physics'; +import type { PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; +import { BoxShape } from '@codexo/exojs/physics'; const WIDTH = 56; const HEIGHT = 84; diff --git a/packages/create-exo-app/templates/platformer/src/scenes/PlatformerScene.ts b/packages/create-exo-app/templates/platformer/src/scenes/PlatformerScene.ts index 8e84184b3..fb522594d 100644 --- a/packages/create-exo-app/templates/platformer/src/scenes/PlatformerScene.ts +++ b/packages/create-exo-app/templates/platformer/src/scenes/PlatformerScene.ts @@ -14,7 +14,7 @@ import { SystemOrder, View, } from '@codexo/exojs'; -import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs/physics'; import { Player } from '../objects/Player'; diff --git a/packages/create-exo-app/templates/top-down/package.json b/packages/create-exo-app/templates/top-down/package.json index c3abaaff2..888eba091 100644 --- a/packages/create-exo-app/templates/top-down/package.json +++ b/packages/create-exo-app/templates/top-down/package.json @@ -12,11 +12,7 @@ }, "dependencies": { "@codexo/exojs": "lockstep", - "@codexo/exojs-pathfinding": "lockstep", - "@codexo/exojs-physics": "lockstep", - "@codexo/exojs-tiled": "lockstep", - "@codexo/exojs-tilemap": "lockstep", - "@codexo/exojs-tilemap-physics": "lockstep" + "@codexo/exojs-tiled": "lockstep" }, "devDependencies": { "typescript": "^7.0.2", diff --git a/packages/create-exo-app/templates/top-down/src/actor.ts b/packages/create-exo-app/templates/top-down/src/actor.ts index 1d02f13e9..0003e229f 100644 --- a/packages/create-exo-app/templates/top-down/src/actor.ts +++ b/packages/create-exo-app/templates/top-down/src/actor.ts @@ -1,7 +1,7 @@ import { Container, type PointLike, Vector } from '@codexo/exojs'; -import { NavigationAgentComponent, type TileNavigationSpace } from '@codexo/exojs-pathfinding'; -import { CircleShape, KinematicMovementComponent, type PhysicsBody, PhysicsBodyComponent, type PhysicsWorld } from '@codexo/exojs-physics'; -import type { ResolvedTile } from '@codexo/exojs-tilemap'; +import { NavigationAgentComponent, type TileNavigationSpace } from '@codexo/exojs/pathfinding'; +import { CircleShape, KinematicMovementComponent, type PhysicsBody, PhysicsBodyComponent, type PhysicsWorld } from '@codexo/exojs/physics'; +import type { ResolvedTile } from '@codexo/exojs/tilemap'; export interface ActorOptions { /** Where the actor's centre starts, in world pixels. */ diff --git a/packages/create-exo-app/templates/top-down/src/scenes/ProceduralMapScene.ts b/packages/create-exo-app/templates/top-down/src/scenes/ProceduralMapScene.ts index dc0d8de40..8d811e7fa 100644 --- a/packages/create-exo-app/templates/top-down/src/scenes/ProceduralMapScene.ts +++ b/packages/create-exo-app/templates/top-down/src/scenes/ProceduralMapScene.ts @@ -1,5 +1,5 @@ import { Asset, TextureRegion } from '@codexo/exojs'; -import { ObjectKind, ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, type TileMapObject, TileSet } from '@codexo/exojs-tilemap'; +import { ObjectKind, ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, type TileMapObject, TileSet } from '@codexo/exojs/tilemap'; import { cellAt, COLUMNS, DOOR_TILE, findMarker, FLOOR_TILE, ROWS, TILE, WALL_TILE } from '../level'; import { TopDownScene } from './TopDownScene'; diff --git a/packages/create-exo-app/templates/top-down/src/scenes/TiledMapScene.ts b/packages/create-exo-app/templates/top-down/src/scenes/TiledMapScene.ts index 4d13bf82f..219b5b468 100644 --- a/packages/create-exo-app/templates/top-down/src/scenes/TiledMapScene.ts +++ b/packages/create-exo-app/templates/top-down/src/scenes/TiledMapScene.ts @@ -1,5 +1,5 @@ import { Asset } from '@codexo/exojs'; -import type { TileMap } from '@codexo/exojs-tilemap'; +import type { TileMap } from '@codexo/exojs/tilemap'; import { TopDownScene } from './TopDownScene'; diff --git a/packages/create-exo-app/templates/top-down/src/scenes/TopDownScene.ts b/packages/create-exo-app/templates/top-down/src/scenes/TopDownScene.ts index 42d5a3802..bd38e6524 100644 --- a/packages/create-exo-app/templates/top-down/src/scenes/TopDownScene.ts +++ b/packages/create-exo-app/templates/top-down/src/scenes/TopDownScene.ts @@ -17,10 +17,10 @@ import { VectorAction, View, } from '@codexo/exojs'; -import { type NavigationStatus, TileNavigationSpace } from '@codexo/exojs-pathfinding'; -import { BoxShape, PhysicsWorld } from '@codexo/exojs-physics'; -import { type ResolvedTile, type TileLayer, type TileMap, TileMapNode } from '@codexo/exojs-tilemap'; -import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics'; +import { type NavigationStatus, TileNavigationSpace } from '@codexo/exojs/pathfinding'; +import { BoxShape, PhysicsWorld } from '@codexo/exojs/physics'; +import { type ResolvedTile, type TileLayer, type TileMap, TileMapNode } from '@codexo/exojs/tilemap'; +import { TileColliderStreamer } from '@codexo/exojs/tilemap-physics'; import { type Actor, type ActorOptions, spawnActor } from '../actor'; diff --git a/packages/exojs-audio-fx/README.md b/packages/exojs-audio-fx/README.md index 114d4d3a4..d6c354940 100644 --- a/packages/exojs-audio-fx/README.md +++ b/packages/exojs-audio-fx/README.md @@ -16,7 +16,7 @@ This helper expects an initialized application with audio already playing on its ```ts import type { Application } from '@codexo/exojs'; -import { AudioAnalyser } from '@codexo/exojs-audio-fx'; +import { AudioAnalyser } from '@codexo/exojs/audio-fx'; export const inspectMusic = (app: Application): { read: () => Uint8Array; destroy: () => void } => { const analyser = new AudioAnalyser({ source: app.audio.music, fftSize: 1024 }); diff --git a/packages/exojs-audio-fx/docs/pcm-streaming.md b/packages/exojs-audio-fx/docs/pcm-streaming.md index db0b70ba0..654449b3f 100644 --- a/packages/exojs-audio-fx/docs/pcm-streaming.md +++ b/packages/exojs-audio-fx/docs/pcm-streaming.md @@ -4,7 +4,7 @@ ```ts import { getAudioContext } from '@codexo/exojs'; -import { PcmStreamSource } from '@codexo/exojs-audio-fx'; +import { PcmStreamSource } from '@codexo/exojs/audio-fx'; const stream = new PcmStreamSource({ channels: 2, capacityFrames: 8192, bus: app.audio.music }); await stream.ready; diff --git a/packages/exojs-audio-fx/package.json b/packages/exojs-audio-fx/package.json index 14773962a..c4b3a2ea0 100644 --- a/packages/exojs-audio-fx/package.json +++ b/packages/exojs-audio-fx/package.json @@ -1,46 +1,18 @@ { - "name": "@codexo/exojs-audio-fx", - "version": "0.19.1", - "description": "Audio effects, DSP, beat detection and analysis for ExoJS.", - "repository": { - "type": "git", - "url": "git+https://github.com/Exoridus/ExoJS.git", - "directory": "packages/exojs-audio-fx" - }, + "name": "@codexo/exojs-module-audio-fx", + "version": "0.0.0", + "private": true, + "description": "Audio effects, DSP, beat detection and analysis for ExoJS. Source of the @codexo/exojs/audio-fx subpath; built into and shipped with the Core package, never published on its own.", "type": "module", "sideEffects": false, - "main": "./dist/esm/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/esm/index.d.ts", - "exports": { - ".": { - "types": "./dist/esm/index.d.ts", - "import": "./dist/esm/index.js", - "default": "./dist/esm/index.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "dist/esm/", - "README.md", - "LICENSE" - ], "scripts": { - "build": "tsx ../../scripts/build-extension.ts", - "build:dev": "tsx ../../scripts/build-extension.ts --dev", "typecheck": "exo-tsc --noEmit && exo-tsc --noEmit -p tsconfig.test.json && exo-tsc --noEmit -p tsconfig.worklets.json", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", "test": "vitest run --root ../.. --project=exojs-audio-fx" }, - "peerDependencies": { - "@codexo/exojs": "0.19.x" - }, "devDependencies": { "@codexo/exojs": "workspace:*", "@codexo/exojs-config": "workspace:*" }, - "license": "MIT", - "publishConfig": { - "access": "public" - } + "license": "MIT" } diff --git a/packages/exojs-audio-fx/rolldown.config.ts b/packages/exojs-audio-fx/rolldown.config.ts index 598217d0f..6c7cf50f5 100644 --- a/packages/exojs-audio-fx/rolldown.config.ts +++ b/packages/exojs-audio-fx/rolldown.config.ts @@ -1,9 +1,8 @@ -import { createExtensionBuildOptions } from '@codexo/exojs-config/rolldown'; +import { defineRuntimeModule } from '@codexo/exojs-config/rolldown'; -// Audio-fx is a library package (no `/register`): the effects, DSP, BeatDetector -// and AudioAnalyser. Modules reference each other relatively, so no package -// source condition is required. -export default createExtensionBuildOptions({ +// The effects, DSP, BeatDetector and AudioAnalyser. Modules reference each +// other relatively, so no package source condition is required. +export default defineRuntimeModule({ root: import.meta.dirname, sourceCondition: null, inputs: ['src/index.ts'], diff --git a/packages/exojs-audio-fx/tsconfig.build.json b/packages/exojs-audio-fx/tsconfig.build.json deleted file mode 100644 index b7937ed84..000000000 --- a/packages/exojs-audio-fx/tsconfig.build.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "_comment": "Declaration emit for the Rolldown build (`rolldown.config.ts`), which has no declaration emitter. `paths` resolves the public `@codexo/exojs*` specifiers this package's source imports against Core's built declarations (not source, unlike tsconfig.json's own paths, which back the regular source-mode typecheck). Worklet sources stay excluded (inherited from tsconfig.json) - see that file's comment.", - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "customConditions": [], - "paths": { - "@codexo/exojs": ["../../dist/esm/index.d.ts"], - "@codexo/exojs/extensions": ["../../dist/esm/extensions/index.d.ts"], - "@codexo/exojs/renderer-sdk": ["../../dist/esm/renderer-sdk.d.ts"], - "@codexo/exojs/debug": ["../../dist/esm/debug/index.d.ts"] - } - } -} diff --git a/packages/exojs-audio-fx/tsconfig.json b/packages/exojs-audio-fx/tsconfig.json index 1204c18f7..265b8a383 100644 --- a/packages/exojs-audio-fx/tsconfig.json +++ b/packages/exojs-audio-fx/tsconfig.json @@ -1,13 +1,7 @@ { - "extends": "@codexo/exojs-config/typescript/extension.json", + "extends": ["@codexo/exojs-config/typescript/extension.json", "@codexo/exojs-config/typescript/workspace-paths.json"], "compilerOptions": { - "customConditions": ["@codexo/exojs-source"], - "paths": { - "@codexo/exojs": ["../../src/index.ts"], - "@codexo/exojs/extensions": ["../../src/extensions/index.ts"], - "@codexo/exojs/renderer-sdk": ["../../src/renderer-sdk.ts"], - "@codexo/exojs/debug": ["../../src/debug/index.ts"] - } + "customConditions": ["@codexo/exojs-source"] }, "include": ["src/**/*", "src/typings.d.ts", "../../src/typings.d.ts"], // The `*.worklet.ts` sources typecheck against the AudioWorkletGlobalScope diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index 78b9a8b19..a36af20e0 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -21,10 +21,6 @@ "devDependencies": { "@codexo/exojs": "workspace:*", "@codexo/exojs-config": "workspace:*", - "@codexo/exojs-lighting": "workspace:*", - "@codexo/exojs-particles": "workspace:*", - "@codexo/exojs-physics": "workspace:*", - "@codexo/exojs-tilemap": "workspace:*", "@types/node": "^26.6.2", "@webgpu/types": "^0.1.74", "playwright": "^1.63.0", diff --git a/packages/exojs-bench/src/physics/adapters/exojs-physics.ts b/packages/exojs-bench/src/physics/adapters/exojs-physics.ts index 8986fbc17..4c15ce6a2 100644 --- a/packages/exojs-bench/src/physics/adapters/exojs-physics.ts +++ b/packages/exojs-bench/src/physics/adapters/exojs-physics.ts @@ -1,4 +1,4 @@ -import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs/physics'; import type { PhysicsAdapter, diff --git a/packages/exojs-bench/src/physics/driver.ts b/packages/exojs-bench/src/physics/driver.ts index 85d080679..068e4b564 100644 --- a/packages/exojs-bench/src/physics/driver.ts +++ b/packages/exojs-bench/src/physics/driver.ts @@ -28,7 +28,7 @@ import type { PhysicsArmReport, PhysicsClockReport } from './page/contract'; import type { PhysicsCellResult, PhysicsCellSpec } from './PhysicsAdapter'; /** npm package name of the native physics arm, resolved for version provenance. */ -const NATIVE_PHYSICS_PACKAGE = '@codexo/exojs-physics'; +const NATIVE_PHYSICS_PACKAGE = '@codexo/exojs/physics'; const HERE = dirname(fileURLToPath(import.meta.url)); const PAGE_DIR = resolve(HERE, 'page'); diff --git a/packages/exojs-bench/src/physics/page/harness.ts b/packages/exojs-bench/src/physics/page/harness.ts index 5e88016d2..d1d0dd8cd 100644 --- a/packages/exojs-bench/src/physics/page/harness.ts +++ b/packages/exojs-bench/src/physics/page/harness.ts @@ -102,7 +102,7 @@ const ARM_FACTORIES: ReadonlyArray<{ { engine: 'exojs-physics', config: 'native', - library: '@codexo/exojs-physics', + library: '@codexo/exojs/physics', create: async (): Promise => { const module = await import('../adapters/exojs-physics'); diff --git a/packages/exojs-bench/src/rendering/adapters/exojs.ts b/packages/exojs-bench/src/rendering/adapters/exojs.ts index 866626f9a..a59773ffb 100644 --- a/packages/exojs-bench/src/rendering/adapters/exojs.ts +++ b/packages/exojs-bench/src/rendering/adapters/exojs.ts @@ -1,6 +1,6 @@ -import { type Lighting, LightmapLighting, PointLight, PolygonOccluder } from '@codexo/exojs-lighting'; -import { AlphaFadeOverLifetime, Curve, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; -import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; +import { type Lighting, LightmapLighting, PointLight, PolygonOccluder } from '@codexo/exojs/lighting'; +import { AlphaFadeOverLifetime, Curve, particlesExtension, ParticleSystem } from '@codexo/exojs/particles'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs/tilemap'; import { Application } from '#core/Application'; import { Color } from '#core/Color'; diff --git a/packages/exojs-bench/src/shared/viteServer.ts b/packages/exojs-bench/src/shared/viteServer.ts index 338a456e9..b847b247c 100644 --- a/packages/exojs-bench/src/shared/viteServer.ts +++ b/packages/exojs-bench/src/shared/viteServer.ts @@ -33,9 +33,10 @@ const ENGINE_SRC = resolve(REPO_ROOT, 'src'); * silently kept its default capacity instead of the one the cell asked for. */ const SOURCE_PACKAGE_ALIASES: ReadonlyArray<{ find: string; replacement: string }> = [ - { find: '@codexo/exojs-lighting', replacement: resolve(REPO_ROOT, 'packages/exojs-lighting/src/index.ts') }, - { find: '@codexo/exojs-particles', replacement: resolve(REPO_ROOT, 'packages/exojs-particles/src/index.ts') }, - { find: '@codexo/exojs-tilemap', replacement: resolve(REPO_ROOT, 'packages/exojs-tilemap/src/index.ts') }, + { find: '@codexo/exojs/lighting', replacement: resolve(REPO_ROOT, 'packages/exojs-lighting/src/index.ts') }, + { find: '@codexo/exojs/particles', replacement: resolve(REPO_ROOT, 'packages/exojs-particles/src/index.ts') }, + { find: '@codexo/exojs/tilemap', replacement: resolve(REPO_ROOT, 'packages/exojs-tilemap/src/index.ts') }, + { find: '@codexo/exojs/physics', replacement: resolve(REPO_ROOT, 'packages/exojs-physics/src/index.ts') }, { find: '@codexo/exojs/renderer-sdk', replacement: resolve(ENGINE_SRC, 'renderer-sdk.ts') }, { find: '@codexo/exojs/extensions', replacement: resolve(ENGINE_SRC, 'extensions/index.ts') }, { find: '@codexo/exojs', replacement: resolve(ENGINE_SRC, 'index.ts') }, diff --git a/packages/exojs-bench/tsconfig.json b/packages/exojs-bench/tsconfig.json index 675d97040..86a4fe7c2 100644 --- a/packages/exojs-bench/tsconfig.json +++ b/packages/exojs-bench/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs-physics` package (which itself imports the `@codexo/exojs` barrel), and the rendering extension arms import `@codexo/exojs-particles`, `@codexo/exojs-tilemap` and `@codexo/exojs-lighting`; none of those expose a `@codexo/exojs-source` export condition, and their package entries point at `dist`, which the type-check runs without, so their entries are mapped to source here - an arm that imports a package missing from this map type-checks locally off a stale `dist` and only fails in CI, where there is none; `@codexo/exojs-particles-source` is in `customConditions` for the same reason one level down, since `@codexo/exojs-particles` resolves its own `#*` imports through a package `imports` map that otherwise lands on stale `dist` declarations — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", + "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs/physics` subpath (which itself imports the `@codexo/exojs` barrel), and the rendering extension arms import `@codexo/exojs/particles`, `@codexo/exojs/tilemap` and `@codexo/exojs/lighting`; the Core exports map has no source condition, and its entries point at `dist`, which the type-check runs without, so their entries are mapped to source here - an arm that imports a package missing from this map type-checks locally off a stale `dist` and only fails in CI, where there is none; `@codexo/exojs-particles-source` is in `customConditions` for the same reason one level down, since the particles sources resolve their own `#*` imports through a package `imports` map that otherwise lands on stale `dist` declarations — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", "extends": "@codexo/exojs-config/typescript/test.json", "compilerOptions": { "noEmit": true, @@ -8,10 +8,10 @@ "types": ["vitest/globals", "node", "@webgpu/types"], "paths": { "#*": ["../../src/*"], - "@codexo/exojs-lighting": ["../exojs-lighting/src/index.ts"], - "@codexo/exojs-particles": ["../exojs-particles/src/index.ts"], - "@codexo/exojs-physics": ["../exojs-physics/src/index.ts"], - "@codexo/exojs-tilemap": ["../exojs-tilemap/src/index.ts"], + "@codexo/exojs/lighting": ["../exojs-lighting/src/index.ts"], + "@codexo/exojs/particles": ["../exojs-particles/src/index.ts"], + "@codexo/exojs/physics": ["../exojs-physics/src/index.ts"], + "@codexo/exojs/tilemap": ["../exojs-tilemap/src/index.ts"], "@codexo/exojs": ["../../src/index.ts"], "@codexo/exojs/extensions": ["../../src/extensions/index.ts"], "@codexo/exojs/renderer-sdk": ["../../src/renderer-sdk.ts"], diff --git a/packages/exojs-config/package-policy/index.js b/packages/exojs-config/package-policy/index.js index 304e450f8..90b7a68e1 100644 --- a/packages/exojs-config/package-policy/index.js +++ b/packages/exojs-config/package-policy/index.js @@ -195,6 +195,128 @@ export function verifyCliPackage(dir, opts) { return { ok: checks.every(c => c.ok), checks }; } +/** + * Verify a legacy shim: a published package that keeps a retired name + * importable by forwarding to Core subpaths. + * + * A shim must not be able to carry a second implementation or reach a consumer + * with anything but Core: it ships its forwarder files and nothing else, + * depends on nothing, peers on `@codexo/exojs` alone and runs no lifecycle + * script. Every file it ships is a statement of the form + * `export ... from '@codexo/exojs/'`. + * @param {string} dir + * @param {{ name: string, targets: readonly string[] }} opts the Core specifiers the shim may forward to + * @returns {PolicyResult} + */ +export function verifyLegacyShimPackage(dir, opts) { + const pkg = read(dir); + /** @type {PolicyCheck[]} */ + const checks = []; + /** @type {(name: string, cond: unknown, detail?: string) => number} */ + const ok = (name, cond, detail) => checks.push({ name, ok: Boolean(cond), detail }); + /** @type {string[]} */ + const files = Array.isArray(pkg.files) ? pkg.files : []; + const code = files.filter(file => /\.(?:js|d\.ts)$/.test(file)); + const forwarder = /^export (?:\*|\{[^}]+\}) from '([^']+)';$/; + + ok('name matches', pkg.name === opts.name, `${pkg.name} vs ${opts.name}`); + ok('type: module', pkg.type === 'module'); + ok('not private', pkg.private !== true); + ok('has version', typeof pkg.version === 'string'); + ok('has repository field', pkg.repository != null); + ok('publishConfig public', pkg.publishConfig?.access === 'public'); + ok('library sideEffects: false', pkg.sideEffects === false); + ok('exports "." entry', pkg.exports?.['.']?.types && pkg.exports['.'].import); + ok('exports ./package.json', pkg.exports?.['./package.json'] === './package.json'); + ok('ships LICENSE and README', files.includes('LICENSE') && files.includes('README.md')); + ok('LICENSE file present', existsSync(join(dir, 'LICENSE'))); + ok('README present', existsSync(join(dir, 'README.md'))); + ok('ships forwarders only', files.length === code.length + 2, files.join(', ')); + ok('peer @codexo/exojs only', JSON.stringify(Object.keys(pkg.peerDependencies ?? {})) === '["@codexo/exojs"]'); + ok('no dependencies', !pkg.dependencies && !pkg.optionalDependencies && !pkg.bundleDependencies && !pkg.bundledDependencies); + ok('no lifecycle scripts', !pkg.scripts || Object.keys(pkg.scripts).length === 0); + ok('no imports map', pkg.imports === undefined); + + for (const file of code) { + const path = join(dir, file); + const lines = existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n') : []; + const targets = lines.map(line => forwarder.exec(line)?.[1]); + ok( + `${file} only forwards to Core`, + lines.length > 0 && targets.every(target => target !== undefined && opts.targets.includes(target)), + targets.join(', '), + ); + } + + return { ok: checks.every(c => c.ok), checks }; +} + +/** + * Verify a published content package (`@codexo/exojs-demo-assets`). + * + * Content ships data plus small descriptor factories, not engine code. It is + * judged against the same publish contract as a runtime package, except that + * its compiled tree lives under `dist/` (the default instance addresses its + * media with `new URL('../media/...', import.meta.url)`, which has to resolve + * from `dist/` as well as from `src/`), and it must not carry engine + * lifecycle scripts: installing it may copy nothing and run nothing. + * + * @param {string} dir + * @param {{ name: string }} opts + * @returns {PolicyResult} + */ +export function verifyContentPackage(dir, opts) { + const pkg = read(dir); + /** @type {PolicyCheck[]} */ + const checks = []; + /** @type {(name: string, cond: unknown, detail?: string) => number} */ + const ok = (name, cond, detail) => checks.push({ name, ok: Boolean(cond), detail }); + + ok('name matches', pkg.name === opts.name, `${pkg.name} vs ${opts.name}`); + ok('type: module', pkg.type === 'module'); + ok('not private', pkg.private !== true); + ok('has version', typeof pkg.version === 'string'); + ok('exports map', pkg.exports && typeof pkg.exports === 'object'); + ok('exports "." entry', pkg.exports?.['.']?.types && pkg.exports['.'].import); + ok('exports ./extended entry', pkg.exports?.['./extended']?.types && pkg.exports['./extended'].import); + ok('exports ./manifest.json', pkg.exports?.['./manifest.json'] === './manifest.json'); + ok('exports ./media/*', pkg.exports?.['./media/*'] === './media/*'); + ok('exports ./package.json', pkg.exports?.['./package.json'] === './package.json'); + ok('files allowlist', Array.isArray(pkg.files) && pkg.files.length > 0); + ok( + 'files ship dist', + /** @type {string[]} */ (pkg.files ?? []).some(f => f.includes('dist')), + ); + ok('files ship media', (pkg.files ?? []).includes('media/')); + ok('files ship manifest.json', (pkg.files ?? []).includes('manifest.json')); + ok('files ship THIRD_PARTY_NOTICES.md', (pkg.files ?? []).includes('THIRD_PARTY_NOTICES.md')); + ok('ships LICENSE', (pkg.files ?? []).includes('LICENSE')); + ok('publishConfig public', pkg.publishConfig?.access === 'public'); + ok('LICENSE file present', existsSync(join(dir, 'LICENSE'))); + ok('README present', existsSync(join(dir, 'README.md'))); + ok('has repository field', pkg.repository != null); + ok('has copy bin', pkg.bin != null && typeof pkg.bin === 'object' && 'exojs-demo-assets-copy' in pkg.bin); + // Content is imported by a running engine, so it declares the engine as a + // peer - but never as a production dependency, which would install its own + // engine copy. + ok('peer @codexo/exojs', Boolean(pkg.peerDependencies?.['@codexo/exojs'])); + ok('no production deps', !pkg.dependencies || Object.keys(pkg.dependencies).length === 0); + // No install-time behavior: install scripts and pre/post lifecycle hooks + // would run on every consumer install and break the no-IO import contract. + ok( + 'no install scripts', + pkg.scripts?.postinstall === undefined && pkg.scripts?.preinstall === undefined && pkg.scripts?.install === undefined, + ); + ok('library sideEffects: false', pkg.sideEffects === false); + + const exportTargets = JSON.stringify(pkg.exports ?? {}); + ok('no raw .ts runtime entry', !/\.ts"/.test(exportTargets.replace(/\.d\.ts"/g, ''))); + ok('no @/ alias in manifest', !JSON.stringify(pkg).includes('"@/')); + ok('no workspace: in deps', !JSON.stringify(pkg.dependencies ?? {}).includes('workspace:')); + + return { ok: checks.every(c => c.ok), checks }; +} + /** * Verify the private shared-config package. * @param {string} dir diff --git a/packages/exojs-config/package.json b/packages/exojs-config/package.json index a244e72a9..f25304bfe 100644 --- a/packages/exojs-config/package.json +++ b/packages/exojs-config/package.json @@ -28,7 +28,8 @@ "./typescript/extension.json": "./typescript/extension.json", "./typescript/test.json": "./typescript/test.json", "./typescript/package-test.json": "./typescript/package-test.json", - "./typescript/worker.json": "./typescript/worker.json" + "./typescript/worker.json": "./typescript/worker.json", + "./typescript/workspace-paths.json": "./typescript/workspace-paths.json" }, "dependencies": { "@codecov/rollup-plugin": "^2.0.1", diff --git a/packages/exojs-config/rolldown/index.js b/packages/exojs-config/rolldown/index.js index 59725aef9..cffd44a17 100644 --- a/packages/exojs-config/rolldown/index.js +++ b/packages/exojs-config/rolldown/index.js @@ -1,10 +1,11 @@ -// Shared Rolldown build for official ExoJS extension packages: the -// preserveModules ESM tree, Core externalization, source maps, build-constant -// replacement, GLSL string imports, and package-private `#` resolution. No -// `typescript()` transform - Rolldown transpiles TypeScript natively, with no -// single-Program rootDir constraint. Declarations are a separate step, driven -// by `scripts/build-extension.ts` against the package's own -// `tsconfig.build.json`, since Rolldown has no declaration emitter. +// Shared Rolldown builds for the official ExoJS packages: the preserveModules +// ESM tree, Core externalization, source maps, build-constant replacement, GLSL +// string imports, and package-private `#` resolution. No `typescript()` +// transform - Rolldown transpiles TypeScript natively, with no single-Program +// rootDir constraint. Declarations are a separate step, since Rolldown has no +// declaration emitter: adapters emit theirs from `scripts/build-extension.ts` +// against the package's own `tsconfig.build.json`, runtime modules from the +// Core build's single declaration program. import { readFileSync } from 'node:fs'; import { dirname, relative, resolve } from 'node:path'; @@ -35,27 +36,29 @@ function preservedModuleNaming(sourceRoot) { } /** - * @param {{ root: string, sourceCondition: string | null, inputs?: string[], external?: string[] }} opts - * Same contract as `createExtensionConfig` in `../rollup/index.js`. - * @returns {import('rolldown').RolldownOptions} + * @typedef {object} TreeOptions + * @property {string} root package directory the sources and inputs are relative to + * @property {string | null} sourceCondition package-private `#` source condition, if the package has `#` imports + * @property {string[]} inputs entry modules, relative to `root` + * @property {string} outDir output directory, absolute or relative to `root` + * @property {Record} defines build-constant replacements + * @property {string} bundleName Codecov bundle name + * @property {(id: string) => boolean} [external] + * @property {import('rolldown').Plugin[]} [plugins] plugins that run before the shared ones */ -export function createExtensionBuildOptions(opts) { - const { root, sourceCondition, inputs = ['src/index.ts'], external = [] } = opts; - const defines = createBuildDefinesFromRepo({ - mode: process.env.EXOJS_ENV === 'development' ? 'development' : 'production', - packageDir: root, - }); - - /** @param {string} id */ - const isExternal = id => id.startsWith('@codexo/exojs') || external.some(name => id === name || id.startsWith(`${name}/`)); +/** + * @param {TreeOptions} opts + * @returns {import('rolldown').RolldownOptions} + */ +function createPreservedTreeOptions(opts) { + const { root, sourceCondition, inputs, outDir, defines, bundleName, external, plugins = [] } = opts; - const packageName = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).name ?? 'extension'; const codecovPlugins = process.env.CODECOV_TOKEN ? [ codecovRollupPlugin({ enableBundleAnalysis: true, - bundleName: packageName.replace(/^@codexo\//, ''), + bundleName, uploadToken: process.env.CODECOV_TOKEN, telemetry: false, }), @@ -65,7 +68,7 @@ export function createExtensionBuildOptions(opts) { return { cwd: root, input: inputs, - external: isExternal, + ...(external ? { external } : {}), transform: { define: defines }, resolve: sourceCondition ? { conditionNames: [sourceCondition, 'browser', 'module', 'import', 'default'], extensions: ['.ts', '.js'] } @@ -73,9 +76,9 @@ export function createExtensionBuildOptions(opts) { // Shader text ships inside string literals, which no JavaScript minifier // descends into, so its comments are payload every consumer downloads: // stripped here, as the core's minified bundles strip them. - plugins: [createShaderPlugin({ minify: true }), createWorkletPlugin(), ...codecovPlugins], + plugins: [...plugins, createShaderPlugin({ minify: true }), createWorkletPlugin(), ...codecovPlugins], output: { - dir: 'dist/esm', + dir: outDir, format: 'es', sourcemap: true, preserveModules: true, @@ -98,3 +101,119 @@ export function createExtensionBuildOptions(opts) { }, }; } + +/** + * Build options for a separately published extension package (an adapter): its + * own `dist/esm` tree, with every `@codexo/exojs` specifier left as a bare + * external import that the consumer's installed Core satisfies. + * + * @param {{ root: string, sourceCondition: string | null, inputs?: string[], external?: string[] }} opts + * @returns {import('rolldown').RolldownOptions} + */ +export function createExtensionBuildOptions(opts) { + const { root, sourceCondition, inputs = ['src/index.ts'], external = [] } = opts; + + const defines = createBuildDefinesFromRepo({ + mode: process.env.EXOJS_ENV === 'development' ? 'development' : 'production', + packageDir: root, + }); + + const packageName = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).name ?? 'extension'; + + return createPreservedTreeOptions({ + root, + sourceCondition, + inputs, + outDir: 'dist/esm', + defines, + bundleName: packageName.replace(/^@codexo\//, ''), + external: id => id.startsWith('@codexo/exojs') || external.some(name => id === name || id.startsWith(`${name}/`)), + }); +} + +/** + * @typedef {object} RuntimeModule + * @property {string} root source package directory + * @property {string | null} sourceCondition package-private `#` source condition, if the module has `#` imports + * @property {readonly string[]} inputs entry modules, relative to `root` + */ + +/** + * Declares how a runtime module is compiled. The module ships inside the Core + * package, so the Core build supplies the output location and the resolution + * of every specifier that leaves the module; see + * {@link createRuntimeModuleBuildOptions}. + * + * @param {{ root: string, sourceCondition: string | null, inputs?: string[] }} opts + * @returns {Readonly} + */ +export function defineRuntimeModule(opts) { + const { root, sourceCondition, inputs = ['src/index.ts'] } = opts; + + return Object.freeze({ root, sourceCondition, inputs: Object.freeze([...inputs]) }); +} + +/** + * Build options for a runtime module compiled into the Core package tree. + * + * `resolveExternal` maps a specifier that leaves the module to the absolute + * file it ships as, or returns `undefined` to let normal resolution bundle it. + * A mapped import is emitted as a path relative to each importing chunk, so the + * module reaches Core and its sibling modules through the same files the Core + * entries load instead of through a bare package specifier. + * + * @param {Readonly} module + * @param {{ outDir: string, defines: Record, bundleName: string, resolveExternal: (specifier: string, importer: string | undefined) => string | undefined }} target + * @returns {import('rolldown').RolldownOptions} + */ +export function createRuntimeModuleBuildOptions(module, target) { + const outDir = resolve(module.root, target.outDir); + + return createPreservedTreeOptions({ + root: module.root, + sourceCondition: module.sourceCondition, + inputs: [...module.inputs], + outDir, + defines: target.defines, + bundleName: target.bundleName, + plugins: [createShippedExternalsPlugin({ sourceRoot: resolve(module.root, 'src'), outDir, resolveExternal: target.resolveExternal })], + }); +} + +/** + * Emits an import that leaves a preserveModules tree as a path relative to the + * importing module's own output file. + * + * The relative path is computed here rather than left to Rolldown: given an + * absolute external id, Rolldown relativizes it against the importer's SOURCE + * location, which lies in a different directory than the emitted file. A + * preserveModules chunk sits at `outDir/`, so the + * output location of every importer is known before bundling, and a relative + * external id is emitted verbatim. + * + * @param {{ sourceRoot: string, outDir: string, resolveExternal: (specifier: string, importer: string | undefined) => string | undefined }} opts + * @returns {import('rolldown').Plugin} + */ +export function createShippedExternalsPlugin(opts) { + const { sourceRoot, outDir, resolveExternal } = opts; + + return { + name: 'exojs-shipped-externals', + resolveId(source, importer) { + const target = resolveExternal(source, importer); + + if (target === undefined) { + return null; + } + + if (importer === undefined) { + throw new Error(`"${source}" cannot be an entry of a preserveModules tree.`); + } + + const importerOutput = resolve(outDir, relative(sourceRoot, importer)); + const id = relative(dirname(importerOutput), target).replaceAll('\\', '/'); + + return { id: id.startsWith('.') ? id : `./${id}`, external: true }; + }, + }; +} diff --git a/packages/exojs-config/typescript/consumer.json b/packages/exojs-config/typescript/consumer.json index 28ea448da..90ea96d99 100644 --- a/packages/exojs-config/typescript/consumer.json +++ b/packages/exojs-config/typescript/consumer.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "_comment": "Profile for code that consumes ExoJS the way an application does, rather than code that ships inside the engine: the example catalog, guide snippets, the full-bundle entry and the public-surface type tests. It deliberately mirrors what `create-exo-app` scaffolds (plain `strict: true`, `skipLibCheck`, no emit) instead of extending base.json, because base.json is the engine's own profile - `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax` and the unused-symbol checks are engine policy that no scaffold turns on, and checking a copy-paste surface under options its readers do not have makes the gate report drift that does not exist for them. `allowJs`/`checkJs` are on because the example catalog ships each `.ts` source alongside the `.js` twin the transpiler emits, and the gate checks both; they are inert in a program whose file set is `.ts` only. Consumers add their own include/exclude, workspace `paths` and `customConditions`.", + "_comment": "Profile for code that consumes ExoJS the way an application does, rather than code that ships inside the engine: the example catalog, guide snippets and the public-surface type tests. It deliberately mirrors what `create-exo-app` scaffolds (plain `strict: true`, `skipLibCheck`, no emit) instead of extending base.json, because base.json is the engine's own profile - `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax` and the unused-symbol checks are engine policy that no scaffold turns on, and checking a copy-paste surface under options its readers do not have makes the gate report drift that does not exist for them. `allowJs`/`checkJs` are on because the example catalog ships each `.ts` source alongside the `.js` twin the transpiler emits, and the gate checks both; they are inert in a program whose file set is `.ts` only. Consumers add their own include/exclude, workspace `paths` and `customConditions`.", "compilerOptions": { "target": "es2022", "module": "esnext", diff --git a/packages/exojs-config/typescript/workspace-paths.json b/packages/exojs-config/typescript/workspace-paths.json new file mode 100644 index 000000000..091392dc4 --- /dev/null +++ b/packages/exojs-config/typescript/workspace-paths.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "_comment": "Source mappings for the public `@codexo/exojs` specifiers inside this repository, so a program type-checks against the workspace sources rather than against whatever `dist/` a previous build left behind. The paths are relative to this file. A config that needs further mappings has to repeat these, because TypeScript replaces an inherited `paths` object wholesale.", + "compilerOptions": { + "paths": { + "@codexo/exojs": ["../../../src/index.ts"], + "@codexo/exojs/extensions": ["../../../src/extensions/index.ts"], + "@codexo/exojs/renderer-sdk": ["../../../src/renderer-sdk.ts"], + "@codexo/exojs/debug": ["../../../src/debug/index.ts"], + "@codexo/exojs-demo-assets": ["../../exojs-demo-assets/src/index.ts"], + "@codexo/exojs-demo-assets/extended": ["../../exojs-demo-assets/src/extended.ts"], + "@codexo/exojs/tilemap": ["../../exojs-tilemap/src/index.ts"], + "@codexo/exojs/particles": ["../../exojs-particles/src/index.ts"], + "@codexo/exojs/audio-fx": ["../../exojs-audio-fx/src/index.ts"], + "@codexo/exojs/physics": ["../../exojs-physics/src/index.ts"], + "@codexo/exojs/tilemap-physics": ["../../exojs-tilemap-physics/src/index.ts"], + "@codexo/exojs/lighting": ["../../exojs-lighting/src/index.ts"], + "@codexo/exojs/pathfinding": ["../../exojs-pathfinding/src/index.ts"] + } + } +} diff --git a/packages/exojs-demo-assets/LICENSE b/packages/exojs-demo-assets/LICENSE new file mode 100644 index 000000000..dfb7cd04a --- /dev/null +++ b/packages/exojs-demo-assets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Codexo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/exojs-demo-assets/README.md b/packages/exojs-demo-assets/README.md new file mode 100644 index 000000000..542901db1 --- /dev/null +++ b/packages/exojs-demo-assets/README.md @@ -0,0 +1,101 @@ +# @codexo/exojs-demo-assets + +Optional demo media for [ExoJS](https://github.com/Exoridus/ExoJS): a typed, flat catalog of asset descriptors for textures, sounds, music, fonts, bitmap fonts, maps, JSON atlases and videos, with licences and a hash manifest. + +This package is data and descriptors, not the Core asset system. Importing it or reading a property fetches nothing, decodes nothing and creates no placeholder resources. A resource exists only once a concrete loader has claimed its descriptor. + +## Install + +```sh +npm install @codexo/exojs @codexo/exojs-demo-assets +``` + +## Usage + +```ts +import { Application, type RenderingContext, Scene, Sprite, type Video } from '@codexo/exojs'; +import { demoAssets as assets } from '@codexo/exojs-demo-assets'; + +class DemoScene extends Scene { + private video!: Video; + private ship!: Sprite; + + override async load(): Promise { + // Video, music and fonts have no placeholder handle: await them in load(). + this.video = await this.loader.load(assets.videoHighRes); + } + + override init(): void { + // Textures and sounds can be claimed synchronously: get() returns a handle + // that fills in when the file arrives. + this.ship = new Sprite(this.loader.get(assets.ship)).setPosition(64, 64); + this.video.applyOptions({ loop: true, muted: true }); + this.video.play(); + } + + override draw(context: RenderingContext): void { + context.render(this.video); + context.render(this.ship); + } +} + +const app = new Application({ scenes: { DemoScene }, canvas: { mount: document.body } }); + +await app.start(DemoScene); +``` + +Reading a property such as `assets.ship` only returns its descriptor. Textures and sounds support `loader.get()` and `await loader.load()`; video, music, fonts, bitmap fonts and SVG images have no synchronous handle and load with `await loader.load()`. The catalog object is frozen and reusable across scenes and applications; claims and payload sharing belong to each loader. + +## Bundlers + +Each descriptor in `demoAssets` is a literal `new URL('../media/', import.meta.url)`, which Vite and other bundlers that understand the pattern follow. Two consequences matter for a production build: + +- The descriptors live in one object, so importing `demoAssets` makes the bundler emit every standard file (about 19 MB), not only the ones the code loads. That suits a demo or prototype; a shipped game should load its own media or copy just what it needs. +- Tiled maps, LDtk projects and bitmap fonts reference sibling files by relative path. A bundler that renames files with content hashes, or inlines small ones as `data:` URLs, breaks those references. Serve these formats through the copy command and `createDemoAssets` below. + +The Vite dev server serves the media straight from the installed package, relative references included. + +## Hosting the media elsewhere + +The default `demoAssets` instance addresses the media shipped inside this package, which works in Node ESM and with bundlers that resolve `new URL(..., import.meta.url)`. Hosts that copy the media to their own directory use the factory: + +```ts +import { createDemoAssets } from '@codexo/exojs-demo-assets'; + +const assets = createDemoAssets(new URL('./demo-media/', import.meta.url)); +``` + +`baseUrl` must be an absolute URL; a relative location has to be resolved by the caller first. Copy the media deterministically with the bundled command: + +```sh +npx exojs-demo-assets-copy ./public/demo-media --clean +``` + +It copies exactly the standard-profile files listed in `manifest.json` to their manifest paths and verifies each file against its SHA-256. + +## Extended media + +The standard profile ships in this package (160 files, under 20 MB). The extended HDR10 video candidate does not: it is delivered as a separate archive (`exojs-demo-assets-extended-.zip`) and served from a location you choose. + +```ts +import type { Scene, Video } from '@codexo/exojs'; +import { createExtendedDemoAssets } from '@codexo/exojs-demo-assets/extended'; + +const extended = createExtendedDemoAssets('https://media.example.com/exojs-demo-extended/'); + +export const loadHdrClip = async (scene: Scene): Promise