diff --git a/.github/workflows/_ci-checks.yml b/.github/workflows/_ci-checks.yml index a1211ba7..81d11e5e 100644 --- a/.github/workflows/_ci-checks.yml +++ b/.github/workflows/_ci-checks.yml @@ -108,7 +108,7 @@ jobs: # Root `pnpm typecheck` only covers src/**; the extension packages own a # separate tsconfig each, so typecheck them explicitly. - name: Typecheck extension packages - run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" typecheck + run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" typecheck lint: name: Lint @@ -368,11 +368,14 @@ jobs: - name: Build core run: pnpm build + - name: Check bundle sizes + run: pnpm size + # The extension packages each have their own rollup build; pnpm runs them # in workspace-dependency order (tilemap before tiled). A broken package # build is now caught here instead of only at release time. - name: Build extension packages - run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" build + run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build - name: Verify core package exports run: pnpm verify:exports @@ -392,7 +395,7 @@ jobs: # Pack each extension package (dry run) so a broken `files`/`exports` set in # a package manifest is caught here, not at release time. - name: Extension package dry runs - run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" pack --dry-run + run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" pack --dry-run # publint validates the published package.json itself (exports map, files, # types resolution, module/main coherence) — the layer attw does NOT cover diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e720590..f35acb61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,6 +102,9 @@ jobs: pnpm --filter @codexo/exojs-tiled build pnpm --filter @codexo/exojs-physics 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 site:build # The site build can regenerate tracked content with platform-dependent diff --git a/.gitignore b/.gitignore index c4fa4f1b..1d115669 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ codexo-bg-* !.gitmojirc.json !.prettierignore !.prettierrc +!.size-limit.cjs # Allowlist tracked dot-directories. The directory itself plus all # non-dotfile contents (workflow YAMLs, husky hooks, etc.). Dotfiles diff --git a/.size-limit.cjs b/.size-limit.cjs new file mode 100644 index 00000000..b8cfe054 --- /dev/null +++ b/.size-limit.cjs @@ -0,0 +1,14 @@ +module.exports = [ + { + path: 'dist/exo.esm.js', + limit: '700 KB', + gzip: true, + }, + { + path: 'dist/exo.iife.min.js', + limit: '250 KB', + gzip: true, + }, + // The full bundle (dist/exo.full.iife.min.js) is opt-in (EXOJS_FULL_BUNDLE=1) + // and not produced by the default build, so it is not size-gated here. +]; diff --git a/eslint.config.ts b/eslint.config.ts index 1c6db65a..b510f275 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -492,11 +492,11 @@ export default defineConfig([ // policy. Excludes create-exo-app (standalone scaffolding CLI, no ESLint // integration). { - files: ['packages/exojs-*/test/**/*.ts'], + files: ['packages/exojs-*/test/**/*.{ts,tsx}'], ...tseslint.configs.disableTypeChecked, }, { - files: ['packages/exojs-*/test/**/*.ts'], + files: ['packages/exojs-*/test/**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', @@ -554,7 +554,7 @@ export default defineConfig([ // Site React components. Astro files are type-checked by `astro check`; this // block covers the TypeScript/TSX islands that ship browser interactivity. { - files: ['site/src/**/*.{ts,tsx}'], + files: ['site/src/**/*.{ts,tsx}', 'packages/exojs-react/src/**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', @@ -655,6 +655,30 @@ export default defineConfig([ }, }, + // The React integration package holds an imperative ExoJS `Application` handle + // in `useState` and mutates it by design (resize / clearColor / sizingMode), + // which the immutability rule cannot model. `@eslint-react/exhaustive-deps` + // duplicates `react-hooks/exhaustive-deps`; keep the latter as the single + // source so the in-code disables apply once. + { + files: ['packages/exojs-react/src/**/*.{ts,tsx}'], + rules: { + 'react-hooks/immutability': 'off', + '@eslint-react/exhaustive-deps': 'off', + // Creating the imperative Application/Scene in an effect and exposing it + // as state is the defining pattern of this bridge, not a bug. + 'react-hooks/set-state-in-effect': 'off', + '@eslint-react/set-state-in-effect': 'off', + // Targets React 18; `` / `useContext` are correct there + // (the `use()` and bare-`` forms are React 19+). + '@eslint-react/no-context-provider': 'off', + '@eslint-react/no-use-context': 'off', + // Reading declarative `` config via Children.forEach is the + // intended pattern (mirrors react-three-fiber / react-router). + '@eslint-react/no-children-for-each': 'off', + }, + }, + // --------------------------------------------------------------------------- // Per-subsystem overrides for src/. Scoped narrowly because these directories // either have hot-path lifecycle invariants, browser-API variance, or typed- @@ -1067,7 +1091,7 @@ export default defineConfig([ // structural test config above; covers both root and package test suites. { ...vitest.configs.recommended, - files: ['test/**/*.ts', 'packages/exojs-*/test/**/*.ts'], + files: ['test/**/*.ts', 'packages/exojs-*/test/**/*.{ts,tsx}'], rules: { ...vitest.configs.recommended.rules, // Primary value: block an accidentally committed `.only`. diff --git a/examples/debug-layer/performance-overlay.js b/examples/debug-layer/performance-overlay.js index 275375b1..7534cc11 100644 --- a/examples/debug-layer/performance-overlay.js +++ b/examples/debug-layer/performance-overlay.js @@ -1,5 +1,5 @@ // Auto-generated from performance-overlay.ts — edit the .ts source, not this file. -import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ canvas: { @@ -17,14 +17,21 @@ const debug = new DebugOverlay(app); debug.layers.performance.visible = true; class PerformanceOverlayScene extends Scene { sprites; + layer; async load(loader) { await loader.load(Texture, { bunny: 'image/ship-a.png' }); } init(loader) { const { width, height } = this.app.canvas; + // All sprites share one texture, so adding them to a single container and + // rendering it once lets the renderer batch them into a single draw call. + // Rendering each sprite with its own `context.render(sprite)` call would + // instead emit one draw call per sprite and tank the frame rate. + this.layer = new Container(); this.sprites = Array.from({ length: 1600 }, () => { const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.25); sprite.setPosition(Math.random() * width, Math.random() * height); + this.layer.addChild(sprite); return { sprite, vx: (Math.random() - 0.5) * 120, @@ -47,8 +54,7 @@ class PerformanceOverlayScene extends Scene { } draw(context) { context.backend.clear(); - for (const { sprite } of this.sprites) - context.render(sprite); + context.render(this.layer); } } app.start(new PerformanceOverlayScene()); diff --git a/examples/debug-layer/performance-overlay.ts b/examples/debug-layer/performance-overlay.ts index 7ff4b2b8..b5b1d415 100644 --- a/examples/debug-layer/performance-overlay.ts +++ b/examples/debug-layer/performance-overlay.ts @@ -1,4 +1,4 @@ -import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; +import { Application, Color, Container, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs'; import { DebugOverlay } from '@codexo/exojs/debug'; const app = new Application({ @@ -19,6 +19,7 @@ debug.layers.performance.visible = true; class PerformanceOverlayScene extends Scene { private sprites!: { sprite: Sprite; vx: number; vy: number }[]; + private layer!: Container; override async load(loader): Promise { await loader.load(Texture, { bunny: 'image/ship-a.png' }); @@ -27,9 +28,15 @@ class PerformanceOverlayScene extends Scene { override init(loader): void { const { width, height } = this.app.canvas; + // All sprites share one texture, so adding them to a single container and + // rendering it once lets the renderer batch them into a single draw call. + // Rendering each sprite with its own `context.render(sprite)` call would + // instead emit one draw call per sprite and tank the frame rate. + this.layer = new Container(); this.sprites = Array.from({ length: 1600 }, () => { const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.25); sprite.setPosition(Math.random() * width, Math.random() * height); + this.layer.addChild(sprite); return { sprite, vx: (Math.random() - 0.5) * 120, @@ -53,7 +60,7 @@ class PerformanceOverlayScene extends Scene { override draw(context): void { context.backend.clear(); - for (const { sprite } of this.sprites) context.render(sprite); + context.render(this.layer); } } diff --git a/examples/sprites-textures/blendmodes.js b/examples/sprites-textures/blendmodes.js index feb94fb7..3ff90ddd 100644 --- a/examples/sprites-textures/blendmodes.js +++ b/examples/sprites-textures/blendmodes.js @@ -20,8 +20,20 @@ const BLEND_MODES = [ { mode: BlendModes.Subtract, name: 'Subtract' }, { mode: BlendModes.Multiply, name: 'Multiply' }, { mode: BlendModes.Screen, name: 'Screen' }, + // Advanced (backdrop-aware) modes — correct coverage, work with alpha. { mode: BlendModes.Darken, name: 'Darken' }, { mode: BlendModes.Lighten, name: 'Lighten' }, + { mode: BlendModes.Overlay, name: 'Overlay' }, + { mode: BlendModes.ColorDodge, name: 'Color Dodge' }, + { mode: BlendModes.ColorBurn, name: 'Color Burn' }, + { mode: BlendModes.HardLight, name: 'Hard Light' }, + { mode: BlendModes.SoftLight, name: 'Soft Light' }, + { mode: BlendModes.Difference, name: 'Difference' }, + { mode: BlendModes.Exclusion, name: 'Exclusion' }, + { mode: BlendModes.Hue, name: 'Hue' }, + { mode: BlendModes.Saturation, name: 'Saturation' }, + { mode: BlendModes.Color, name: 'Color' }, + { mode: BlendModes.Luminosity, name: 'Luminosity' }, ]; class BlendmodesScene extends Scene { background; diff --git a/examples/sprites-textures/blendmodes.ts b/examples/sprites-textures/blendmodes.ts index 4e1e1f19..8bd6c739 100644 --- a/examples/sprites-textures/blendmodes.ts +++ b/examples/sprites-textures/blendmodes.ts @@ -22,8 +22,20 @@ const BLEND_MODES: Array<{ mode: BlendModes; name: string }> = [ { mode: BlendModes.Subtract, name: 'Subtract' }, { mode: BlendModes.Multiply, name: 'Multiply' }, { mode: BlendModes.Screen, name: 'Screen' }, + // Advanced (backdrop-aware) modes — correct coverage, work with alpha. { mode: BlendModes.Darken, name: 'Darken' }, { mode: BlendModes.Lighten, name: 'Lighten' }, + { mode: BlendModes.Overlay, name: 'Overlay' }, + { mode: BlendModes.ColorDodge, name: 'Color Dodge' }, + { mode: BlendModes.ColorBurn, name: 'Color Burn' }, + { mode: BlendModes.HardLight, name: 'Hard Light' }, + { mode: BlendModes.SoftLight, name: 'Soft Light' }, + { mode: BlendModes.Difference, name: 'Difference' }, + { mode: BlendModes.Exclusion, name: 'Exclusion' }, + { mode: BlendModes.Hue, name: 'Hue' }, + { mode: BlendModes.Saturation, name: 'Saturation' }, + { mode: BlendModes.Color, name: 'Color' }, + { mode: BlendModes.Luminosity, name: 'Luminosity' }, ]; class BlendmodesScene extends Scene { diff --git a/package.json b/package.json index ac424d42..e34a4ab4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "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", "README.md", "CHANGELOG.md", "LICENSE" @@ -75,6 +79,7 @@ "verify:release-matrix": "tsx ./scripts/verify-release-matrix.ts", "verify:create-exo-app": "tsx ./scripts/verify-create-exo-app.ts", "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", + "create:package": "tsx scripts/create-package.ts", "verify:release": "pnpm verify:lockstep && pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm lint:strict && pnpm format:check && pnpm test && pnpm verify:package && pnpm verify:create-exo-app && pnpm site:build", "verify:quick": "pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm typecheck:packages && pnpm lint:all && pnpm format:check && pnpm docs:api:check", "verify:ci": "pnpm verify:quick && pnpm test", @@ -90,11 +95,11 @@ "typecheck": "tsc --noEmit", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", "typecheck:guides": "tsx scripts/extract-guide-snippets.ts && tsc --noEmit -p tsconfig.guides.json", - "typecheck:packages": "pnpm --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-audio-fx\" typecheck", + "typecheck:packages": "pnpm --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.js\" \"site/src/**/*.{ts,tsx}\"", "lint:fix": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.js\" \"site/src/**/*.{ts,tsx}\"", "site:lint": "eslint \"site/src/**/*.{ts,tsx}\"", - "lint:packages": "eslint \"packages/exojs-*/src/**/*.ts\" \"packages/exojs-*/test/**/*.ts\"", + "lint:packages": "eslint \"packages/exojs-*/src/**/*.{ts,tsx}\" \"packages/exojs-*/test/**/*.{ts,tsx}\"", "lint:all": "pnpm lint && pnpm lint:packages", "lint:strict": "eslint --max-warnings=0 \"src/**/*.ts\"", "lint:strict:fast": "eslint --cache --cache-location .cache/eslintcache --max-warnings=0 \"src/**/*.ts\"", @@ -131,6 +136,7 @@ "perf:bench:all": "pnpm perf:bench:rendering && pnpm perf:bench:audio && pnpm perf:bench:collision && pnpm perf:bench:scene-graph && pnpm perf:bench:interaction", "perf:profile": "tsx test/perf/profile-benchmark.ts", "perf:profile:gc": "node --expose-gc --import tsx/esm test/perf/profile-benchmark.ts", + "size": "size-limit", "prepare": "husky", "commit": "gitmoji -c" }, @@ -154,6 +160,9 @@ "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", + "@size-limit/preset-small-lib": "^11.2.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/css-font-loading-module": "0.0.14", "@types/jsdom": "^28.0.1", "@types/node": "^25.6.0", @@ -179,6 +188,7 @@ "rimraf": "^6.1.3", "rollup": "^4.60.3", "rollup-plugin-string": "^3.0.0", + "size-limit": "^11.2.0", "tslib": "^2.8.1", "tsx": "^4.21.0", "typescript": "~6.0.3", diff --git a/packages/exojs-aseprite/LICENSE b/packages/exojs-aseprite/LICENSE new file mode 100644 index 00000000..dfb7cd04 --- /dev/null +++ b/packages/exojs-aseprite/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-aseprite/README.md b/packages/exojs-aseprite/README.md new file mode 100644 index 00000000..61f0ab7f --- /dev/null +++ b/packages/exojs-aseprite/README.md @@ -0,0 +1,83 @@ +# @codexo/exojs-aseprite + +Official ExoJS extension for loading [Aseprite](https://www.aseprite.org) JSON sprite-sheet +exports into a ready-to-animate sprite, with one animation clip per Aseprite frame tag. + +## Installation + +```sh +npm install @codexo/exojs @codexo/exojs-aseprite +``` + +`@codexo/exojs` is a peer dependency. This package has no other runtime dependencies. + +> Export your sprite sheet from Aseprite as a **JSON + PNG** pair (`File → Export Sprite Sheet`, +> *Output → JSON Data*). Either array or hash frame mode works; frame tags become animation clips. + +## What this package provides + +- `AsepriteSheet` — parsed sprite sheet; the result of `loader.load(AsepriteSheet, url)`. Exposes + the underlying `spritesheet`, a `clips` map (one `AnimatedSpriteClipDefinition` per frame tag), + and `createAnimatedSprite()` for a ready-to-play `AnimatedSprite` +- `asepriteExtension` — extension descriptor registering the Aseprite asset binding +- `asepriteBinding` — the underlying `AssetBinding` (advanced/custom wiring) +- `AsepriteFormatError` — typed error thrown on malformed Aseprite JSON +- `AsepriteData` and related types (`AsepriteFrameData`, `AsepriteFrameTag`, `AsepriteMeta`, + `AsepriteSlice`, …) plus the `isAsepriteArrayData` guard + +## Usage + +Register the extension, load an Aseprite JSON export, and create an animated sprite. The extension +fetches the JSON, resolves and loads the packed texture, and builds one clip per frame tag: + +```ts +import { Application } from '@codexo/exojs'; +import { AsepriteSheet, asepriteExtension } from '@codexo/exojs-aseprite'; + +const app = new Application({ extensions: [asepriteExtension] }); + +const sheet = await app.loader.load(AsepriteSheet, 'sprites/hero.aseprite.json'); + +const sprite = sheet.createAnimatedSprite(); +sprite.play('run'); // 'run' is an Aseprite frame-tag name +app.scene.root.addChild(sprite); +``` + +Clip frame rate is derived from each frame's Aseprite `duration` (falling back to 12 fps). Frame +indices in a tag are resolved against the ordered frame array; out-of-range indices are skipped. + +## `/register` convenience entry + +Importing `/register` registers `asepriteExtension` in the global `ExtensionRegistry`, so +subsequently created Applications that use global defaults pick it up automatically: + +```ts +// Side effect: registers asepriteExtension in the global ExtensionRegistry. +import '@codexo/exojs-aseprite/register'; + +// All named exports are also re-exported from /register: +import { AsepriteSheet, asepriteExtension } from '@codexo/exojs-aseprite/register'; +``` + +This is the only side-effectful entry — importing the package root (`@codexo/exojs-aseprite`) does +**not** register anything. + +## Texture ownership + +The packed texture is loaded via the Loader and stays in the Loader cache. `AsepriteSheet.destroy()` +releases the parsed sprite sheet; the Loader handles texture lifecycle and deduplication. + +## Core compatibility + +| `@codexo/exojs-aseprite` | `@codexo/exojs` | +|---|---| +| 0.14.x | 0.14.x | + +## Links + +- [API reference](https://exojs.dev/api/exojs-aseprite) +- [Aseprite](https://www.aseprite.org) + +## License + +MIT © Codexo diff --git a/packages/exojs-aseprite/package.json b/packages/exojs-aseprite/package.json new file mode 100644 index 00000000..0b780f0c --- /dev/null +++ b/packages/exojs-aseprite/package.json @@ -0,0 +1,53 @@ +{ + "name": "@codexo/exojs-aseprite", + "version": "0.14.0", + "description": "Aseprite sprite sheet asset extension for ExoJS.", + "repository": { + "type": "git", + "url": "git+https://github.com/Exoridus/ExoJS.git", + "directory": "packages/exojs-aseprite" + }, + "type": "module", + "sideEffects": [ + "./dist/esm/register.js" + ], + "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" + }, + "./register": { + "types": "./dist/esm/register.d.ts", + "import": "./dist/esm/register.js", + "default": "./dist/esm/register.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/esm/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:production", + "build:dev": "tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:development", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\"", + "test": "vitest run --root ../.. --project=exojs-aseprite" + }, + "peerDependencies": { + "@codexo/exojs": "0.14.x" + }, + "devDependencies": { + "@codexo/exojs": "workspace:*", + "@codexo/exojs-config": "workspace:*" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/exojs-aseprite/rollup.config.ts b/packages/exojs-aseprite/rollup.config.ts new file mode 100644 index 00000000..7d5827e4 --- /dev/null +++ b/packages/exojs-aseprite/rollup.config.ts @@ -0,0 +1,8 @@ +import { createExtensionConfig } from '@codexo/exojs-config/rollup'; + +// exojs-aseprite has no package-internal `#` imports (all same-directory `./`), +// so no source condition / node-resolve is needed; Core's `#` resolves to its dist. +export default createExtensionConfig({ + root: import.meta.dirname, + sourceCondition: null, +}); diff --git a/packages/exojs-aseprite/src/AsepriteData.ts b/packages/exojs-aseprite/src/AsepriteData.ts new file mode 100644 index 00000000..ea5df91f --- /dev/null +++ b/packages/exojs-aseprite/src/AsepriteData.ts @@ -0,0 +1,104 @@ +// TypeScript types for the Aseprite JSON sprite sheet export format. +// Supports both array mode (frames is an ordered array) and hash mode +// (frames is an object keyed by frame name / filename). + +/** Playback direction of an Aseprite frame tag animation. */ +export type AsepriteDirection = 'forward' | 'pingpong' | 'pingpong_reverse' | 'reverse'; + +/** Pixel region rectangle used throughout the Aseprite JSON format. */ +export interface AsepriteRect { + readonly h: number; + readonly w: number; + readonly x: number; + readonly y: number; +} + +/** Width/height size descriptor used in Aseprite metadata. */ +export interface AsepriteSize { + readonly h: number; + readonly w: number; +} + +/** A single animation frame in the Aseprite JSON export. */ +export interface AsepriteFrameData { + /** Display duration of this frame in milliseconds. */ + readonly duration: number; + /** Pixel region of this frame within the packed sprite sheet texture. */ + readonly frame: AsepriteRect; + readonly rotated: boolean; + readonly sourceSize: AsepriteSize; + readonly spriteSourceSize: AsepriteRect; + readonly trimmed: boolean; +} + +/** + * A named animation range defined via Aseprite frame tags. + * `from` and `to` are inclusive zero-based frame indices. + */ +export interface AsepriteFrameTag { + readonly color?: string; + readonly direction: AsepriteDirection; + /** Inclusive end frame index. */ + readonly to: number; + /** Inclusive start frame index. */ + readonly from: number; + readonly name: string; +} + +/** A single layer entry in the Aseprite JSON metadata. */ +export interface AsepriteLayer { + readonly blendMode: string; + readonly name: string; + readonly opacity: number; +} + +/** Bounds of a named slice at a specific frame. */ +export interface AsepriteSliceKey { + readonly bounds: AsepriteRect; + readonly frame: number; +} + +/** A named slice defined in the Aseprite editor. */ +export interface AsepriteSlice { + readonly color?: string; + readonly keys: readonly AsepriteSliceKey[]; + readonly name: string; +} + +/** Metadata block of an Aseprite JSON export. */ +export interface AsepriteMeta { + readonly app: string; + readonly format: string; + readonly frameTags?: readonly AsepriteFrameTag[]; + /** Relative path to the exported sprite sheet image. */ + readonly image: string; + readonly layers?: readonly AsepriteLayer[]; + readonly scale: string; + readonly size: AsepriteSize; + readonly slices?: readonly AsepriteSlice[]; + readonly version: string; +} + +/** Aseprite JSON export in array mode — frames is an ordered array. */ +export interface AsepriteArrayData { + readonly frames: readonly AsepriteFrameData[]; + readonly meta: AsepriteMeta; +} + +/** Aseprite JSON export in hash mode — frames is an object keyed by frame name. */ +export interface AsepriteHashData { + readonly frames: Readonly>; + readonly meta: AsepriteMeta; +} + +/** + * Union of both Aseprite JSON export formats. + * + * Use {@link isAsepriteArrayData} to discriminate between array and hash mode. + */ +export type AsepriteData = AsepriteArrayData | AsepriteHashData; + +/** Returns `true` when `data` is in array mode (frames is an array). */ +export function isAsepriteArrayData(data: AsepriteData): data is AsepriteArrayData { + return Array.isArray(data.frames); +} diff --git a/packages/exojs-aseprite/src/AsepriteSheet.ts b/packages/exojs-aseprite/src/AsepriteSheet.ts new file mode 100644 index 00000000..abf8f38e --- /dev/null +++ b/packages/exojs-aseprite/src/AsepriteSheet.ts @@ -0,0 +1,150 @@ +import { AnimatedSprite, type AnimatedSpriteClipDefinition, type Rectangle, Spritesheet, type Texture } from '@codexo/exojs'; + +import { type AsepriteData, type AsepriteFrameData,isAsepriteArrayData } from './AsepriteData'; + +/** + * Normalises an {@link AsepriteData} document into an ordered array of + * {@link AsepriteFrameData} entries regardless of whether the JSON was + * produced in array or hash mode. + */ +function normaliseFrames(data: AsepriteData): AsepriteFrameData[] { + if (isAsepriteArrayData(data)) { + return [...data.frames]; + } + + return Object.values(data.frames); +} + +/** + * Calculates the average frames-per-second for a subset of frames, based on + * the per-frame `duration` field (milliseconds per frame) exported by Aseprite. + * Falls back to `12` fps when all durations are zero or the slice is empty. + */ +function avgFps(frames: AsepriteFrameData[], from: number, to: number): number { + const slice = frames.slice(from, to + 1); + + if (slice.length === 0) { + return 12; + } + + const totalMs = slice.reduce((sum, f) => sum + f.duration, 0); + const avgMs = totalMs / slice.length; + + return avgMs > 0 ? 1000 / avgMs : 12; +} + +/** + * Parsed representation of an Aseprite JSON sprite sheet export. + * + * `AsepriteSheet.parse(data, texture)` converts the raw JSON document into: + * - A {@link Spritesheet} whose frames correspond to the Aseprite frame array + * (keyed by zero-based index string: `"0"`, `"1"`, …). + * - A `clips` map of {@link AnimatedSpriteClipDefinition} entries built from + * `meta.frameTags`, one per named tag. + * + * Call {@link createAnimatedSprite} to obtain a ready-to-use + * {@link AnimatedSprite} with all clips pre-registered. + * + * @example + * ```ts + * const sheet = await loader.load(AsepriteSheet, 'hero.aseprite.json'); + * const sprite = sheet.createAnimatedSprite(); + * sprite.play('run'); + * scene.addChild(sprite); + * ``` + */ +export class AsepriteSheet { + /** The underlying {@link Spritesheet} whose frames are keyed by index string. */ + public readonly spritesheet: Spritesheet; + + /** + * Animation clips derived from the Aseprite `frameTags` metadata. + * Each clip's frames are live references into {@link spritesheet.frames}; + * they are cloned automatically when passed to {@link AnimatedSprite.defineClip}. + */ + public readonly clips: ReadonlyMap; + + /** + * @internal — use {@link AsepriteSheet.parse} to create instances. + * The public modifier is required for the Loader's `AssetConstructor` token + * contract; users should call `parse()` instead of constructing directly. + */ + public constructor(spritesheet: Spritesheet, clips: ReadonlyMap) { + this.spritesheet = spritesheet; + this.clips = clips; + } + + /** + * Parse a raw {@link AsepriteData} document and the already-loaded + * {@link Texture} into an {@link AsepriteSheet}. + * + * Supports both Aseprite array mode and hash mode. Frame indices from + * `frameTags` are resolved against the ordered frame array; out-of-range + * indices are silently skipped. + * + * Ping-pong directions (`pingpong`, `pingpong_reverse`) are recorded with + * `loop: true` but the reversed segment is not automatically appended — + * the clip plays only the declared `from`→`to` range. + */ + public static parse(data: AsepriteData, texture: Texture): AsepriteSheet { + const frameArray = normaliseFrames(data); + + // Build SpritesheetData: frame names are zero-based index strings. + const spritesheetFrames: Record = {}; + + for (let i = 0; i < frameArray.length; i++) { + const frameData = frameArray[i]!; + spritesheetFrames[String(i)] = { frame: frameData.frame }; + } + + const spritesheet = new Spritesheet(texture, { frames: spritesheetFrames }); + + // Build clips from frameTags, resolving frame indices into Rectangles. + const clips = new Map(); + const frameTags = data.meta.frameTags ?? []; + + for (const tag of frameTags) { + const frames: Rectangle[] = []; + + for (let i = tag.from; i <= tag.to; i++) { + if (i >= 0 && i < frameArray.length) { + frames.push(spritesheet.getFrame(String(i))); + } + } + + if (frames.length === 0) { + continue; + } + + clips.set(tag.name, { + fps: avgFps(frameArray, tag.from, tag.to), + frames, + loop: true, + }); + } + + return new AsepriteSheet(spritesheet, clips); + } + + /** + * Create an {@link AnimatedSprite} with all frame-tag clips pre-defined. + * + * Each clip is registered via {@link AnimatedSprite.defineClip}, which + * clones the frame {@link Rectangle}s so the sprite owns its own copies. + * Call {@link AnimatedSprite.play} with a tag name to start playback. + */ + public createAnimatedSprite(): AnimatedSprite { + const sprite = new AnimatedSprite(this.spritesheet.texture); + + for (const [name, clip] of this.clips) { + sprite.defineClip(name, clip); + } + + return sprite; + } + + /** Destroy the underlying {@link Spritesheet} and release its frame resources. */ + public destroy(): void { + this.spritesheet.destroy(); + } +} diff --git a/packages/exojs-aseprite/src/asepriteBinding.ts b/packages/exojs-aseprite/src/asepriteBinding.ts new file mode 100644 index 00000000..66532cd1 --- /dev/null +++ b/packages/exojs-aseprite/src/asepriteBinding.ts @@ -0,0 +1,125 @@ +// Relative-path resolution for Aseprite image references (JSON → PNG). +// Mirrors the approach used in @codexo/exojs-tiled: Aseprite stores the image +// path relative to the JSON file; asset sources are often themselves relative, +// so plain `new URL(ref, base)` cannot be used when `base` has no scheme. + +import { Texture } from '@codexo/exojs'; +import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; + +import type { AsepriteData } from './AsepriteData'; +import { AsepriteSheet } from './AsepriteSheet'; + +// ── URL resolution ─────────────────────────────────────────────────────────── + +/** Matches references that are already absolute: scheme, `//`, `/`, data/blob. */ +const absoluteRefPattern = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/)/i; + +/** Matches a base that has an explicit scheme (absolute URL). */ +const absoluteBasePattern = /^[a-z][a-z\d+.-]*:/i; + +/** Synthetic origin used to borrow `URL`'s `../`/`./` collapsing. */ +const syntheticOrigin = 'https://exojs.invalid/'; + +/** + * Resolves `ref` (the image path read from an Aseprite JSON file) relative to + * `base` (the resolved location of the JSON file itself). + * + * - Absolute refs (scheme, `//`, `/`, `data:`, `blob:`) are returned as-is. + * - Absolute bases delegate to `new URL(ref, base).href`. + * - Relative bases use a synthetic origin to collapse `./` and `../` segments, + * then strips the origin from the result. + */ +function resolveAsepriteUrl(ref: string, base: string): string { + if (absoluteRefPattern.test(ref)) { + return ref; + } + + if (absoluteBasePattern.test(base)) { + return new URL(ref, base).href; + } + + const resolved = new URL(ref, syntheticOrigin + base.replace(/^\/+/, '')); + + return resolved.href.slice(syntheticOrigin.length); +} + +// ── Validation ─────────────────────────────────────────────────────────────── + +/** + * Thrown when an Aseprite JSON document does not match the expected shape. + * `source` is the URL of the file being parsed. + */ +export class AsepriteFormatError extends Error { + public readonly source: string; + + public constructor(source: string, message: string) { + super(`[AsepriteFormatError] ${source}: ${message}`); + this.name = 'AsepriteFormatError'; + this.source = source; + } +} + +/** + * Validates an `unknown` value against the minimum required Aseprite JSON + * shape and narrows it to {@link AsepriteData}. Throws {@link AsepriteFormatError} + * on any mismatch. + */ +function validateAsepriteData(raw: unknown, source: string): AsepriteData { + if (typeof raw !== 'object' || raw === null) { + throw new AsepriteFormatError(source, 'root must be an object'); + } + + const doc = raw as Record; + + if (!('frames' in doc)) { + throw new AsepriteFormatError(source, 'missing required field "frames"'); + } + + if (!('meta' in doc) || typeof doc.meta !== 'object' || doc.meta === null) { + throw new AsepriteFormatError(source, 'missing required field "meta"'); + } + + const meta = doc.meta as Record; + + if (typeof meta.image !== 'string' || meta.image.length === 0) { + throw new AsepriteFormatError(source, '"meta.image" must be a non-empty string'); + } + + const frames = doc.frames; + + if (!Array.isArray(frames) && (typeof frames !== 'object' || frames === null)) { + throw new AsepriteFormatError(source, '"frames" must be an array or an object'); + } + + return doc as unknown as AsepriteData; +} + +// ── Asset binding ───────────────────────────────────────────────────────────── + +/** + * Declarative asset binding for {@link AsepriteSheet}. + * + * `loader.load(AsepriteSheet, 'hero.aseprite.json')` fetches and validates the + * Aseprite JSON export, resolves the packed image URL from `meta.image` + * (relative to the JSON source), loads the {@link Texture} via the Loader's + * sub-load deduplication, and constructs a fully-parsed {@link AsepriteSheet}. + * + * The `aseprite` type name enables the asset-config shorthand: + * `{ type: 'aseprite', source: 'hero.aseprite.json' }`. + */ +export const asepriteBinding = { + type: AsepriteSheet, + typeNames: ['asepriteSheet'], + create() { + return { + async load(req, ctx): Promise { + const raw = await ctx.fetchJson(req.source); + const data = validateAsepriteData(raw, req.source); + const imageUrl = resolveAsepriteUrl(data.meta.image, req.source); + const texture = (await ctx.loader.load(Texture, imageUrl)) as Texture; + + return AsepriteSheet.parse(data, texture); + }, + } satisfies AssetHandler; + }, +} satisfies AssetBinding; diff --git a/packages/exojs-aseprite/src/asepriteExtension.ts b/packages/exojs-aseprite/src/asepriteExtension.ts new file mode 100644 index 00000000..81c6e8b1 --- /dev/null +++ b/packages/exojs-aseprite/src/asepriteExtension.ts @@ -0,0 +1,21 @@ +import type { AssetBinding, Extension } from '@codexo/exojs/extensions'; + +import { asepriteBinding } from './asepriteBinding'; + +/** + * Default immutable Aseprite extension descriptor. + * + * Registers one asset binding: + * - {@link asepriteBinding} — `loader.load(AsepriteSheet, 'hero.aseprite.json')` → + * fetches the Aseprite JSON, resolves and loads the packed texture, and + * returns a fully-parsed {@link AsepriteSheet} with all frame-tag clips. + * + * Use with `ApplicationOptions.extensions` or call + * `import '@codexo/exojs-aseprite/register'` for global auto-registration. + */ +export const asepriteExtension: Extension = Object.freeze({ + id: '@codexo/exojs-aseprite', + // Localized erasure cast: typed binding (Options=undefined) meets the + // untyped Extension.assets contract here. Runtime behavior is unaffected. + assets: [asepriteBinding] as unknown as AssetBinding[], +}); diff --git a/packages/exojs-aseprite/src/index.ts b/packages/exojs-aseprite/src/index.ts new file mode 100644 index 00000000..3b99e975 --- /dev/null +++ b/packages/exojs-aseprite/src/index.ts @@ -0,0 +1,5 @@ +// @codexo/exojs-aseprite — side-effect-free root entry. +// Importing this entry does NOT register the extension globally. +// Use @codexo/exojs-aseprite/register for global registration. + +export * from './public'; diff --git a/packages/exojs-aseprite/src/public.ts b/packages/exojs-aseprite/src/public.ts new file mode 100644 index 00000000..194b3ddf --- /dev/null +++ b/packages/exojs-aseprite/src/public.ts @@ -0,0 +1,33 @@ +// Side-effect-free public API for @codexo/exojs-aseprite. +// No registration is performed on import. + +export { asepriteBinding,AsepriteFormatError } from './asepriteBinding'; +export type { + AsepriteArrayData, + AsepriteData, + AsepriteDirection, + AsepriteFrameData, + AsepriteFrameTag, + AsepriteHashData, + AsepriteLayer, + AsepriteMeta, + AsepriteRect, + AsepriteSize, + AsepriteSlice, + AsepriteSliceKey, +} from './AsepriteData'; +export { isAsepriteArrayData } from './AsepriteData'; +export { asepriteExtension } from './asepriteExtension'; +export { AsepriteSheet } from './AsepriteSheet'; + +// ── Module augmentation — typed load calls ──────────────────────────────────── +import type { AsepriteSheet } from './AsepriteSheet'; + +declare module '@codexo/exojs' { + interface AssetDefinitions { + asepriteSheet: { + resource: AsepriteSheet; + config: { source: string }; + }; + } +} diff --git a/packages/exojs-aseprite/src/register.ts b/packages/exojs-aseprite/src/register.ts new file mode 100644 index 00000000..ec35e933 --- /dev/null +++ b/packages/exojs-aseprite/src/register.ts @@ -0,0 +1,13 @@ +// @codexo/exojs-aseprite/register — explicit registration entry. +// Importing this entry registers the default asepriteExtension descriptor +// in the global ExtensionRegistry. Subsequently constructed Applications +// that use global defaults will receive the Aseprite extension. +// This is the only side-effectful entry in this package. + +import { ExtensionRegistry } from '@codexo/exojs/extensions'; + +import { asepriteExtension } from './asepriteExtension'; + +ExtensionRegistry.register(asepriteExtension); + +export * from './public'; diff --git a/packages/exojs-aseprite/test/aseprite-binding.test.ts b/packages/exojs-aseprite/test/aseprite-binding.test.ts new file mode 100644 index 00000000..45c5b13d --- /dev/null +++ b/packages/exojs-aseprite/test/aseprite-binding.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { type AssetLoaderContext, Texture } from '@codexo/exojs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { asepriteBinding,AsepriteFormatError } from '../src/asepriteBinding'; +import { AsepriteSheet } from '../src/AsepriteSheet'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const PKG_DIR = basename(process.cwd()) === 'exojs-aseprite' ? process.cwd() : join(process.cwd(), 'packages', 'exojs-aseprite'); +const FIXTURES_DIR = join(PKG_DIR, 'test', 'fixtures'); + +function loadFixture(name: string): unknown { + return JSON.parse(readFileSync(join(FIXTURES_DIR, name), 'utf-8')); +} + +// ── Context factory ──────────────────────────────────────────────────────────── + +function makeContext(fixtures: Record) { + const loaderLoad = vi.fn(); + + const context: AssetLoaderContext = { + loader: { load: loaderLoad } as unknown as AssetLoaderContext['loader'], + identityKey: 'test', + fetchText: vi.fn(), + fetchArrayBuffer: vi.fn(), + fetchJson: vi.fn(async (source: string): Promise => { + if (Object.hasOwn(fixtures, source)) return fixtures[source]; + throw new Error(`aseprite-binding.test: no fixture for "${source}"`); + }), + }; + + loaderLoad.mockImplementation(async (token: unknown): Promise => { + if (token === Texture) { + const tex = new Texture(); + tex.width = 48; + tex.height = 16; + return tex; + } + throw new Error(`aseprite-binding.test: unexpected loader.load token: ${String(token)}`); + }); + + return { context, loaderLoad }; +} + +// ── Descriptor ─────────────────────────────────────────────────────────────── + +describe('asepriteBinding descriptor', () => { + it('targets the AsepriteSheet constructor', () => { + expect(asepriteBinding.type).toBe(AsepriteSheet); + }); + + it('declares typeNames ["asepriteSheet"]', () => { + expect(asepriteBinding.typeNames).toEqual(['asepriteSheet']); + }); + + it('does NOT claim file extensions (token-only binding)', () => { + expect((asepriteBinding as { extensions?: unknown }).extensions).toBeUndefined(); + }); + + it('create() returns a handler with a load function', () => { + expect(typeof asepriteBinding.create().load).toBe('function'); + }); + + it('create() handler has no custom getIdentityKey (default source identity)', () => { + expect(asepriteBinding.create().getIdentityKey).toBeUndefined(); + }); +}); + +// ── load() — happy path ───────────────────────────────────────────────────────── + +describe('asepriteBinding.load — array fixture', () => { + const fixtures = { 'sprites/hero.json': loadFixture('hero.array.json') }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a fully-parsed AsepriteSheet', async () => { + const { context } = makeContext(fixtures); + const handler = asepriteBinding.create(); + const sheet = await handler.load({ source: 'sprites/hero.json' }, context); + expect(sheet).toBeInstanceOf(AsepriteSheet); + expect(sheet.spritesheet.frames.size).toBe(3); + expect(sheet.clips.size).toBe(2); + }); + + it('resolves the packed image URL relative to the JSON source and sub-loads it as a Texture', async () => { + const { context, loaderLoad } = makeContext(fixtures); + const handler = asepriteBinding.create(); + await handler.load({ source: 'sprites/hero.json' }, context); + expect(loaderLoad).toHaveBeenCalledWith(Texture, 'sprites/hero.png'); + }); + + it('passes absolute image references through unchanged', async () => { + const doc = loadFixture('hero.array.json') as { meta: { image: string } }; + doc.meta.image = 'https://cdn.example.com/hero.png'; + const { context, loaderLoad } = makeContext({ 'sprites/hero.json': doc }); + const handler = asepriteBinding.create(); + await handler.load({ source: 'sprites/hero.json' }, context); + expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://cdn.example.com/hero.png'); + }); + + it('loads the hash-form fixture identically', async () => { + const { context } = makeContext({ 'sprites/hero.json': loadFixture('hero.hash.json') }); + const handler = asepriteBinding.create(); + const sheet = await handler.load({ source: 'sprites/hero.json' }, context); + expect(sheet.spritesheet.frames.size).toBe(3); + }); +}); + +// ── load() — validation / AsepriteFormatError ─────────────────────────────────── + +describe('asepriteBinding.load — AsepriteFormatError on malformed input', () => { + async function loadRaw(raw: unknown): Promise { + const { context } = makeContext({ 'doc.json': raw }); + return asepriteBinding.create().load({ source: 'doc.json' }, context); + } + + it('rejects a non-object root', async () => { + await expect(loadRaw(null)).rejects.toThrow(/root must be an object/); + await expect(loadRaw(42)).rejects.toThrow(AsepriteFormatError); + }); + + it('rejects a document missing "frames"', async () => { + await expect(loadRaw({ meta: { image: 'x.png' } })).rejects.toThrow(/missing required field "frames"/); + }); + + it('rejects a document missing "meta"', async () => { + await expect(loadRaw({ frames: [] })).rejects.toThrow(/missing required field "meta"/); + }); + + it('rejects a document whose "meta" is not an object', async () => { + await expect(loadRaw({ frames: [], meta: null })).rejects.toThrow(/missing required field "meta"/); + }); + + it('rejects an empty or missing "meta.image"', async () => { + await expect(loadRaw({ frames: [], meta: { image: '' } })).rejects.toThrow(/"meta.image" must be a non-empty string/); + await expect(loadRaw({ frames: [], meta: {} })).rejects.toThrow(/"meta.image" must be a non-empty string/); + }); + + it('rejects "frames" that is neither an array nor an object', async () => { + await expect(loadRaw({ frames: 5, meta: { image: 'x.png' } })).rejects.toThrow(/"frames" must be an array or an object/); + }); + + it('attaches the source URL and typed name to the thrown error', async () => { + let caught: unknown; + try { + await loadRaw(null); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AsepriteFormatError); + expect((caught as AsepriteFormatError).name).toBe('AsepriteFormatError'); + expect((caught as AsepriteFormatError).source).toBe('doc.json'); + expect((caught as Error).message).toContain('[AsepriteFormatError] doc.json:'); + }); + + it('does not attempt to load a texture when validation fails', async () => { + const { context, loaderLoad } = makeContext({ 'doc.json': { frames: 5, meta: { image: 'x.png' } } }); + await expect(asepriteBinding.create().load({ source: 'doc.json' }, context)).rejects.toThrow(AsepriteFormatError); + expect(loaderLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/exojs-aseprite/test/aseprite-extension.test.ts b/packages/exojs-aseprite/test/aseprite-extension.test.ts new file mode 100644 index 00000000..ddb40d36 --- /dev/null +++ b/packages/exojs-aseprite/test/aseprite-extension.test.ts @@ -0,0 +1,85 @@ +import { ExtensionRegistry } from '@codexo/exojs/extensions'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { buildSnapshot } from '../../../src/extensions/snapshot'; +import { resetExtensionRegistryForTesting } from '../../../src/extensions/testing'; +import { asepriteBinding } from '../src/asepriteBinding'; +import { asepriteExtension } from '../src/asepriteExtension'; + +// ── Descriptor ─────────────────────────────────────────────────────────────── + +describe('asepriteExtension descriptor', () => { + it('has the package id', () => { + expect(asepriteExtension.id).toBe('@codexo/exojs-aseprite'); + }); + + it('is a frozen, immutable descriptor', () => { + expect(Object.isFrozen(asepriteExtension)).toBe(true); + }); + + it('registers exactly one asset binding (the aseprite binding)', () => { + expect(asepriteExtension.assets).toHaveLength(1); + expect(asepriteExtension.assets![0]).toBe(asepriteBinding); + }); + + it('declares no dependencies, renderers, or serializers', () => { + expect(asepriteExtension.dependencies).toBeUndefined(); + expect(asepriteExtension.renderers).toBeUndefined(); + expect(asepriteExtension.serializers).toBeUndefined(); + }); +}); + +// ── buildSnapshot materialization ────────────────────────────────────────────── + +describe('buildSnapshot([asepriteExtension])', () => { + it('materializes a single extension with the aseprite binding', () => { + const snapshot = buildSnapshot([asepriteExtension]); + expect(snapshot.extensions.map(e => e.id)).toEqual(['@codexo/exojs-aseprite']); + expect(snapshot.assets).toHaveLength(1); + expect(snapshot.assets).toContain(asepriteBinding); + }); + + it('contributes no renderers or serializers', () => { + const snapshot = buildSnapshot([asepriteExtension]); + expect(snapshot.renderers).toHaveLength(0); + expect(snapshot.serializers).toHaveLength(0); + }); +}); + +// ── Side-effect contract: root entry vs /register ─────────────────────────────── + +describe('@codexo/exojs-aseprite root entry (side-effect-free)', () => { + beforeEach(() => { + resetExtensionRegistryForTesting(); + }); + + it('root import does NOT register asepriteExtension in the ExtensionRegistry', async () => { + await import('../src/index'); + expect(ExtensionRegistry.has('@codexo/exojs-aseprite')).toBe(false); + }); +}); + +describe('@codexo/exojs-aseprite/register entry', () => { + beforeEach(() => { + resetExtensionRegistryForTesting(); + }); + + it('registers asepriteExtension on import', async () => { + await import('../src/register'); + expect(ExtensionRegistry.has('@codexo/exojs-aseprite')).toBe(true); + }); +}); + +describe('export parity', () => { + it('root and register expose the same named exports', async () => { + const root = await import('../src/index'); + const register = await import('../src/register'); + const rootKeys = Object.keys(root) + .filter(k => k !== 'default') + .sort(); + const registerKeys = Object.keys(register) + .filter(k => k !== 'default') + .sort(); + expect(rootKeys).toEqual(registerKeys); + }); +}); diff --git a/packages/exojs-aseprite/test/aseprite-sheet.test.ts b/packages/exojs-aseprite/test/aseprite-sheet.test.ts new file mode 100644 index 00000000..42a486d0 --- /dev/null +++ b/packages/exojs-aseprite/test/aseprite-sheet.test.ts @@ -0,0 +1,280 @@ +import { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { AnimatedSprite, Spritesheet, Texture } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import type { AsepriteData } from '../src/AsepriteData'; +import { isAsepriteArrayData } from '../src/AsepriteData'; +import { AsepriteSheet } from '../src/AsepriteSheet'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const PKG_DIR = basename(process.cwd()) === 'exojs-aseprite' ? process.cwd() : join(process.cwd(), 'packages', 'exojs-aseprite'); +const FIXTURES_DIR = join(PKG_DIR, 'test', 'fixtures'); + +function loadFixture(name: string): AsepriteData { + return JSON.parse(readFileSync(join(FIXTURES_DIR, name), 'utf-8')) as AsepriteData; +} + +const arrayData = loadFixture('hero.array.json'); +const hashData = loadFixture('hero.hash.json'); + +function newTexture(): Texture { + const tex = new Texture(); + tex.width = 48; + tex.height = 16; + + return tex; +} + +// ── isAsepriteArrayData ──────────────────────────────────────────────────────── + +describe('isAsepriteArrayData', () => { + it('returns true for the array-frames form', () => { + expect(isAsepriteArrayData(arrayData)).toBe(true); + }); + + it('returns false for the hash-frames form', () => { + expect(isAsepriteArrayData(hashData)).toBe(false); + }); +}); + +// ── AsepriteSheet.parse — array form ─────────────────────────────────────────── + +describe('AsepriteSheet.parse — array form', () => { + it('returns an AsepriteSheet instance', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet).toBeInstanceOf(AsepriteSheet); + }); + + it('builds a Spritesheet with one frame per Aseprite frame', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.spritesheet).toBeInstanceOf(Spritesheet); + expect(sheet.spritesheet.frames.size).toBe(3); + }); + + it('keys spritesheet frames by zero-based index string', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.spritesheet.frames.has('0')).toBe(true); + expect(sheet.spritesheet.frames.has('1')).toBe(true); + expect(sheet.spritesheet.frames.has('2')).toBe(true); + }); + + it('maps each frame rectangle to the Aseprite frame pixel region', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + const frame2 = sheet.spritesheet.getFrame('2'); + expect(frame2.x).toBe(32); + expect(frame2.y).toBe(0); + expect(frame2.width).toBe(16); + expect(frame2.height).toBe(16); + }); + + it('forwards the supplied texture onto the Spritesheet', () => { + const texture = newTexture(); + const sheet = AsepriteSheet.parse(arrayData, texture); + expect(sheet.spritesheet.texture).toBe(texture); + }); +}); + +// ── AsepriteSheet.parse — clips from frameTags ───────────────────────────────── + +describe('AsepriteSheet.parse — clips from frameTags', () => { + it('creates one clip per frame tag', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.clips.size).toBe(2); + expect(sheet.clips.has('walk')).toBe(true); + expect(sheet.clips.has('bounce')).toBe(true); + }); + + it('resolves a clip to one rectangle per inclusive frame in the [from,to] range', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + // walk: from 0 to 1 inclusive -> 2 frames. + expect(sheet.clips.get('walk')!.frames).toHaveLength(2); + }); + + it('clip frames are live references into the spritesheet frames', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.clips.get('walk')!.frames[0]).toBe(sheet.spritesheet.getFrame('0')); + expect(sheet.clips.get('walk')!.frames[1]).toBe(sheet.spritesheet.getFrame('1')); + }); + + it('marks every clip as looping regardless of direction', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.clips.get('walk')!.loop).toBe(true); + expect(sheet.clips.get('bounce')!.loop).toBe(true); + }); + + it('does NOT expand a pingpong tag into a reversed segment (plays only from->to)', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + // bounce: from 1 to 2 pingpong -> 2 frames (1, 2), not 3 (1, 2, 1). + expect(sheet.clips.get('bounce')!.frames).toHaveLength(2); + }); + + it('derives clip fps from the average frame duration (100ms -> 10fps)', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.clips.get('walk')!.fps).toBe(10); + }); +}); + +// ── AsepriteSheet.parse — fps averaging and fallbacks ────────────────────────── + +describe('AsepriteSheet.parse — fps derivation', () => { + function makeData(durations: number[], tag: { from: number; to: number }): AsepriteData { + return { + frames: durations.map((duration, i) => ({ + duration, + frame: { x: i * 16, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + sourceSize: { w: 16, h: 16 }, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + })), + meta: { + app: 'aseprite', + version: '1.3', + image: 'x.png', + format: 'RGBA8888', + size: { w: 48, h: 16 }, + scale: '1', + frameTags: [{ name: 'clip', from: tag.from, to: tag.to, direction: 'forward' }], + }, + }; + } + + it('averages mixed durations across the range (100/200/300 -> 200ms -> 5fps)', () => { + const sheet = AsepriteSheet.parse(makeData([100, 200, 300], { from: 0, to: 2 }), newTexture()); + expect(sheet.clips.get('clip')!.fps).toBe(5); + }); + + it('falls back to 12fps when all frame durations are zero', () => { + const sheet = AsepriteSheet.parse(makeData([0, 0, 0], { from: 0, to: 2 }), newTexture()); + expect(sheet.clips.get('clip')!.fps).toBe(12); + }); +}); + +// ── AsepriteSheet.parse — frame-index edge cases ─────────────────────────────── + +describe('AsepriteSheet.parse — frame-index handling in tags', () => { + function makeData(tag: { from: number; to: number }): AsepriteData { + return { + frames: [0, 1, 2].map(i => ({ + duration: 100, + frame: { x: i * 16, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + sourceSize: { w: 16, h: 16 }, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + })), + meta: { + app: 'aseprite', + version: '1.3', + image: 'x.png', + format: 'RGBA8888', + size: { w: 48, h: 16 }, + scale: '1', + frameTags: [{ name: 'clip', from: tag.from, to: tag.to, direction: 'forward' }], + }, + }; + } + + it('silently skips out-of-range frame indices in a tag', () => { + // from 1 to 10 against 3 frames -> only indices 1 and 2 resolve. + const sheet = AsepriteSheet.parse(makeData({ from: 1, to: 10 }), newTexture()); + expect(sheet.clips.get('clip')!.frames).toHaveLength(2); + }); + + it('omits a clip whose entire range is out of bounds (zero resolved frames)', () => { + const sheet = AsepriteSheet.parse(makeData({ from: 5, to: 9 }), newTexture()); + expect(sheet.clips.has('clip')).toBe(false); + }); +}); + +// ── AsepriteSheet.parse — missing frameTags ──────────────────────────────────── + +describe('AsepriteSheet.parse — no frame tags', () => { + it('produces an empty clips map when meta.frameTags is absent', () => { + const data: AsepriteData = { + frames: [ + { + duration: 100, + frame: { x: 0, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + sourceSize: { w: 16, h: 16 }, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + }, + ], + meta: { + app: 'aseprite', + version: '1.3', + image: 'x.png', + format: 'RGBA8888', + size: { w: 16, h: 16 }, + scale: '1', + }, + }; + const sheet = AsepriteSheet.parse(data, newTexture()); + expect(sheet.clips.size).toBe(0); + }); +}); + +// ── AsepriteSheet.parse — hash form ──────────────────────────────────────────── + +describe('AsepriteSheet.parse — hash form', () => { + it('produces the same frame count and index-string keys as the array form', () => { + const sheet = AsepriteSheet.parse(hashData, newTexture()); + expect(sheet.spritesheet.frames.size).toBe(3); + // Frames are re-keyed by ordinal index, NOT by the hash filename keys. + expect(sheet.spritesheet.frames.has('0')).toBe(true); + expect(sheet.spritesheet.frames.has('hero 0.aseprite')).toBe(false); + }); + + it('preserves frame order from object insertion order', () => { + const sheet = AsepriteSheet.parse(hashData, newTexture()); + expect(sheet.spritesheet.getFrame('1').x).toBe(16); + }); + + it('builds the same clips as the array form', () => { + const sheet = AsepriteSheet.parse(hashData, newTexture()); + expect([...sheet.clips.keys()].sort()).toEqual(['bounce', 'walk']); + }); +}); + +// ── createAnimatedSprite ─────────────────────────────────────────────────────── + +describe('AsepriteSheet.createAnimatedSprite', () => { + it('returns an AnimatedSprite with every tag pre-defined as a playable clip', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + const sprite = sheet.createAnimatedSprite(); + expect(sprite).toBeInstanceOf(AnimatedSprite); + expect(() => sprite.play('walk')).not.toThrow(); + expect(() => sprite.play('bounce')).not.toThrow(); + }); + + it('play() activates the named clip and adopts its looping flag', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + const sprite = sheet.createAnimatedSprite(); + sprite.play('walk'); + expect(sprite.currentClip).toBe('walk'); + expect(sprite.playing).toBe(true); + expect(sprite.loop).toBe(true); + }); + + it('throws when playing a clip that has no matching tag', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + const sprite = sheet.createAnimatedSprite(); + expect(() => sprite.play('missing')).toThrow(/clip "missing" is not defined/); + }); +}); + +// ── destroy ──────────────────────────────────────────────────────────────────── + +describe('AsepriteSheet.destroy', () => { + it('clears the underlying spritesheet frames', () => { + const sheet = AsepriteSheet.parse(arrayData, newTexture()); + expect(sheet.spritesheet.frames.size).toBe(3); + sheet.destroy(); + expect(sheet.spritesheet.frames.size).toBe(0); + }); +}); diff --git a/packages/exojs-aseprite/test/fixtures/hero.array.json b/packages/exojs-aseprite/test/fixtures/hero.array.json new file mode 100644 index 00000000..8fb296d9 --- /dev/null +++ b/packages/exojs-aseprite/test/fixtures/hero.array.json @@ -0,0 +1,50 @@ +{ + "frames": [ + { + "filename": "hero 0.aseprite", + "frame": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + }, + { + "filename": "hero 1.aseprite", + "frame": { "x": 16, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + }, + { + "filename": "hero 2.aseprite", + "frame": { "x": 32, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + } + ], + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7", + "image": "hero.png", + "format": "RGBA8888", + "size": { "w": 48, "h": 16 }, + "scale": "1", + "frameTags": [ + { "name": "walk", "from": 0, "to": 1, "direction": "forward" }, + { "name": "bounce", "from": 1, "to": 2, "direction": "pingpong" } + ], + "slices": [ + { + "name": "hitbox", + "color": "#0000ffff", + "keys": [{ "frame": 0, "bounds": { "x": 2, "y": 2, "w": 12, "h": 12 } }] + } + ] + } +} diff --git a/packages/exojs-aseprite/test/fixtures/hero.hash.json b/packages/exojs-aseprite/test/fixtures/hero.hash.json new file mode 100644 index 00000000..397fb0b4 --- /dev/null +++ b/packages/exojs-aseprite/test/fixtures/hero.hash.json @@ -0,0 +1,47 @@ +{ + "frames": { + "hero 0.aseprite": { + "frame": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + }, + "hero 1.aseprite": { + "frame": { "x": 16, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + }, + "hero 2.aseprite": { + "frame": { "x": 32, "y": 0, "w": 16, "h": 16 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, + "sourceSize": { "w": 16, "h": 16 }, + "duration": 100 + } + }, + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7", + "image": "hero.png", + "format": "RGBA8888", + "size": { "w": 48, "h": 16 }, + "scale": "1", + "frameTags": [ + { "name": "walk", "from": 0, "to": 1, "direction": "forward" }, + { "name": "bounce", "from": 1, "to": 2, "direction": "pingpong" } + ], + "slices": [ + { + "name": "hitbox", + "color": "#0000ffff", + "keys": [{ "frame": 0, "bounds": { "x": 2, "y": 2, "w": 12, "h": 12 } }] + } + ] + } +} diff --git a/packages/exojs-aseprite/tsconfig.json b/packages/exojs-aseprite/tsconfig.json new file mode 100644 index 00000000..e427ecb7 --- /dev/null +++ b/packages/exojs-aseprite/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "customConditions": ["@codexo/source"], + "paths": { + "@codexo/exojs": ["../../src/index.ts"], + "@codexo/exojs/extensions": ["../../src/extensions/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/exojs-config/rollup/index.js b/packages/exojs-config/rollup/index.js index 66323b89..b0254181 100644 --- a/packages/exojs-config/rollup/index.js +++ b/packages/exojs-config/rollup/index.js @@ -24,20 +24,26 @@ const corePaths = { }; /** - * @param {{ root: string, sourceCondition: string | null, inputs?: string[] }} opts + * @param {{ root: string, sourceCondition: string | null, inputs?: string[], external?: string[] }} opts + * `external` lists additional bare package names (e.g. `'react'`) to mark + * external alongside the always-external `@codexo/exojs*` core. Subpaths + * (e.g. `react/jsx-runtime`) are matched too. * @returns {import('rollup').RollupOptions} */ export function createExtensionConfig(opts) { - const { root, sourceCondition, inputs = ['src/index.ts', 'src/register.ts'] } = opts; + const { root, sourceCondition, inputs = ['src/index.ts', 'src/register.ts'], external = [] } = opts; const defines = createBuildDefinesFromRepo({ mode: process.env.EXOJS_ENV === 'development' ? 'development' : 'production', packageDir: root, }); + const isExternal = id => + id.startsWith('@codexo/exojs') || external.some(name => id === name || id.startsWith(`${name}/`)); + return { input: inputs, - external: id => id.startsWith('@codexo/exojs'), + external: isExternal, output: { dir: 'dist/esm', format: 'es', diff --git a/packages/exojs-ldtk/LICENSE b/packages/exojs-ldtk/LICENSE new file mode 100644 index 00000000..dfb7cd04 --- /dev/null +++ b/packages/exojs-ldtk/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-ldtk/README.md b/packages/exojs-ldtk/README.md new file mode 100644 index 00000000..9263c9fe --- /dev/null +++ b/packages/exojs-ldtk/README.md @@ -0,0 +1,89 @@ +# @codexo/exojs-ldtk + +Official ExoJS extension for loading [LDtk](https://ldtk.io) level files (`.ldtk`) into runtime +`TileMap`s — one per LDtk level — ready to render with the generic tilemap node. + +## Installation + +```sh +npm install @codexo/exojs @codexo/exojs-ldtk +``` + +`@codexo/exojs` is a peer dependency. `@codexo/exojs-tilemap` is a regular dependency and is +installed transitively — you do not need to install it manually. + +## What this package provides + +- `LdtkMap` — parsed LDtk world; the result of `loader.load(LdtkMap, url)`. Exposes the raw `data`, + the converted runtime `levels` (`readonly TileMap[]`, in document order), and + `getLevelByName(identifier)` +- `ldtkToTileMap` — convert a single LDtk level to a `TileMap` (used internally; available for + custom pipelines), plus its `LdtkToTileMapOptions` +- `ldtkExtension` — extension descriptor; depends on `tilemapExtension` automatically +- `ldtkMapBinding` — the underlying `AssetBinding` (advanced/custom wiring) +- The raw LDtk JSON types (`LdtkData`, `LdtkLevel`, `LdtkLayerInstance`, `LdtkEntityInstance`, …) + and the flip-bit constants (`ldtkFlipX`, `ldtkFlipY`, `ldtkFlipXy`, `ldtkFlipNone`) +- `TileMap`, `TileMapNode`, `TileMapView`, `TileLayer`, `TileSet`, `ObjectLayer`, … re-exported + from `@codexo/exojs-tilemap` (same class identity — `instanceof TileMap` holds across both import + paths) + +## Usage + +Register the extension and load a `.ldtk` world. One extension enables **both** loading and +rendering — `ldtkExtension` depends on `tilemapExtension`, so the tile chunk renderer bindings are +materialised automatically: + +```ts +import { Application } from '@codexo/exojs'; +import { LdtkMap, TileMapNode, ldtkExtension } from '@codexo/exojs-ldtk'; + +const app = new Application({ extensions: [ldtkExtension] }); + +const world = await app.loader.load(LdtkMap, 'levels/world.ldtk'); + +// Render the first level (each LDtk level is its own TileMap): +const level = world.getLevelByName('Level_0') ?? world.levels[0]; +app.scene.root.addChild(new TileMapNode(level)); +``` + +`TileMapNode` is the same class exported by `@codexo/exojs-tilemap` (see its +[README](https://www.npmjs.com/package/@codexo/exojs-tilemap) for the rendering/culling model and +actor interleaving). + +## `/register` convenience entry + +Importing `/register` registers `ldtkExtension` (and its `tilemapExtension` dependency) in the +global `ExtensionRegistry`, so subsequently created Applications that use global defaults pick them +up automatically: + +```ts +// Side effect: registers ldtkExtension in the global ExtensionRegistry. +import '@codexo/exojs-ldtk/register'; + +// All named exports are also re-exported from /register: +import { LdtkMap, ldtkExtension } from '@codexo/exojs-ldtk/register'; +``` + +This is the only side-effectful entry — importing the package root (`@codexo/exojs-ldtk`) does +**not** register anything. + +## Texture ownership + +Tileset textures are loaded via the Loader and stay in the Loader cache. `LdtkMap.destroy()` +destroys the owned runtime `TileMap`s but does **not** unload textures (Loader-owned) or remove any +scene nodes — the application owns those. + +## Core compatibility + +| `@codexo/exojs-ldtk` | `@codexo/exojs` | +|---|---| +| 0.14.x | 0.14.x | + +## Links + +- [API reference](https://exojs.dev/api/exojs-ldtk) +- [LDtk level editor](https://ldtk.io) + +## License + +MIT © Codexo diff --git a/packages/exojs-ldtk/package.json b/packages/exojs-ldtk/package.json new file mode 100644 index 00000000..42fa3fdb --- /dev/null +++ b/packages/exojs-ldtk/package.json @@ -0,0 +1,56 @@ +{ + "name": "@codexo/exojs-ldtk", + "version": "0.14.0", + "description": "LDtk level format asset extension for ExoJS.", + "repository": { + "type": "git", + "url": "git+https://github.com/Exoridus/ExoJS.git", + "directory": "packages/exojs-ldtk" + }, + "type": "module", + "sideEffects": [ + "./dist/esm/register.js" + ], + "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" + }, + "./register": { + "types": "./dist/esm/register.d.ts", + "import": "./dist/esm/register.js", + "default": "./dist/esm/register.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/esm/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:production", + "build:dev": "tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:development", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "vitest run --root ../.. --project=exojs-ldtk" + }, + "peerDependencies": { + "@codexo/exojs": "0.14.x" + }, + "dependencies": { + "@codexo/exojs-tilemap": "workspace:*" + }, + "devDependencies": { + "@codexo/exojs": "workspace:*", + "@codexo/exojs-config": "workspace:*" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/exojs-ldtk/rollup.config.ts b/packages/exojs-ldtk/rollup.config.ts new file mode 100644 index 00000000..02415a73 --- /dev/null +++ b/packages/exojs-ldtk/rollup.config.ts @@ -0,0 +1,8 @@ +import { createExtensionConfig } from '@codexo/exojs-config/rollup'; + +// LDtk has no package-internal `#` imports (all same-directory `./`), so no +// source condition / node-resolve is needed; Core's `#` resolves to its dist. +export default createExtensionConfig({ + root: import.meta.dirname, + sourceCondition: null, +}); diff --git a/packages/exojs-ldtk/src/LdtkData.ts b/packages/exojs-ldtk/src/LdtkData.ts new file mode 100644 index 00000000..dbafc0ca --- /dev/null +++ b/packages/exojs-ldtk/src/LdtkData.ts @@ -0,0 +1,201 @@ +/** + * TypeScript types for the LDtk JSON format (version 1.5.x). + * + * These interfaces model the root LDtk JSON document as produced by the LDtk + * level editor. Only the fields consumed by the ExoJS runtime adapter are + * declared here; unknown fields are not stripped at parse time. + * + * @see https://ldtk.io/json/ + */ + +/* eslint-disable @typescript-eslint/naming-convention -- LDtk uses __ prefix for runtime fields */ + +// ── Tile data ───────────────────────────────────────────────────────────────── + +/** Flip-bit constants for {@link LdtkTileData.f}. */ +export const ldtkFlipNone = 0; +export const ldtkFlipX = 1; +export const ldtkFlipY = 2; +export const ldtkFlipXy = 3; + +/** A single tile placed in a Tiles or AutoLayer layer instance. */ +export interface LdtkTileData { + /** Pixel position `[x, y]` of this tile within the layer. */ + readonly px: readonly [number, number]; + /** Source position `[x, y]` in the tileset image (top-left of the tile). */ + readonly src: readonly [number, number]; + /** + * Flip bits: `0` = none, `1` = flipX, `2` = flipY, `3` = flipX + flipY. + * Use {@link LDTK_FLIP_X} / {@link LDTK_FLIP_Y} constants for clarity. + */ + readonly f: number; + /** Local tile index in the owning tileset. */ + readonly t: number; + /** Per-tile opacity in `[0, 1]`. Defaults to `1` when absent. */ + readonly a?: number; +} + +// ── Entity data ─────────────────────────────────────────────────────────────── + +/** A field value on an entity or level instance. */ +export interface LdtkFieldInstance { + readonly __identifier: string; + readonly __type: string; + readonly __value: unknown; +} + +/** An entity instance placed in an Entities layer. */ +export interface LdtkEntityInstance { + /** Entity definition identifier (class name). */ + readonly __identifier: string; + /** Alias of `__identifier`, mirrors the entity definition type. */ + readonly __type: string; + /** Pixel position `[x, y]` of the entity origin. */ + readonly px: readonly [number, number]; + readonly width: number; + readonly height: number; + readonly fieldInstances: readonly LdtkFieldInstance[]; + /** Globally unique instance id (UUID string). */ + readonly iid: string; + /** UID of the entity definition this instance was created from. */ + readonly defUid: number; +} + +// ── Layer instances ─────────────────────────────────────────────────────────── + +/** Discriminant string for a layer instance type. */ +export type LdtkLayerType = 'Tiles' | 'IntGrid' | 'Entities' | 'AutoLayer'; + +/** A layer instance within a level. */ +export interface LdtkLayerInstance { + /** Layer definition identifier. */ + readonly __identifier: string; + /** Layer type discriminant. */ + readonly __type: LdtkLayerType; + /** Layer width in grid cells. */ + readonly __cWid: number; + /** Layer height in grid cells. */ + readonly __cHei: number; + /** Grid / tile size in pixels. */ + readonly __gridSize: number; + /** UID of the layer definition. */ + readonly layerDefUid: number; + /** UID of the parent level. */ + readonly levelId: number; + readonly visible: boolean; + /** Globally unique instance id (UUID string). */ + readonly iid: string; + /** + * UID of the tileset used by this layer. + * Present for `Tiles`, `AutoLayer`, and `IntGrid` layers that use a tileset. + */ + readonly __tilesetDefUid?: number; + /** Placed tiles for `Tiles` layer type. */ + readonly gridTiles?: readonly LdtkTileData[]; + /** Auto-computed tiles for `AutoLayer` (and `IntGrid` + auto-rules) layer types. */ + readonly autoLayerTiles?: readonly LdtkTileData[]; + /** Entity instances for `Entities` layer type. */ + readonly entityInstances?: readonly LdtkEntityInstance[]; + /** + * Flat CSV array of IntGrid values for `IntGrid` layer type. + * Index = `y * __cWid + x`. `0` = empty cell. + */ + readonly intGridCsv?: readonly number[]; + /** Horizontal pixel offset applied to the layer. */ + readonly pxOffsetX?: number; + /** Vertical pixel offset applied to the layer. */ + readonly pxOffsetY?: number; + /** Layer opacity in `[0, 1]`. */ + readonly opacity?: number; +} + +// ── Levels ──────────────────────────────────────────────────────────────────── + +/** A level in the LDtk world. */ +export interface LdtkLevel { + /** Human-readable level identifier (unique within the world). */ + readonly identifier: string; + readonly uid: number; + /** Globally unique instance id (UUID string). */ + readonly iid: string; + /** World-space X position of the level's top-left corner in pixels. */ + readonly worldX: number; + /** World-space Y position of the level's top-left corner in pixels. */ + readonly worldY: number; + /** Level width in pixels. */ + readonly pxWid: number; + /** Level height in pixels. */ + readonly pxHei: number; + readonly bgColor?: string; + /** + * Layer instances in this level (top-to-bottom render order). + * `null` when the level is stored in a separate `.ldtkl` file and has not + * been loaded yet. + */ + readonly layerInstances: readonly LdtkLayerInstance[] | null; + readonly fieldInstances?: readonly LdtkFieldInstance[]; + /** Relative path to an external `.ldtkl` file for multi-world setups. */ + readonly externalRelPath?: string; +} + +// ── Definitions ─────────────────────────────────────────────────────────────── + +/** Tileset definition from `defs.tilesets`. */ +export interface LdtkTilesetDef { + readonly uid: number; + /** Human-readable tileset identifier. */ + readonly identifier: string; + /** + * Relative path to the tileset atlas image. + * `null` for internal / embedded tilesets with no image. + */ + readonly relPath: string | null; + /** Tile grid size (both width and height) in pixels. */ + readonly tileGridSize: number; + /** Tileset image width in pixels. */ + readonly pxWid: number; + /** Tileset image height in pixels. */ + readonly pxHei: number; + /** Pixel spacing between tiles in the atlas. */ + readonly spacing?: number; + /** Pixel padding (margin) around the atlas edges. */ + readonly padding?: number; +} + +/** An IntGrid value definition (maps a raw int to a named, coloured entry). */ +export interface LdtkIntGridValueDef { + readonly value: number; + readonly identifier: string | null; + readonly color: string; +} + +/** Layer definition from `defs.layers`. */ +export interface LdtkLayerDef { + readonly uid: number; + readonly identifier: string; + readonly type: LdtkLayerType; + /** UID of the default tileset for this layer. `null` or absent = none. */ + readonly tilesetDefUid?: number | null; + readonly gridSize: number; + readonly intGridValues?: readonly LdtkIntGridValueDef[]; +} + +/** Top-level definitions block (`defs`). */ +export interface LdtkDefs { + readonly tilesets: readonly LdtkTilesetDef[]; + readonly layers: readonly LdtkLayerDef[]; +} + +// ── Root document ───────────────────────────────────────────────────────────── + +/** Root LDtk JSON document (`*.ldtk`). */ +export interface LdtkData { + /** LDtk JSON format version string (e.g. `"1.5.3"`). */ + readonly jsonVersion: string; + readonly worldGridWidth?: number; + readonly worldGridHeight?: number; + readonly defaultGridSize?: number; + readonly bgColor?: string; + readonly defs: LdtkDefs; + readonly levels: readonly LdtkLevel[]; +} diff --git a/packages/exojs-ldtk/src/LdtkMap.ts b/packages/exojs-ldtk/src/LdtkMap.ts new file mode 100644 index 00000000..c4b73d61 --- /dev/null +++ b/packages/exojs-ldtk/src/LdtkMap.ts @@ -0,0 +1,60 @@ +import type { TileMap } from '@codexo/exojs-tilemap'; + +import type { LdtkData } from './LdtkData'; + +/** + * A parsed LDtk world document: holds the raw JSON data and the converted + * runtime {@link TileMap} for each level. + * + * `LdtkMap` is the parsed source model. Each LDtk level is independently + * convertible to a format-independent `TileMap`; access them via + * {@link levels} (by document order) or by name via {@link getLevelByName}. + * + * Construction is cheap — the runtime `TileMap[]` is supplied externally + * (built by {@link import('./ldtkToTileMap').ldtkToTileMap}). The map does + * **not** own tileset textures; those remain in the Loader cache. + */ +export class LdtkMap { + /** Resolved URL this map was loaded from. */ + public readonly source: string; + /** The raw parsed LDtk document. */ + public readonly data: LdtkData; + /** + * Runtime TileMaps — one per LDtk level, in document order. + * + * The index here corresponds to `data.levels[i]`. Levels for which + * conversion was skipped (e.g. external `.ldtkl` files not yet loaded) + * are `undefined` at that position. + */ + public readonly levels: readonly TileMap[]; + + public constructor(source: string, data: LdtkData, levels: readonly TileMap[]) { + this.source = source; + this.data = data; + this.levels = levels; + } + + /** + * Find a level's runtime {@link TileMap} by the LDtk level `identifier`, + * or `undefined` when no level with that name exists. + * + * The lookup is O(n) in the number of levels. + */ + public getLevelByName(identifier: string): TileMap | undefined { + const index = this.data.levels.findIndex(level => level.identifier === identifier); + if (index === -1) return undefined; + return this.levels[index]; + } + + /** + * Destroy all owned runtime TileMaps. + * + * Is idempotent. Does NOT destroy tileset textures (Loader-owned) or any + * SceneNodes — the application is responsible for those. + */ + public destroy(): void { + for (const level of this.levels) { + level.destroy(); + } + } +} diff --git a/packages/exojs-ldtk/src/index.ts b/packages/exojs-ldtk/src/index.ts new file mode 100644 index 00000000..15bc3f31 --- /dev/null +++ b/packages/exojs-ldtk/src/index.ts @@ -0,0 +1,5 @@ +// @codexo/exojs-ldtk — side-effect-free root entry. +// Importing this entry does NOT register the extension globally. +// Use @codexo/exojs-ldtk/register for global registration. + +export * from './public'; diff --git a/packages/exojs-ldtk/src/ldtkBinding.ts b/packages/exojs-ldtk/src/ldtkBinding.ts new file mode 100644 index 00000000..f228b790 --- /dev/null +++ b/packages/exojs-ldtk/src/ldtkBinding.ts @@ -0,0 +1,30 @@ +import type { AssetBinding, AssetHandler } from '@codexo/exojs/extensions'; + +import { LdtkMap } from './LdtkMap'; +import { loadLdtkMap } from './loadLdtkMap'; + +/** + * Declarative asset binding for {@link LdtkMap}. + * + * Claims the `ldtk` file extension so that: + * - `loader.load(LdtkMap, 'world.ldtk')` — returns the parsed + * {@link LdtkMap} with all levels pre-converted to runtime + * {@link import('@codexo/exojs-tilemap').TileMap}s. + * - `loader.load('world.ldtk')` — auto-routed to `LdtkMap` via + * the `ExtensionTypeMap` augmentation in `public.ts`. + * + * Each loaded level's TileMap is accessible via {@link LdtkMap.levels} or + * {@link LdtkMap.getLevelByName}. + */ +export const ldtkMapBinding = { + type: LdtkMap, + typeNames: ['ldtkMap'], + extensions: ['ldtk'], + create() { + return { + async load(req, ctx) { + return loadLdtkMap(req.source, ctx); + }, + } satisfies AssetHandler; + }, +} satisfies AssetBinding; diff --git a/packages/exojs-ldtk/src/ldtkExtension.ts b/packages/exojs-ldtk/src/ldtkExtension.ts new file mode 100644 index 00000000..2e10eb48 --- /dev/null +++ b/packages/exojs-ldtk/src/ldtkExtension.ts @@ -0,0 +1,27 @@ +import type { AssetBinding, Extension } from '@codexo/exojs/extensions'; +import { tilemapExtension } from '@codexo/exojs-tilemap'; + +import { ldtkMapBinding } from './ldtkBinding'; + +/** + * Default immutable LDtk extension descriptor. + * + * Registers one asset binding: + * - {@link ldtkMapBinding} — `loader.load(LdtkMap, 'world.ldtk')` → fetches + * the `.ldtk` JSON, loads all referenced tileset images, and returns a + * fully assembled {@link LdtkMap} with one runtime + * {@link import('@codexo/exojs-tilemap').TileMap} per level. + * + * Depends on {@link tilemapExtension} so that snapshot construction always + * materialises the generic tilemap runtime before the LDtk adapter. + * + * Use with `ApplicationOptions.extensions` or call + * `import '@codexo/exojs-ldtk/register'` for global auto-registration. + */ +export const ldtkExtension: Extension = Object.freeze({ + id: '@codexo/exojs-ldtk', + dependencies: [tilemapExtension], + // Localized erasure cast: typed binding meets the untyped Extension.assets + // contract here. Runtime behaviour is unaffected. + assets: [ldtkMapBinding] as unknown as AssetBinding[], +}); diff --git a/packages/exojs-ldtk/src/ldtkToTileMap.ts b/packages/exojs-ldtk/src/ldtkToTileMap.ts new file mode 100644 index 00000000..c5353031 --- /dev/null +++ b/packages/exojs-ldtk/src/ldtkToTileMap.ts @@ -0,0 +1,281 @@ +import type { TileMapObject, TileProperties, TilePropertyValue, TileSet } from '@codexo/exojs-tilemap'; +import { ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap } from '@codexo/exojs-tilemap'; + +import type { + LdtkData, + LdtkEntityInstance, + LdtkFieldInstance, + LdtkLayerInstance, + LdtkLevel, + LdtkTileData, +} from './LdtkData'; +import { ldtkFlipX, ldtkFlipY } from './LdtkData'; +import { LdtkMap } from './LdtkMap'; + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Options for {@link ldtkToTileMap}. + */ +export interface LdtkToTileMapOptions { + /** + * Source URL of the loaded `.ldtk` file, used as {@link LdtkMap.source}. + * Defaults to an empty string when omitted (e.g. programmatic / test usage). + */ + readonly source?: string; + /** + * Pre-loaded runtime {@link TileSet}s keyed by LDtk tileset UID. + * + * When provided, tile layers are populated with tile data from + * `gridTiles` / `autoLayerTiles`. Without this map, tile layers are created + * with the correct dimensions but no tiles are placed — only entity and + * object layers carry data. + * + * Populate via {@link import('./loadLdtkMap').loadLdtkMap} for asset-loading + * flows, or pass a hand-crafted map for testing. + */ + readonly tilesets?: ReadonlyMap; +} + +/** + * Convert a raw {@link LdtkData} document into an {@link LdtkMap} containing + * one runtime {@link TileMap} per LDtk level. + * + * Tile layers (`Tiles` / `AutoLayer`) become renderable `TileLayer`s. IntGrid + * layers become dimension-correct `TileLayer`s — tile data is placed only when + * the layer carries `autoLayerTiles`. Entity layers become data-only + * `ObjectLayer`s with entity position, size, and scalar field properties. + * + * Pass `options.tilesets` to populate tile data; omit it for structure-only + * conversion (useful in unit tests that do not need textures). + */ +export function ldtkToTileMap(data: LdtkData, options?: LdtkToTileMapOptions): LdtkMap { + const source = options?.source ?? ''; + const tilesets = options?.tilesets ?? new Map(); + + const levels = data.levels.map((level, levelIndex) => + convertLevel(level, levelIndex, data, tilesets), + ); + + return new LdtkMap(source, data, levels); +} + +// ── Level conversion ────────────────────────────────────────────────────────── + +// eslint-disable-next-line complexity +function convertLevel( + level: LdtkLevel, + levelIndex: number, + data: LdtkData, + tilesets: ReadonlyMap, +): TileMap { + // Derive map-level tile size from the first non-entity layer, or fall back. + const gridSize = pickLevelGridSize(level, data.defaultGridSize ?? 16); + const mapWidth = Math.max(1, Math.ceil(level.pxWid / gridSize)); + const mapHeight = Math.max(1, Math.ceil(level.pxHei / gridSize)); + + const runtimeTilesets: TileSet[] = [...tilesets.values()]; + const runtimeLayers: TileLayer[] = []; + const runtimeObjectLayers: ObjectLayer[] = []; + + // LDtk stores layers top-to-bottom (first = top-most); preserve that order. + const layerInstances = level.layerInstances ?? []; + let entityCounter = 0; + + for (const layerInst of layerInstances) { + const layerGridSize = layerInst.__gridSize; + // Layer IDs must be unique within a TileMap (= one level). + // Use layerDefUid directly — it is unique per layer definition in the file. + const layerId = layerInst.layerDefUid; + + switch (layerInst.__type) { + case 'Tiles': + case 'AutoLayer': { + const rLayer = makeTileLayer(layerInst, layerId, runtimeTilesets); + const tiles = + layerInst.__type === 'Tiles' + ? (layerInst.gridTiles ?? []) + : (layerInst.autoLayerTiles ?? []); + const tsUid = layerInst.__tilesetDefUid; + if (tsUid !== undefined) { + const rts = tilesets.get(tsUid); + if (rts) populateTileLayer(rLayer, tiles, rts, layerGridSize); + } + runtimeLayers.push(rLayer); + break; + } + + case 'IntGrid': { + const rLayer = makeTileLayer(layerInst, layerId, runtimeTilesets); + // IntGrid layers may carry auto-tiles when "Auto-layer" rules are + // configured. Use those for rendering; raw intGridCsv is data-only. + const autoTiles = layerInst.autoLayerTiles ?? []; + const tsUid = layerInst.__tilesetDefUid; + if (autoTiles.length > 0 && tsUid !== undefined) { + const rts = tilesets.get(tsUid); + if (rts) populateTileLayer(rLayer, autoTiles, rts, layerGridSize); + } + runtimeLayers.push(rLayer); + break; + } + + case 'Entities': { + const objects = convertEntityLayer( + layerInst, + layerGridSize, + levelIndex, + entityCounter, + ); + entityCounter += layerInst.entityInstances?.length ?? 0; + runtimeObjectLayers.push( + new ObjectLayer({ + id: layerId, + name: layerInst.__identifier, + visible: layerInst.visible, + opacity: layerInst.opacity ?? 1, + offsetX: layerInst.pxOffsetX ?? 0, + offsetY: layerInst.pxOffsetY ?? 0, + objects, + }), + ); + break; + } + } + } + + return new TileMap({ + name: level.identifier, + width: mapWidth, + height: mapHeight, + tileWidth: gridSize, + tileHeight: gridSize, + tilesets: runtimeTilesets, + layers: runtimeLayers, + objectLayers: runtimeObjectLayers, + properties: { + ldtkUid: level.uid, + ldtkIid: level.iid, + worldX: level.worldX, + worldY: level.worldY, + }, + }); +} + +// ── Helpers: TileLayer ──────────────────────────────────────────────────────── + +function makeTileLayer( + layerInst: LdtkLayerInstance, + layerId: number, + tilesets: readonly TileSet[], +): TileLayer { + return new TileLayer({ + id: layerId, + name: layerInst.__identifier, + width: layerInst.__cWid, + height: layerInst.__cHei, + tilesets, + tileWidth: layerInst.__gridSize, + tileHeight: layerInst.__gridSize, + visible: layerInst.visible, + opacity: layerInst.opacity ?? 1, + offsetX: layerInst.pxOffsetX ?? 0, + offsetY: layerInst.pxOffsetY ?? 0, + }); +} + +function populateTileLayer( + layer: TileLayer, + tiles: readonly LdtkTileData[], + tileset: TileSet, + gridSize: number, +): void { + for (const tile of tiles) { + const tx = Math.floor(tile.px[0] / gridSize); + const ty = Math.floor(tile.px[1] / gridSize); + if (!layer.inBounds(tx, ty)) continue; + + const localTileId = tile.t; + if (localTileId < 0 || localTileId >= tileset.tileCount) continue; + + const f = tile.f; + layer.setTileAt(tx, ty, { + tileset, + localTileId, + transform: { + flipX: (f & ldtkFlipX) !== 0, + flipY: (f & ldtkFlipY) !== 0, + diagonal: false, + }, + }); + } +} + +// ── Helpers: ObjectLayer ────────────────────────────────────────────────────── + +function convertEntityLayer( + layerInst: LdtkLayerInstance, + _gridSize: number, + levelIndex: number, + baseCounter: number, +): TileMapObject[] { + const instances = layerInst.entityInstances ?? []; + const objects: TileMapObject[] = []; + + for (let i = 0; i < instances.length; i++) { + const entity = instances[i]; + if (entity === undefined) continue; + // Build a deterministic numeric id: (levelIndex * 1_000_000) + counter. + const id = levelIndex * 1_000_000 + baseCounter + i; + objects.push(convertEntity(entity, id)); + } + + return objects; +} + +function convertEntity(entity: LdtkEntityInstance, id: number): TileMapObject { + return { + kind: 'rectangle', + id, + name: entity.__identifier, + type: entity.__identifier, + x: entity.px[0], + y: entity.px[1], + width: entity.width, + height: entity.height, + rotation: 0, + visible: true, + properties: convertFieldInstances(entity.fieldInstances), + }; +} + +/** + * Project LDtk field instances to a flat {@link TileProperties} bag. + * Only scalar values (string, number, boolean) are forwarded; complex types + * (arrays, colours, enums-as-objects) are silently skipped. + */ +function convertFieldInstances(fields: readonly LdtkFieldInstance[]): TileProperties { + if (fields.length === 0) return Object.freeze({}); + const out: Record = {}; + for (const field of fields) { + const v = field.__value; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + out[field.__identifier] = v; + } + } + return Object.freeze(out); +} + +// ── Helpers: grid size ──────────────────────────────────────────────────────── + +function pickLevelGridSize(level: LdtkLevel, fallback: number): number { + const instances = level.layerInstances ?? []; + for (const layer of instances) { + if (layer.__type !== 'Entities' && layer.__gridSize > 0) { + return layer.__gridSize; + } + } + return fallback; +} + +// Re-export identity transform constant for convenience (tree-shake friendly). +export { TILE_TRANSFORM_IDENTITY }; diff --git a/packages/exojs-ldtk/src/loadLdtkMap.ts b/packages/exojs-ldtk/src/loadLdtkMap.ts new file mode 100644 index 00000000..5953124e --- /dev/null +++ b/packages/exojs-ldtk/src/loadLdtkMap.ts @@ -0,0 +1,101 @@ +import { type AssetLoaderContext, Texture, TextureRegion } from '@codexo/exojs'; +import { TileSet } from '@codexo/exojs-tilemap'; + +import type { LdtkData, LdtkTilesetDef } from './LdtkData'; +import type { LdtkMap } from './LdtkMap'; +import { ldtkToTileMap } from './ldtkToTileMap'; + +// ── URL resolution ──────────────────────────────────────────────────────────── + +/** + * Resolve a tileset-relative path against the base `.ldtk` URL. + * Mirrors the approach used by the Tiled adapter. + */ +function resolveLdtkUrl(relPath: string, baseUrl: string): string { + return new URL(relPath, baseUrl).href; +} + +// ── Tileset loading ─────────────────────────────────────────────────────────── + +/** + * Load one LDtk tileset definition into a runtime {@link TileSet}. + * Returns `null` when the tileset has no atlas image (`relPath` is null or + * empty) — those entries are silently skipped and their tiles will not render. + */ +async function loadLdtkTileset( + def: LdtkTilesetDef, + ldtkSource: string, + context: AssetLoaderContext, +): Promise { + if (!def.relPath) return null; + + const imageUrl = resolveLdtkUrl(def.relPath, ldtkSource); + const texture = (await context.loader.load(Texture, imageUrl)) as Texture; + + const tileSize = def.tileGridSize; + const spacing = def.spacing ?? 0; + const margin = def.padding ?? 0; + + // Compute columns / tileCount from atlas dimensions. + const innerWidth = def.pxWid - margin * 2; + const innerHeight = def.pxHei - margin * 2; + const columns = Math.floor((innerWidth + spacing) / (tileSize + spacing)); + const rows = Math.floor((innerHeight + spacing) / (tileSize + spacing)); + + if (columns <= 0 || rows <= 0) return null; + + const tileCount = columns * rows; + const region = new TextureRegion(texture, { + x: 0, + y: 0, + width: def.pxWid, + height: def.pxHei, + }); + + return new TileSet({ + name: def.identifier, + texture: region, + tileWidth: tileSize, + tileHeight: tileSize, + tileCount, + columns, + spacing, + margin, + }); +} + +// ── Public loader ───────────────────────────────────────────────────────────── + +/** + * Fetch a `.ldtk` file, load all referenced tileset images, and return a + * fully assembled {@link LdtkMap} with one runtime {@link import('@codexo/exojs-tilemap').TileMap} + * per level. + * + * Tilesets without an atlas image (`relPath = null`) are silently skipped; + * their tiles will not appear in the rendered output. + * @internal + */ +export async function loadLdtkMap( + source: string, + context: AssetLoaderContext, +): Promise { + const raw = await context.fetchJson(source); + // Cast without deep validation — structural errors surface as runtime + // exceptions when we access fields during conversion. + const data = raw as LdtkData; + + // Load all referenced tilesets concurrently. + const tilesetEntries = await Promise.all( + data.defs.tilesets.map(async (def) => { + const ts = await loadLdtkTileset(def, source, context); + return [def.uid, ts] as const; + }), + ); + + const tilesets = new Map(); + for (const [uid, ts] of tilesetEntries) { + if (ts !== null) tilesets.set(uid, ts); + } + + return ldtkToTileMap(data, { source, tilesets }); +} diff --git a/packages/exojs-ldtk/src/public.ts b/packages/exojs-ldtk/src/public.ts new file mode 100644 index 00000000..30ec8a8a --- /dev/null +++ b/packages/exojs-ldtk/src/public.ts @@ -0,0 +1,83 @@ +// Side-effect-free public API for @codexo/exojs-ldtk. +// No registration is performed on import. + +// ── Extension wiring ────────────────────────────────────────────────────────── +export { ldtkMapBinding } from './ldtkBinding'; +export { ldtkExtension } from './ldtkExtension'; + +// ── Parsed source model ─────────────────────────────────────────────────────── +export { LdtkMap } from './LdtkMap'; +export type { LdtkToTileMapOptions } from './ldtkToTileMap'; +export { ldtkToTileMap } from './ldtkToTileMap'; + +// ── Raw LDtk JSON types ─────────────────────────────────────────────────────── +export type { + LdtkData, + LdtkDefs, + LdtkEntityInstance, + LdtkFieldInstance, + LdtkIntGridValueDef, + LdtkLayerDef, + LdtkLayerInstance, + LdtkLayerType, + LdtkLevel, + LdtkTileData, + LdtkTilesetDef, +} from './LdtkData'; +export { ldtkFlipNone, ldtkFlipX, ldtkFlipXy, ldtkFlipY } from './LdtkData'; + +// ── Runtime facade (re-exports from @codexo/exojs-tilemap) ─────────────────── +// These re-export the *same* module bindings — `instanceof TileMap` holds +// whether the class was imported from @codexo/exojs-tilemap or here. +export type { + ChunkCoord, + EllipseObject, + ObjectLayerOptions, + ObjectPoint, + ObjectQuery, + ObjectSchema, + PackedTile, + PointObject, + PolygonObject, + PolylineObject, + ReadonlyTileChunk, + RectangleObject, + ResolvedTile, + TileDefinition, + TileLayerOptions, + TileMapObject, + TileMapObjectKind, + TileMapOptions, + TileMapViewOptions, + TileObject, + TileProperties, + TilePropertyValue, + TileSetOptions, + TileTransform, +} from '@codexo/exojs-tilemap'; +export { + ObjectKind, + ObjectLayer, + TILE_TRANSFORM_IDENTITY, + TileLayer, + TileMap, + tilemapExtension, + TileMapView, + TileSet, +} from '@codexo/exojs-tilemap'; + +// ── Module augmentation — typed load calls ──────────────────────────────────── +import type { LdtkMap } from './LdtkMap'; + +declare module '@codexo/exojs' { + interface ExtensionTypeMap { + /** `.ldtk` path-only loads resolve to {@link LdtkMap}. */ + ldtk: LdtkMap; + } + interface AssetDefinitions { + ldtkMap: { + resource: LdtkMap; + config: { source: string }; + }; + } +} diff --git a/packages/exojs-ldtk/src/register.ts b/packages/exojs-ldtk/src/register.ts new file mode 100644 index 00000000..d55965b4 --- /dev/null +++ b/packages/exojs-ldtk/src/register.ts @@ -0,0 +1,13 @@ +// @codexo/exojs-ldtk/register — explicit registration entry. +// Importing this entry registers the default ldtkExtension descriptor +// in the global ExtensionRegistry. Subsequently constructed Applications +// that use global defaults will receive the LDtk extension. +// This is the only side-effectful entry in this package. + +import { ExtensionRegistry } from '@codexo/exojs/extensions'; + +import { ldtkExtension } from './ldtkExtension'; + +ExtensionRegistry.register(ldtkExtension); + +export * from './public'; diff --git a/packages/exojs-ldtk/test/fixtures/world.ldtk b/packages/exojs-ldtk/test/fixtures/world.ldtk new file mode 100644 index 00000000..608bebc9 --- /dev/null +++ b/packages/exojs-ldtk/test/fixtures/world.ldtk @@ -0,0 +1,75 @@ +{ + "jsonVersion": "1.5.3", + "defaultGridSize": 16, + "defs": { + "tilesets": [ + { + "uid": 1, + "identifier": "Atlas", + "relPath": "tiles.png", + "tileGridSize": 16, + "pxWid": 64, + "pxHei": 64, + "spacing": 0, + "padding": 0 + } + ], + "layers": [ + { "uid": 101, "identifier": "Tiles", "type": "Tiles", "gridSize": 16, "tilesetDefUid": 1 }, + { "uid": 102, "identifier": "Entities", "type": "Entities", "gridSize": 16 } + ] + }, + "levels": [ + { + "identifier": "Level_0", + "uid": 1, + "iid": "aaaaaaaa-0000-0000-0000-000000000001", + "worldX": 0, + "worldY": 0, + "pxWid": 64, + "pxHei": 64, + "layerInstances": [ + { + "__identifier": "Tiles", + "__type": "Tiles", + "__cWid": 4, + "__cHei": 4, + "__gridSize": 16, + "layerDefUid": 101, + "levelId": 1, + "visible": true, + "iid": "bbbbbbbb-0000-0000-0000-000000000001", + "__tilesetDefUid": 1, + "gridTiles": [ + { "px": [0, 0], "src": [0, 0], "f": 0, "t": 0 }, + { "px": [16, 0], "src": [16, 0], "f": 1, "t": 1 } + ], + "autoLayerTiles": [] + }, + { + "__identifier": "Entities", + "__type": "Entities", + "__cWid": 4, + "__cHei": 4, + "__gridSize": 16, + "layerDefUid": 102, + "levelId": 1, + "visible": true, + "iid": "bbbbbbbb-0000-0000-0000-000000000002", + "entityInstances": [ + { + "__identifier": "Player", + "__type": "Player", + "px": [8, 8], + "width": 16, + "height": 16, + "fieldInstances": [], + "iid": "cccccccc-0000-0000-0000-000000000001", + "defUid": 200 + } + ] + } + ] + } + ] +} diff --git a/packages/exojs-ldtk/test/ldtk-conversion.test.ts b/packages/exojs-ldtk/test/ldtk-conversion.test.ts new file mode 100644 index 00000000..e0344181 --- /dev/null +++ b/packages/exojs-ldtk/test/ldtk-conversion.test.ts @@ -0,0 +1,764 @@ +import { type Texture, TextureRegion } from '@codexo/exojs'; +import { TileSet } from '@codexo/exojs-tilemap'; +import { describe, expect, it } from 'vitest'; + +import type { + LdtkData, + LdtkEntityInstance, + LdtkLayerInstance, + LdtkLevel, +} from '../src/LdtkData'; +import { ldtkFlipNone, ldtkFlipX, ldtkFlipXy, ldtkFlipY } from '../src/LdtkData'; +import { ldtkToTileMap } from '../src/ldtkToTileMap'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fakeTexture(): Texture { + return { + width: 512, + height: 512, + uid: 0, + label: 'test', + destroy: () => {}, + destroyed: false, + } as unknown as Texture; +} + +function makeTileset(name = 'Atlas', tileCount = 4): TileSet { + return new TileSet({ + name, + texture: new TextureRegion(fakeTexture(), { x: 0, y: 0, width: 512, height: 512 }), + tileWidth: 16, + tileHeight: 16, + tileCount, + }); +} + +/** Build a single-level document containing exactly one layer instance. */ +function docWithLayer(layer: LdtkLayerInstance, level: Partial = {}): LdtkData { + return { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'L', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 64, + pxHei: 16, + layerInstances: [layer], + ...level, + }, + ], + }; +} + +// ── Flip-bit constants ────────────────────────────────────────────────────────── + +describe('LDtk flip-bit constants', () => { + it('have the LDtk-documented values', () => { + expect(ldtkFlipNone).toBe(0); + expect(ldtkFlipX).toBe(1); + expect(ldtkFlipY).toBe(2); + expect(ldtkFlipXy).toBe(3); + }); + + it('compose as an X|Y bitmask', () => { + expect(ldtkFlipX | ldtkFlipY).toBe(ldtkFlipXy); + expect(ldtkFlipXy & ldtkFlipX).not.toBe(0); + expect(ldtkFlipXy & ldtkFlipY).not.toBe(0); + expect(ldtkFlipNone & ldtkFlipX).toBe(0); + expect(ldtkFlipNone & ldtkFlipY).toBe(0); + }); +}); + +// ── Tile population + flip decoding (Tiles layer, WITH a tileset) ─────────────── + +describe('ldtkToTileMap — tile population with a tileset', () => { + const tileset = makeTileset('Atlas', 4); + + const data = docWithLayer({ + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [ + { px: [0, 0], src: [16, 0], f: 0, t: 1 }, // tx0, no flip + { px: [16, 0], src: [16, 0], f: 1, t: 1 }, // tx1, flipX + { px: [32, 0], src: [16, 0], f: 2, t: 1 }, // tx2, flipY + { px: [48, 0], src: [16, 0], f: 3, t: 1 }, // tx3, flipX + flipY + ], + autoLayerTiles: [], + }); + + function convert() { + return ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }); + } + + it('places one tile per gridTiles entry', () => { + const layer = convert().levels[0]!.layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(4); + }); + + it('decodes flip bits onto each tile transform', () => { + const layer = convert().levels[0]!.layers[0]!; + + const none = layer.getTileAt(0, 0); + expect(none).not.toBeNull(); + expect(none!.transform.flipX).toBe(false); + expect(none!.transform.flipY).toBe(false); + + const flipX = layer.getTileAt(1, 0); + expect(flipX!.transform.flipX).toBe(true); + expect(flipX!.transform.flipY).toBe(false); + + const flipY = layer.getTileAt(2, 0); + expect(flipY!.transform.flipX).toBe(false); + expect(flipY!.transform.flipY).toBe(true); + + const flipXy = layer.getTileAt(3, 0); + expect(flipXy!.transform.flipX).toBe(true); + expect(flipXy!.transform.flipY).toBe(true); + }); + + it('never sets the diagonal transform (LDtk has no anti-diagonal flip)', () => { + const layer = convert().levels[0]!.layers[0]!; + for (let tx = 0; tx < 4; tx++) { + expect(layer.getTileAt(tx, 0)!.transform.diagonal).toBe(false); + } + }); + + it('maps the LDtk tile index `t` to the resolved localTileId and tileset', () => { + const tile = convert().levels[0]!.layers[0]!.getTileAt(0, 0)!; + expect(tile.localTileId).toBe(1); + expect(tile.tileset).toBe(tileset); + }); +}); + +describe('ldtkToTileMap — tile population edge cases', () => { + const tileset = makeTileset('Atlas', 4); + + it('skips tiles whose local index is out of the tileset range', () => { + const data = docWithLayer({ + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [ + { px: [0, 0], src: [0, 0], f: 0, t: 0 }, // valid + { px: [16, 0], src: [0, 0], f: 0, t: 99 }, // t >= tileCount → skipped + { px: [32, 0], src: [0, 0], f: 0, t: -1 }, // t < 0 → skipped + ], + autoLayerTiles: [], + }); + + const layer = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]! + .layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(1); + expect(layer.getTileAt(1, 0)).toBeNull(); + expect(layer.getTileAt(2, 0)).toBeNull(); + }); + + it('skips tiles that fall outside the layer grid', () => { + const data = docWithLayer({ + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 2, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [ + { px: [0, 0], src: [0, 0], f: 0, t: 0 }, // tx0, in bounds + { px: [48, 0], src: [0, 0], f: 0, t: 0 }, // tx3, out of bounds (width 2) + ], + autoLayerTiles: [], + }); + + const layer = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]! + .layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(1); + expect(layer.getTileAt(0, 0)).not.toBeNull(); + }); + + it('places no tiles when the layer references a tileset uid not in the map', () => { + const data = docWithLayer({ + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 999, // no entry in tilesets map + gridTiles: [{ px: [0, 0], src: [0, 0], f: 0, t: 0 }], + autoLayerTiles: [], + }); + + const layer = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]! + .layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(0); + }); +}); + +// ── AutoLayer + IntGrid render-tile sourcing ──────────────────────────────────── + +describe('ldtkToTileMap — AutoLayer', () => { + const tileset = makeTileset('Atlas', 4); + + it('renders from autoLayerTiles (not gridTiles) into a TileLayer', () => { + const data = docWithLayer({ + __identifier: 'Walls', + __type: 'AutoLayer', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 110, + levelId: 1, + visible: true, + iid: 'auto-1', + __tilesetDefUid: 1, + autoLayerTiles: [{ px: [16, 0], src: [0, 0], f: 1, t: 2 }], + }); + + const map = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]!; + expect(map.layers).toHaveLength(1); + expect(map.objectLayers).toHaveLength(0); + const tile = map.layers[0]!.getTileAt(1, 0)!; + expect(tile.localTileId).toBe(2); + expect(tile.transform.flipX).toBe(true); + }); +}); + +describe('ldtkToTileMap — IntGrid', () => { + const tileset = makeTileset('Atlas', 4); + + it('renders auto-tiles when an IntGrid layer carries autoLayerTiles + a tileset', () => { + const data = docWithLayer({ + __identifier: 'Collision', + __type: 'IntGrid', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 120, + levelId: 1, + visible: true, + iid: 'int-1', + __tilesetDefUid: 1, + intGridCsv: [1, 0, 0, 0], + autoLayerTiles: [{ px: [0, 0], src: [0, 0], f: 0, t: 3 }], + }); + + const layer = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]! + .layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(1); + expect(layer.getTileAt(0, 0)!.localTileId).toBe(3); + }); + + it('produces a data-only (empty) TileLayer when IntGrid has no auto-tiles', () => { + const data = docWithLayer({ + __identifier: 'Collision', + __type: 'IntGrid', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 120, + levelId: 1, + visible: true, + iid: 'int-1', + __tilesetDefUid: 1, + intGridCsv: [1, 2, 3, 4], + }); + + const layer = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]! + .layers[0]!; + expect(layer.countNonEmptyTiles()).toBe(0); + expect(layer.width).toBe(4); + }); +}); + +// ── Grid-size derivation ──────────────────────────────────────────────────────── + +describe('ldtkToTileMap — level tile size derivation', () => { + const tileset = makeTileset('Atlas', 4); + + it('derives the map tile size from the first non-Entities layer', () => { + const data = docWithLayer( + { + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 2, + __cHei: 1, + __gridSize: 32, // differs from defaultGridSize (16) + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [], + autoLayerTiles: [], + }, + { pxWid: 64, pxHei: 32 }, + ); + + const map = ldtkToTileMap(data, { tilesets: new Map([[1, tileset]]) }).levels[0]!; + expect(map.tileWidth).toBe(32); + expect(map.tileHeight).toBe(32); + // 64 × 32 px at 32 px/tile → 2 × 1 tiles + expect(map.width).toBe(2); + expect(map.height).toBe(1); + }); + + it('skips Entities layers and falls back to data.defaultGridSize', () => { + const data = docWithLayer( + { + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 4, + __gridSize: 16, // ignored: Entities layers do not contribute grid size + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-1', + entityInstances: [], + }, + { pxWid: 64, pxHei: 64 }, + ); + // Override defaultGridSize to prove the fallback is used (the lone Entities + // layer must NOT contribute its own __gridSize). + const tuned: LdtkData = { ...data, defaultGridSize: 8 }; + + const map = ldtkToTileMap(tuned).levels[0]!; + expect(map.tileWidth).toBe(8); + expect(map.width).toBe(8); // 64 / 8 + }); + + it('falls back to 16 when defaultGridSize is absent', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'L', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 32, + pxHei: 32, + layerInstances: [], + }, + ], + }; + + const map = ldtkToTileMap(data).levels[0]!; + expect(map.tileWidth).toBe(16); + expect(map.width).toBe(2); + }); + + it('clamps degenerate level dimensions to a minimum of 1 tile', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'Tiny', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 0, + pxHei: 8, // ceil(8/16) = 1 + layerInstances: [], + }, + ], + }; + + const map = ldtkToTileMap(data).levels[0]!; + expect(map.width).toBe(1); + expect(map.height).toBe(1); + }); +}); + +// ── Level metadata / properties ───────────────────────────────────────────────── + +describe('ldtkToTileMap — level properties', () => { + it('stores the LDtk uid and iid alongside world coordinates', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'L', + uid: 42, + iid: 'level-iid-42', + worldX: 100, + worldY: 200, + pxWid: 32, + pxHei: 32, + layerInstances: [], + }, + ], + }; + + const map = ldtkToTileMap(data).levels[0]!; + expect(map.properties['ldtkUid']).toBe(42); + expect(map.properties['ldtkIid']).toBe('level-iid-42'); + expect(map.properties['worldX']).toBe(100); + expect(map.properties['worldY']).toBe(200); + }); + + it('tolerates a level with null layerInstances (unloaded external level)', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'External', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 48, + pxHei: 16, + layerInstances: null, + }, + ], + }; + + const map = ldtkToTileMap(data).levels[0]!; + expect(map.layers).toHaveLength(0); + expect(map.objectLayers).toHaveLength(0); + expect(map.width).toBe(3); // 48 / 16, default grid + }); +}); + +// ── TileLayer metadata passthrough ────────────────────────────────────────────── + +describe('ldtkToTileMap — TileLayer metadata', () => { + it('forwards id, grid size, visibility, opacity and offsets to the TileLayer', () => { + const data = docWithLayer({ + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: false, + opacity: 0.25, + pxOffsetX: 7, + pxOffsetY: 9, + iid: 'tiles-1', + gridTiles: [], + autoLayerTiles: [], + }); + + const layer = ldtkToTileMap(data).levels[0]!.layers[0]!; + expect(layer.id).toBe(101); + expect(layer.name).toBe('Tiles'); + expect(layer.tileWidth).toBe(16); + expect(layer.tileHeight).toBe(16); + expect(layer.visible).toBe(false); + expect(layer.opacity).toBe(0.25); + expect(layer.offsetX).toBe(7); + expect(layer.offsetY).toBe(9); + }); +}); + +// ── Entity → ObjectLayer conversion ───────────────────────────────────────────── + +describe('ldtkToTileMap — entity field projection', () => { + it('keeps scalar fields and drops complex (array/object/null) field values', () => { + const data = docWithLayer({ + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-1', + entityInstances: [ + { + __identifier: 'NPC', + __type: 'NPC', + px: [0, 0], + width: 16, + height: 16, + iid: 'npc-1', + defUid: 300, + fieldInstances: [ + { __identifier: 'hp', __type: 'Int', __value: 10 }, + { __identifier: 'label', __type: 'String', __value: 'Bob' }, + { __identifier: 'hostile', __type: 'Bool', __value: true }, + { __identifier: 'path', __type: 'Array', __value: [{ cx: 1, cy: 2 }] }, + { __identifier: 'meta', __type: 'Object', __value: { k: 1 } }, + { __identifier: 'nothing', __type: 'String', __value: null }, + ], + }, + ], + }); + + const props = ldtkToTileMap(data).levels[0]!.objectLayers[0]!.objects[0]!.properties; + expect(props['hp']).toBe(10); + expect(props['label']).toBe('Bob'); + expect(props['hostile']).toBe(true); + expect(props['path']).toBeUndefined(); + expect(props['meta']).toBeUndefined(); + expect(props['nothing']).toBeUndefined(); + }); + + it('freezes the projected properties bag', () => { + const data = docWithLayer({ + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-1', + entityInstances: [ + { + __identifier: 'NPC', + __type: 'NPC', + px: [0, 0], + width: 16, + height: 16, + iid: 'npc-1', + defUid: 300, + fieldInstances: [{ __identifier: 'hp', __type: 'Int', __value: 10 }], + }, + ], + }); + + const props = ldtkToTileMap(data).levels[0]!.objectLayers[0]!.objects[0]!.properties; + expect(Object.isFrozen(props)).toBe(true); + }); + + it('emits a frozen empty bag for entities without fields', () => { + const data = docWithLayer({ + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-1', + entityInstances: [ + { + __identifier: 'Marker', + __type: 'Marker', + px: [0, 0], + width: 0, + height: 0, + iid: 'm-1', + defUid: 301, + fieldInstances: [], + }, + ], + }); + + const props = ldtkToTileMap(data).levels[0]!.objectLayers[0]!.objects[0]!.properties; + expect(props).toEqual({}); + expect(Object.isFrozen(props)).toBe(true); + }); + + it('maps the entity __identifier to both name and type, with rectangle geometry', () => { + const data = docWithLayer({ + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-1', + entityInstances: [ + { + __identifier: 'Door', + __type: 'Door', + px: [3, 5], + width: 16, + height: 16, + iid: 'door-1', + defUid: 302, + fieldInstances: [], + }, + ], + }); + + const object = ldtkToTileMap(data).levels[0]!.objectLayers[0]!.objects[0]!; + expect(object.name).toBe('Door'); + expect(object.type).toBe('Door'); + expect(object.kind).toBe('rectangle'); + expect(object.rotation).toBe(0); + expect(object.visible).toBe(true); + }); +}); + +describe('ldtkToTileMap — ObjectLayer metadata', () => { + it('forwards id, visibility, opacity and offsets to the ObjectLayer', () => { + const data = docWithLayer({ + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: false, + opacity: 0.5, + pxOffsetX: 4, + pxOffsetY: 6, + iid: 'ent-1', + entityInstances: [], + }); + + const objectLayer = ldtkToTileMap(data).levels[0]!.objectLayers[0]!; + expect(objectLayer.id).toBe(130); + expect(objectLayer.name).toBe('Entities'); + expect(objectLayer.visible).toBe(false); + expect(objectLayer.opacity).toBe(0.5); + expect(objectLayer.offsetX).toBe(4); + expect(objectLayer.offsetY).toBe(6); + }); +}); + +describe('ldtkToTileMap — entity id assignment across layers and levels', () => { + it('accumulates ids across multiple entity layers within one level', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [ + { + identifier: 'L', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 64, + pxHei: 16, + layerInstances: [ + { + __identifier: 'EntitiesA', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: 1, + visible: true, + iid: 'ent-a', + entityInstances: [ + makeEntity('A0'), + makeEntity('A1'), + ], + }, + { + __identifier: 'EntitiesB', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 131, + levelId: 1, + visible: true, + iid: 'ent-b', + entityInstances: [makeEntity('B0')], + }, + ], + }, + ], + }; + + const layers = ldtkToTileMap(data).levels[0]!.objectLayers; + expect(layers[0]!.objects.map(o => o.id)).toEqual([0, 1]); + expect(layers[1]!.objects.map(o => o.id)).toEqual([2]); + }); + + it('offsets ids by levelIndex * 1_000_000 so they stay globally unique', () => { + const data: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: [makeEntityLevel('L0', 1), makeEntityLevel('L1', 2)], + }; + + const result = ldtkToTileMap(data); + expect(result.levels[0]!.objectLayers[0]!.objects[0]!.id).toBe(0); + expect(result.levels[1]!.objectLayers[0]!.objects[0]!.id).toBe(1_000_000); + }); +}); + +// ── Local builders for the multi-level id fixtures ────────────────────────────── + +function makeEntity(identifier: string): LdtkEntityInstance { + return { + __identifier: identifier, + __type: identifier, + px: [0, 0], + width: 16, + height: 16, + iid: `iid-${identifier}`, + defUid: 0, + fieldInstances: [], + }; +} + +function makeEntityLevel(identifier: string, uid: number): LdtkLevel { + return { + identifier, + uid, + iid: `iid-${uid}`, + worldX: 0, + worldY: 0, + pxWid: 64, + pxHei: 16, + layerInstances: [ + { + __identifier: 'Entities', + __type: 'Entities', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 130, + levelId: uid, + visible: true, + iid: `ent-${uid}`, + entityInstances: [makeEntity('E')], + }, + ], + }; +} diff --git a/packages/exojs-ldtk/test/ldtk-extension.test.ts b/packages/exojs-ldtk/test/ldtk-extension.test.ts new file mode 100644 index 00000000..68616409 --- /dev/null +++ b/packages/exojs-ldtk/test/ldtk-extension.test.ts @@ -0,0 +1,108 @@ +import { ExtensionRegistry } from '@codexo/exojs/extensions'; +import { tilemapExtension } from '@codexo/exojs-tilemap'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { buildSnapshot } from '../../../src/extensions/snapshot'; +import { resetExtensionRegistryForTesting } from '../../../src/extensions/testing'; +import { ldtkMapBinding } from '../src/ldtkBinding'; +import { ldtkExtension } from '../src/ldtkExtension'; +import { LdtkMap } from '../src/LdtkMap'; + +describe('@codexo/exojs-ldtk extension descriptor', () => { + it('has the correct id', () => { + expect(ldtkExtension.id).toBe('@codexo/exojs-ldtk'); + }); + + it('declares tilemapExtension as a dependency (same object reference)', () => { + expect(ldtkExtension.dependencies).toBeDefined(); + expect(ldtkExtension.dependencies).toContain(tilemapExtension); + }); + + it('carries exactly one asset binding (the LdtkMap binding)', () => { + expect(ldtkExtension.assets).toBeDefined(); + expect(ldtkExtension.assets!.length).toBe(1); + expect(ldtkExtension.assets![0]).toBe(ldtkMapBinding); + }); + + it('is a frozen descriptor', () => { + expect(Object.isFrozen(ldtkExtension)).toBe(true); + }); +}); + +describe('@codexo/exojs-ldtk asset binding — ldtkMapBinding', () => { + it('targets the LdtkMap constructor', () => { + expect(ldtkMapBinding.type).toBe(LdtkMap); + }); + + it('has typeNames ["ldtkMap"]', () => { + expect(ldtkMapBinding.typeNames).toEqual(['ldtkMap']); + }); + + it('claims the .ldtk file extension', () => { + expect(ldtkMapBinding.extensions).toEqual(['ldtk']); + }); + + it('create() returns a handler with a load function', () => { + const handler = ldtkMapBinding.create(); + expect(typeof handler.load).toBe('function'); + }); +}); + +describe('buildSnapshot([ldtkExtension])', () => { + it('materializes tilemapExtension before ldtkExtension', () => { + const snapshot = buildSnapshot([ldtkExtension]); + expect(snapshot.extensions.map(e => e.id)).toEqual([ + '@codexo/exojs-tilemap', + '@codexo/exojs-ldtk', + ]); + }); + + it('collects the single LDtk asset binding', () => { + const snapshot = buildSnapshot([ldtkExtension]); + expect(snapshot.assets).toHaveLength(1); + expect(snapshot.assets).toContain(ldtkMapBinding); + }); + + it('pulls in the tilemap renderer binding (one-extension rendering)', () => { + const snapshot = buildSnapshot([ldtkExtension]); + // The tilemap dependency contributes its tile chunk renderer binding, so an + // LDtk-only setup can both load AND render without manual registration. + expect(snapshot.renderers).toHaveLength(1); + }); +}); + +describe('@codexo/exojs-ldtk root entry (side-effect-free)', () => { + beforeEach(() => { + resetExtensionRegistryForTesting(); + }); + + it('root import does NOT register ldtkExtension in ExtensionRegistry', async () => { + await import('../src/index'); + expect(ExtensionRegistry.has('@codexo/exojs-ldtk')).toBe(false); + }); +}); + +describe('@codexo/exojs-ldtk/register entry', () => { + beforeEach(() => { + resetExtensionRegistryForTesting(); + }); + + it('registers ldtkExtension on import', async () => { + await import('../src/register'); + expect(ExtensionRegistry.has('@codexo/exojs-ldtk')).toBe(true); + }); +}); + +describe('export parity', () => { + it('root and register have the same named exports', async () => { + const root = await import('../src/index'); + const register = await import('../src/register'); + const rootKeys = Object.keys(root) + .filter(k => k !== 'default') + .sort(); + const registerKeys = Object.keys(register) + .filter(k => k !== 'default') + .sort(); + expect(rootKeys).toEqual(registerKeys); + }); +}); diff --git a/packages/exojs-ldtk/test/ldtk-map.test.ts b/packages/exojs-ldtk/test/ldtk-map.test.ts new file mode 100644 index 00000000..e38335e8 --- /dev/null +++ b/packages/exojs-ldtk/test/ldtk-map.test.ts @@ -0,0 +1,131 @@ +import type { TileMap } from '@codexo/exojs-tilemap'; +import { describe, expect, it, vi } from 'vitest'; + +import type { LdtkData, LdtkLevel } from '../src/LdtkData'; +import { LdtkMap } from '../src/LdtkMap'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** Build a bare LDtk level record with only the fields LdtkMap reads. */ +function makeLevel(identifier: string, uid: number): LdtkLevel { + return { + identifier, + uid, + iid: `iid-${uid}`, + worldX: 0, + worldY: 0, + pxWid: 16, + pxHei: 16, + layerInstances: [], + }; +} + +/** Build LdtkData carrying the given level identifiers (in order). */ +function makeData(identifiers: readonly string[]): LdtkData { + return { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { tilesets: [], layers: [] }, + levels: identifiers.map((id, i) => makeLevel(id, i + 1)), + }; +} + +/** + * A stand-in TileMap that records destroy() calls. LdtkMap only ever calls + * `destroy()` on its levels and otherwise stores the references opaquely, so a + * spy object is sufficient and keeps the unit isolated from TileMap internals. + */ +function makeFakeTileMap(): TileMap & { destroy: ReturnType } { + return { destroy: vi.fn() } as unknown as TileMap & { + destroy: ReturnType; + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('LdtkMap construction', () => { + it('stores source, data, and levels by reference', () => { + const data = makeData(['A']); + const levels = [makeFakeTileMap()]; + const map = new LdtkMap('world.ldtk', data, levels); + + expect(map.source).toBe('world.ldtk'); + expect(map.data).toBe(data); + expect(map.levels).toBe(levels); + }); + + it('keeps levels index-aligned with data.levels', () => { + const data = makeData(['First', 'Second']); + const first = makeFakeTileMap(); + const second = makeFakeTileMap(); + const map = new LdtkMap('', data, [first, second]); + + expect(map.levels[0]).toBe(first); + expect(map.levels[1]).toBe(second); + }); +}); + +describe('LdtkMap.getLevelByName', () => { + it('returns the runtime TileMap at the matching level index', () => { + const data = makeData(['Intro', 'Boss']); + const intro = makeFakeTileMap(); + const boss = makeFakeTileMap(); + const map = new LdtkMap('', data, [intro, boss]); + + expect(map.getLevelByName('Intro')).toBe(intro); + expect(map.getLevelByName('Boss')).toBe(boss); + }); + + it('returns undefined for an unknown identifier', () => { + const map = new LdtkMap('', makeData(['Only']), [makeFakeTileMap()]); + expect(map.getLevelByName('Missing')).toBeUndefined(); + }); + + it('resolves to the first level when identifiers are duplicated (findIndex semantics)', () => { + const data = makeData(['Dup', 'Dup']); + const firstDup = makeFakeTileMap(); + const secondDup = makeFakeTileMap(); + const map = new LdtkMap('', data, [firstDup, secondDup]); + + expect(map.getLevelByName('Dup')).toBe(firstDup); + }); + + it('returns undefined for a level whose TileMap slot was skipped (sparse levels)', () => { + // data has two levels but only the first was converted; the second slot is + // a hole. getLevelByName resolves the index then reads the (absent) slot. + const data = makeData(['Loaded', 'External']); + const map = new LdtkMap('', data, [makeFakeTileMap()]); + + expect(map.getLevelByName('External')).toBeUndefined(); + }); +}); + +describe('LdtkMap.destroy', () => { + it('destroys every owned level exactly once', () => { + const a = makeFakeTileMap(); + const b = makeFakeTileMap(); + const map = new LdtkMap('', makeData(['A', 'B']), [a, b]); + + map.destroy(); + + expect(a.destroy).toHaveBeenCalledTimes(1); + expect(b.destroy).toHaveBeenCalledTimes(1); + }); + + it('forwards destroy() to each level again on a repeat call (delegates idempotence to TileMap)', () => { + // LdtkMap.destroy does not guard itself — it simply forwards to each level, + // relying on TileMap.destroy being idempotent. Characterize that forwarding. + const a = makeFakeTileMap(); + const map = new LdtkMap('', makeData(['A']), [a]); + + map.destroy(); + map.destroy(); + + expect(a.destroy).toHaveBeenCalledTimes(2); + }); + + it('does nothing when there are no levels', () => { + const map = new LdtkMap('', makeData([]), []); + expect(() => map.destroy()).not.toThrow(); + }); +}); diff --git a/packages/exojs-ldtk/test/ldtkToTileMap.test.ts b/packages/exojs-ldtk/test/ldtkToTileMap.test.ts new file mode 100644 index 00000000..16c0612a --- /dev/null +++ b/packages/exojs-ldtk/test/ldtkToTileMap.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; + +import type { LdtkData } from '../src/LdtkData'; +import { LdtkMap } from '../src/LdtkMap'; +import { ldtkToTileMap } from '../src/ldtkToTileMap'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** Minimal well-formed LDtk document with one level and no tile layers. */ +const minimalData: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [], + layers: [], + }, + levels: [ + { + identifier: 'Level_0', + uid: 1, + iid: 'aaaaaaaa-0000-0000-0000-000000000001', + worldX: 0, + worldY: 0, + pxWid: 256, + pxHei: 128, + layerInstances: [], + }, + ], +}; + +/** Multi-level document to verify per-level TileMap generation. */ +const multiLevelData: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [], + layers: [], + }, + levels: [ + { + identifier: 'World_01', + uid: 10, + iid: 'aaaaaaaa-0000-0000-0000-000000000010', + worldX: 0, + worldY: 0, + pxWid: 320, + pxHei: 192, + layerInstances: [], + }, + { + identifier: 'World_02', + uid: 11, + iid: 'aaaaaaaa-0000-0000-0000-000000000011', + worldX: 320, + worldY: 0, + pxWid: 160, + pxHei: 96, + layerInstances: [], + }, + ], +}; + +/** Document with entity and tile layers to verify layer conversion. */ +const layeredData: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [ + { + uid: 1, + identifier: 'Ground', + relPath: 'tileset.png', + tileGridSize: 16, + pxWid: 256, + pxHei: 256, + spacing: 0, + padding: 0, + }, + ], + layers: [ + { uid: 101, identifier: 'Tiles', type: 'Tiles', gridSize: 16, tilesetDefUid: 1 }, + { uid: 102, identifier: 'Ground', type: 'IntGrid', gridSize: 16 }, + { uid: 103, identifier: 'Entities', type: 'Entities', gridSize: 16 }, + ], + }, + levels: [ + { + identifier: 'MainLevel', + uid: 5, + iid: 'aaaaaaaa-0000-0000-0000-000000000005', + worldX: 0, + worldY: 0, + pxWid: 128, + pxHei: 128, + layerInstances: [ + { + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 8, + __cHei: 8, + __gridSize: 16, + layerDefUid: 101, + levelId: 5, + visible: true, + iid: 'bbbbbbbb-0000-0000-0000-000000000001', + __tilesetDefUid: 1, + // gridTiles omitted → no tiles placed + gridTiles: [{ px: [0, 0], src: [16, 0], f: 0, t: 1 }], + autoLayerTiles: [], + }, + { + __identifier: 'Ground', + __type: 'IntGrid', + __cWid: 8, + __cHei: 8, + __gridSize: 16, + layerDefUid: 102, + levelId: 5, + visible: true, + iid: 'bbbbbbbb-0000-0000-0000-000000000002', + intGridCsv: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + __identifier: 'Entities', + __type: 'Entities', + __cWid: 8, + __cHei: 8, + __gridSize: 16, + layerDefUid: 103, + levelId: 5, + visible: true, + iid: 'bbbbbbbb-0000-0000-0000-000000000003', + entityInstances: [ + { + __identifier: 'Player', + __type: 'Player', + px: [32, 48], + width: 16, + height: 16, + fieldInstances: [ + { __identifier: 'speed', __type: 'Float', __value: 1.5 }, + { __identifier: 'name', __type: 'String', __value: 'Hero' }, + ], + iid: 'cccccccc-0000-0000-0000-000000000001', + defUid: 200, + }, + { + __identifier: 'Coin', + __type: 'Coin', + px: [64, 32], + width: 8, + height: 8, + fieldInstances: [], + iid: 'cccccccc-0000-0000-0000-000000000002', + defUid: 201, + }, + ], + }, + ], + }, + ], +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ldtkToTileMap', () => { + describe('LdtkMap result', () => { + it('returns an LdtkMap instance', () => { + const result = ldtkToTileMap(minimalData); + expect(result).toBeInstanceOf(LdtkMap); + }); + + it('stores the raw data reference', () => { + const result = ldtkToTileMap(minimalData); + expect(result.data).toBe(minimalData); + }); + + it('uses the provided source string', () => { + const result = ldtkToTileMap(minimalData, { source: 'http://example.com/world.ldtk' }); + expect(result.source).toBe('http://example.com/world.ldtk'); + }); + + it('defaults source to empty string when omitted', () => { + const result = ldtkToTileMap(minimalData); + expect(result.source).toBe(''); + }); + }); + + describe('level count and dimensions', () => { + it('produces one TileMap per level', () => { + const result = ldtkToTileMap(multiLevelData); + expect(result.levels).toHaveLength(2); + }); + + it('sets TileMap name to the level identifier', () => { + const result = ldtkToTileMap(multiLevelData); + expect(result.levels[0]?.name).toBe('World_01'); + expect(result.levels[1]?.name).toBe('World_02'); + }); + + it('computes map width and height from pixel dimensions / grid size', () => { + const result = ldtkToTileMap(multiLevelData); + // World_01: 320 × 192 px at 16 px/tile → 20 × 12 tiles + expect(result.levels[0]?.width).toBe(20); + expect(result.levels[0]?.height).toBe(12); + // World_02: 160 × 96 px at 16 px/tile → 10 × 6 tiles + expect(result.levels[1]?.width).toBe(10); + expect(result.levels[1]?.height).toBe(6); + }); + + it('stores world-space metadata in TileMap.properties', () => { + const result = ldtkToTileMap(multiLevelData); + const map = result.levels[1]; + expect(map?.properties['worldX']).toBe(320); + expect(map?.properties['worldY']).toBe(0); + }); + }); + + describe('getLevelByName', () => { + it('finds a level by identifier', () => { + const result = ldtkToTileMap(multiLevelData); + const map = result.getLevelByName('World_02'); + expect(map).toBe(result.levels[1]); + }); + + it('returns undefined for an unknown identifier', () => { + const result = ldtkToTileMap(multiLevelData); + expect(result.getLevelByName('DoesNotExist')).toBeUndefined(); + }); + }); + + describe('layer conversion: minimal (no tilesets)', () => { + it('creates a TileLayer per Tiles layer without tile data when tilesets absent', () => { + const result = ldtkToTileMap(layeredData); + const map = result.levels[0]; + expect(map).toBeDefined(); + // Should have 2 TileLayers (Tiles + IntGrid) + expect(map?.layers).toHaveLength(2); + }); + + it('names tile layers from the layer __identifier', () => { + const result = ldtkToTileMap(layeredData); + const map = result.levels[0]!; + const names = map.layers.map(l => l.name); + expect(names).toContain('Tiles'); + expect(names).toContain('Ground'); + }); + + it('assigns correct layer dimensions (cWid × cHei)', () => { + const result = ldtkToTileMap(layeredData); + const tilesLayer = result.levels[0]?.layers.find(l => l.name === 'Tiles'); + expect(tilesLayer?.width).toBe(8); + expect(tilesLayer?.height).toBe(8); + }); + }); + + describe('layer conversion: entity → ObjectLayer', () => { + it('creates an ObjectLayer for each Entities layer', () => { + const result = ldtkToTileMap(layeredData); + const map = result.levels[0]!; + expect(map.objectLayers).toHaveLength(1); + expect(map.objectLayers[0]?.name).toBe('Entities'); + }); + + it('converts entity instances to rectangle TileMapObjects', () => { + const result = ldtkToTileMap(layeredData); + const entities = result.levels[0]?.objectLayers[0]; + expect(entities?.objects).toHaveLength(2); + }); + + it('sets entity position, size, and type correctly', () => { + const result = ldtkToTileMap(layeredData); + const objects = result.levels[0]?.objectLayers[0]?.objects ?? []; + const player = objects.find(o => o.type === 'Player'); + expect(player).toBeDefined(); + expect(player?.x).toBe(32); + expect(player?.y).toBe(48); + expect(player?.width).toBe(16); + expect(player?.height).toBe(16); + expect(player?.kind).toBe('rectangle'); + }); + + it('maps scalar field instances to TileMapObject properties', () => { + const result = ldtkToTileMap(layeredData); + const player = result.levels[0]?.objectLayers[0]?.objects.find( + o => o.type === 'Player', + ); + expect(player?.properties['speed']).toBe(1.5); + expect(player?.properties['name']).toBe('Hero'); + }); + + it('produces unique numeric ids across entity instances', () => { + const result = ldtkToTileMap(layeredData); + const objects = result.levels[0]?.objectLayers[0]?.objects ?? []; + const ids = objects.map(o => o.id); + expect(new Set(ids).size).toBe(ids.length); + }); + }); + + describe('destroy', () => { + it('destroys all owned levels', () => { + const result = ldtkToTileMap(minimalData); + // Just ensure destroy() does not throw + expect(() => result.destroy()).not.toThrow(); + }); + }); +}); diff --git a/packages/exojs-ldtk/test/load-ldtk-map.test.ts b/packages/exojs-ldtk/test/load-ldtk-map.test.ts new file mode 100644 index 00000000..bb45cfb3 --- /dev/null +++ b/packages/exojs-ldtk/test/load-ldtk-map.test.ts @@ -0,0 +1,260 @@ +import { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { type AssetLoaderContext, Texture } from '@codexo/exojs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LdtkData } from '../src/LdtkData'; +import { LdtkMap } from '../src/LdtkMap'; +import { loadLdtkMap } from '../src/loadLdtkMap'; + +// ── Fixture loading ─────────────────────────────────────────────────────────── + +// Support both "pnpm test" (cwd=repo root) and "pnpm --filter ... test" (cwd=package). +const PKG_DIR = + basename(process.cwd()) === 'exojs-ldtk' + ? process.cwd() + : join(process.cwd(), 'packages', 'exojs-ldtk'); +const FIXTURES_DIR = join(PKG_DIR, 'test', 'fixtures'); + +function loadFixture(name: string): unknown { + return JSON.parse(readFileSync(join(FIXTURES_DIR, name), 'utf-8')); +} + +// ── Mock context factory ──────────────────────────────────────────────────────── + +// A texture large enough that any fixture's atlas region fits inside it. +// TextureRegion validates against the *underlying* texture's intrinsic size, so +// a default-constructed (0x0) Texture would be rejected during tileset assembly. +function fakeTexture(): Texture { + return { + width: 4096, + height: 4096, + uid: 0, + label: 'test', + destroy: () => {}, + destroyed: false, + } as unknown as Texture; +} + +function makeContext(fixtures: Record) { + const loaderLoad = vi.fn( + async (_token: unknown, _url: string): Promise => fakeTexture(), + ); + + const context: AssetLoaderContext = { + loader: { load: loaderLoad } as unknown as AssetLoaderContext['loader'], + identityKey: 'test', + fetchText: vi.fn(), + fetchArrayBuffer: vi.fn(), + fetchJson: vi.fn(async (source: string): Promise => { + if (Object.hasOwn(fixtures, source)) return fixtures[source]; + throw new Error(`load-ldtk-map.test: no fixture registered for source "${source}"`); + }), + }; + + return { context, loaderLoad }; +} + +const ABS_SOURCE = 'https://example.com/maps/world.ldtk'; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('loadLdtkMap — happy path (absolute source)', () => { + function context() { + return makeContext({ [ABS_SOURCE]: loadFixture('world.ldtk') }); + } + + it('returns an LdtkMap with one TileMap per level', async () => { + const map = await loadLdtkMap(ABS_SOURCE, context().context); + expect(map).toBeInstanceOf(LdtkMap); + expect(map.levels).toHaveLength(1); + }); + + it('stores the source URL on the returned map', async () => { + const map = await loadLdtkMap(ABS_SOURCE, context().context); + expect(map.source).toBe(ABS_SOURCE); + }); + + it('fetches the .ldtk JSON from the source', async () => { + const { context: ctx } = context(); + await loadLdtkMap(ABS_SOURCE, ctx); + expect(ctx.fetchJson).toHaveBeenCalledWith(ABS_SOURCE); + }); + + it('loads the tileset atlas image resolved against the source URL', async () => { + const { context: ctx, loaderLoad } = context(); + await loadLdtkMap(ABS_SOURCE, ctx); + // resolveLdtkUrl('tiles.png', 'https://example.com/maps/world.ldtk') + expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://example.com/maps/tiles.png'); + }); + + it('populates the Tiles layer with the gridTiles once the tileset is available', async () => { + const map = await loadLdtkMap(ABS_SOURCE, context().context); + const tilesLayer = map.levels[0]!.layers.find(l => l.name === 'Tiles')!; + // fixture places 2 gridTiles + expect(tilesLayer.countNonEmptyTiles()).toBe(2); + }); + + it('exposes entity layers as ObjectLayers', async () => { + const map = await loadLdtkMap(ABS_SOURCE, context().context); + const objectLayers = map.levels[0]!.objectLayers; + expect(objectLayers).toHaveLength(1); + expect(objectLayers[0]!.objects[0]!.type).toBe('Player'); + }); +}); + +describe('loadLdtkMap — tilesets without an atlas image', () => { + // relPath: null → the tileset is skipped entirely; tiles cannot render. + const fixture: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [ + { + uid: 1, + identifier: 'NoImage', + relPath: null, + tileGridSize: 16, + pxWid: 64, + pxHei: 64, + }, + ], + layers: [{ uid: 101, identifier: 'Tiles', type: 'Tiles', gridSize: 16, tilesetDefUid: 1 }], + }, + levels: [ + { + identifier: 'L', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 64, + pxHei: 16, + layerInstances: [ + { + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [{ px: [0, 0], src: [0, 0], f: 0, t: 0 }], + }, + ], + }, + ], + }; + + it('does not call the loader and leaves tile layers empty', async () => { + const { context, loaderLoad } = makeContext({ [ABS_SOURCE]: fixture }); + const map = await loadLdtkMap(ABS_SOURCE, context); + + expect(loaderLoad).not.toHaveBeenCalled(); + const tilesLayer = map.levels[0]!.layers[0]!; + expect(tilesLayer.countNonEmptyTiles()).toBe(0); + }); +}); + +describe('loadLdtkMap — atlas too small for any tile', () => { + // pxWid (8) < tileGridSize (16) → columns computes to 0 → tileset is dropped. + const fixture: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [ + { + uid: 1, + identifier: 'Tiny', + relPath: 'tiny.png', + tileGridSize: 16, + pxWid: 8, + pxHei: 8, + }, + ], + layers: [{ uid: 101, identifier: 'Tiles', type: 'Tiles', gridSize: 16, tilesetDefUid: 1 }], + }, + levels: [ + { + identifier: 'L', + uid: 1, + iid: 'iid-1', + worldX: 0, + worldY: 0, + pxWid: 64, + pxHei: 16, + layerInstances: [ + { + __identifier: 'Tiles', + __type: 'Tiles', + __cWid: 4, + __cHei: 1, + __gridSize: 16, + layerDefUid: 101, + levelId: 1, + visible: true, + iid: 'tiles-1', + __tilesetDefUid: 1, + gridTiles: [{ px: [0, 0], src: [0, 0], f: 0, t: 0 }], + }, + ], + }, + ], + }; + + it('still loads the texture but drops the tileset (no tiles placed)', async () => { + const { context, loaderLoad } = makeContext({ [ABS_SOURCE]: fixture }); + const map = await loadLdtkMap(ABS_SOURCE, context); + + // The texture load happens before the column check, so it IS requested. + expect(loaderLoad).toHaveBeenCalledWith(Texture, 'https://example.com/maps/tiny.png'); + expect(map.levels[0]!.layers[0]!.countNonEmptyTiles()).toBe(0); + }); +}); + +describe('loadLdtkMap — URL resolution is absolute-base-only (characterization)', () => { + // resolveLdtkUrl uses `new URL(relPath, baseUrl)` directly, which throws when + // the base URL is relative. Unlike the Tiled adapter, LDtk does NOT tolerate a + // relative .ldtk source when a tileset carries a relPath. See report note. + const fixture: LdtkData = { + jsonVersion: '1.5.3', + defaultGridSize: 16, + defs: { + tilesets: [ + { + uid: 1, + identifier: 'Atlas', + relPath: 'tiles.png', + tileGridSize: 16, + pxWid: 64, + pxHei: 64, + }, + ], + layers: [], + }, + levels: [], + }; + + it('rejects when the source is a relative URL and a tileset has a relPath', async () => { + const { context } = makeContext({ 'world.ldtk': fixture }); + await expect(loadLdtkMap('world.ldtk', context)).rejects.toThrow(/Invalid URL/); + }); +}); + +describe('loadLdtkMap — no structural validation (characterization)', () => { + // loadLdtkMap casts fetched JSON straight to LdtkData with no schema check, so + // malformed input surfaces as a raw runtime error during conversion rather than + // a typed format error. + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('throws a raw error (not a typed format error) for an empty document', async () => { + const { context } = makeContext({ [ABS_SOURCE]: {} }); + await expect(loadLdtkMap(ABS_SOURCE, context)).rejects.toThrow(); + }); +}); diff --git a/packages/exojs-ldtk/tsconfig.json b/packages/exojs-ldtk/tsconfig.json new file mode 100644 index 00000000..50183bcd --- /dev/null +++ b/packages/exojs-ldtk/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "customConditions": ["@codexo/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"], + "@codexo/exojs-tilemap": ["../exojs-tilemap/src/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/exojs-particles/test/particle-gpu.test.ts b/packages/exojs-particles/test/particle-gpu.test.ts index 333945d0..93296074 100644 --- a/packages/exojs-particles/test/particle-gpu.test.ts +++ b/packages/exojs-particles/test/particle-gpu.test.ts @@ -384,6 +384,10 @@ describe('ParticleSystem render-inject backend detection', () => { const env = makeMockDevice(); const fakeBackend = Object.create(WebGpuBackend.prototype) as object; Object.defineProperty(fakeBackend, 'device', { value: env.device, configurable: true }); + // Frame-scoped batching uses these instance stacks in _beginDrawPlan/_endDrawPlan; + // Object.create bypasses the constructor that initializes them, so seed them here. + Object.defineProperty(fakeBackend, '_planBaseStack', { value: [], configurable: true }); + Object.defineProperty(fakeBackend, '_planHashStack', { value: [], configurable: true }); const system = new ParticleSystem(makeTexture(), { capacity: 4 }); system.addUpdateModule(new ApplyForce(0, 0)); diff --git a/packages/exojs-physics/src/PhysicsBody.ts b/packages/exojs-physics/src/PhysicsBody.ts index 30c40a3f..60977381 100644 --- a/packages/exojs-physics/src/PhysicsBody.ts +++ b/packages/exojs-physics/src/PhysicsBody.ts @@ -21,6 +21,8 @@ export interface BodyOptions { gravityScale?: number; /** When `true`, the body never rotates under contacts (infinite rotational inertia). Default `false`. */ fixedRotation?: boolean; + /** When `true`, the body is swept against static geometry each step (CCD) so it cannot tunnel through thin walls. Default `false`. */ + isBullet?: boolean; /** Colliders to attach up-front. Each may be a {@link Collider} instance or its {@link ColliderOptions}. */ colliders?: Array; } @@ -54,6 +56,8 @@ export class PhysicsBody { public gravityScale: number; /** When `true`, rotational inertia is treated as infinite. */ public fixedRotation: boolean; + /** When `true`, the body is swept against static geometry each step (CCD) so it cannot tunnel through thin walls. */ + public isBullet: boolean; /** Total mass (0 for static/kinematic). */ public mass = 0; @@ -71,6 +75,9 @@ export class PhysicsBody { /** Angular velocity in rad/s. */ public angularVelocity = 0; + /** When `false`, this body is never put to sleep. Default `true`. */ + public allowSleep = true; + /** @internal — Delta position X accumulated across the frame's sub-steps by the TGS integrator; written into the transform once per frame by {@link _finalizePosition}. */ public _deltaPosX = 0; /** @internal — Delta position Y accumulated across the frame's sub-steps by the TGS integrator. */ @@ -86,6 +93,17 @@ export class PhysicsBody { private _forceY = 0; private _torque = 0; + /** @internal — seconds the body has stayed below the sleep thresholds (frozen while asleep). Read by the world's island pass. */ + public _sleepTime = 0; + /** @internal — dense union-find index assigned by the world's island pass each step. */ + public _islandIndex = 0; + /** @internal — world-space centre of mass at the start of the current fixed step (CCD swept-test origin). */ + public _ccdPrevX = 0; + /** @internal — see {@link _ccdPrevX}. */ + public _ccdPrevY = 0; + + private _sleeping = false; + private _id = -1; private _owner: BodyOwner | null = null; private _attached = false; @@ -103,6 +121,7 @@ export class PhysicsBody { this.angularDamping = options.angularDamping ?? 0; this.gravityScale = options.gravityScale ?? 1; this.fixedRotation = options.fixedRotation ?? false; + this.isBullet = options.isBullet ?? false; // Build up-front colliders now (no id/world registration until the body // joins a world via `world.add()`). They sit in `_colliders` unsynchronised; @@ -178,6 +197,11 @@ export class PhysicsBody { return this._destroyed; } + /** `true` when the body is asleep — skipped by the integrator and solver until woken. */ + public get isSleeping(): boolean { + return this._sleeping; + } + /** * Attach a collider to this body. Accepts a {@link Collider} instance or its * {@link ColliderOptions} (a convenience that constructs the collider for you). @@ -219,6 +243,8 @@ export class PhysicsBody { throw new Error('PhysicsBody: cannot move a destroyed body.'); } + this.wake(); + setTransform(this._transform, position.x, position.y, angle); for (const collider of this._colliders) { @@ -250,6 +276,7 @@ export class PhysicsBody { * sub-step and then cleared. No-op on static/kinematic bodies. Returns `this`. */ public applyForce(forceX: number, forceY: number): this { + this.wake(); this._forceX += forceX; this._forceY += forceY; @@ -261,6 +288,7 @@ export class PhysicsBody { * on static/kinematic bodies (and fixed-rotation bodies). Returns `this`. */ public applyTorque(torque: number): this { + this.wake(); this._torque += torque; return this; @@ -276,6 +304,7 @@ export class PhysicsBody { return this; } + this.wake(); this.linearVelocityX += impulseX * this.invMass; this.linearVelocityY += impulseY * this.invMass; @@ -299,7 +328,7 @@ export class PhysicsBody { * every sub-step and cleared once per frame by {@link _finalizePosition}. */ public _integrateVelocity(h: number, gravityX: number, gravityY: number): void { - if (this.invMass === 0) { + if (this.invMass === 0 || this._sleeping) { return; } @@ -322,7 +351,7 @@ export class PhysicsBody { * re-syncing collider geometry per sub-step. Static bodies never move. */ public _integratePosition(h: number): void { - if (this.type === 'static') { + if (this.type === 'static' || this._sleeping) { return; } @@ -340,6 +369,49 @@ export class PhysicsBody { } } + /** + * @internal — advance the sleep timer over one fixed step `dt`. A body below + * both velocity thresholds accumulates time; a too-fast body, or one that opts + * out via {@link allowSleep}, resets it. The sleep/wake decision is made per + * island by the world (so a stack sleeps as a unit) from {@link _sleepTime}. + */ + public _accumulateSleepTime(dt: number, linearThreshold: number, angularThreshold: number): void { + const tooFast = + this.linearVelocityX * this.linearVelocityX + this.linearVelocityY * this.linearVelocityY > linearThreshold * linearThreshold || + Math.abs(this.angularVelocity) > angularThreshold; + + this._sleepTime = !this.allowSleep || tooFast ? 0 : this._sleepTime + dt; + } + + /** @internal — set the sleep state. Sleeping zeroes the velocity; waking resets the sleep timer. */ + public _setSleeping(sleeping: boolean): void { + if (this._sleeping === sleeping) { + return; + } + + this._sleeping = sleeping; + + if (sleeping) { + this.linearVelocityX = 0; + this.linearVelocityY = 0; + this.angularVelocity = 0; + } else { + this._sleepTime = 0; + } + } + + /** + * Wake the body if it is asleep, resetting its sleep timer. The rest of its + * island wakes with it on the next step (a contact to an awake body keeps the + * whole island awake). Returns `this`. + */ + public wake(): this { + this._setSleeping(false); + this._sleepTime = 0; + + return this; + } + /** * @internal — apply the frame's accumulated delta position/rotation to the * transform (rotating about the centre of mass), re-sync collider geometry and diff --git a/packages/exojs-physics/src/PhysicsWorld.ts b/packages/exojs-physics/src/PhysicsWorld.ts index 2febb82d..50a7f260 100644 --- a/packages/exojs-physics/src/PhysicsWorld.ts +++ b/packages/exojs-physics/src/PhysicsWorld.ts @@ -8,6 +8,7 @@ import { BindingRegistry } from './binding/BindingRegistry'; import type { BindingOptions, PhysicsBinding } from './binding/PhysicsBinding'; import { Collider } from './Collider'; import type { CollisionEvent, SensorEvent } from './events'; +import type { Joint } from './joints/Joint'; import type { BodyOwner } from './PhysicsBody'; import { PhysicsBody } from './PhysicsBody'; import type { QueryFilter, RayHit } from './query/QueryEngine'; @@ -16,6 +17,11 @@ import type { AnyShape } from './shapes/AnyShape'; import { TimeStepper } from './TimeStepper'; import type { BodyType, CollisionFilter, VectorLike } from './types'; +/** Gap left between a clamped bullet and the surface it hit (so it does not re-hit from inside). */ +const ccdSkin = 0.05; +/** Impact speed (px/s) below which a bullet's CCD response does not bounce (mirrors the contact solver). */ +const ccdRestitutionThreshold = 1; + /** Construction options for a {@link PhysicsWorld}. */ export interface PhysicsWorldOptions { /** Gravity in px/s² (+Y down). Integrated each sub-step. Default `(0, 0)`. */ @@ -37,6 +43,14 @@ export interface PhysicsWorldOptions { dampingRatio?: number; /** Interpolate bound nodes between fixed steps (reserved; no effect yet). Default `true`. */ interpolation?: boolean; + /** Put resting bodies to sleep so they skip integration and solving. Default `true`. */ + enableSleeping?: boolean; + /** Linear speed at or below which a body is a sleep candidate, px/s. Default `5`. */ + sleepLinearVelocity?: number; + /** Angular speed at or below which a body is a sleep candidate, rad/s. Default `0.06`. */ + sleepAngularVelocity?: number; + /** Seconds a body must stay below the sleep thresholds before it sleeps. Default `0.5`. */ + timeToSleep?: number; } /** @@ -129,13 +143,30 @@ export class PhysicsWorld implements BodyOwner { public readonly contactHertz: number; /** Soft-contact damping ratio. */ public readonly dampingRatio: number; + /** Whether resting bodies are put to sleep. */ + public readonly enableSleeping: boolean; + /** Linear sleep threshold (px/s). */ + public readonly sleepLinearVelocity: number; + /** Angular sleep threshold (rad/s). */ + public readonly sleepAngularVelocity: number; + /** Seconds below the thresholds before a body sleeps. */ + public readonly timeToSleep: number; private readonly _backend: PhysicsBackend = new NativePhysicsBackend(); private readonly _bodies: PhysicsBody[] = []; private readonly _colliders: Collider[] = []; + private readonly _joints: Joint[] = []; private readonly _bindings = new BindingRegistry(); private readonly _query: QueryEngine; private readonly _commands: Array<() => void> = []; + /** Pooled union-find parent array for the per-step island pass (reused; sized to the body count). */ + private readonly _islandParent: number[] = []; + /** Pooled per-island minimum sleep time, indexed by union-find root. */ + private readonly _islandMinSleep: number[] = []; + /** Pooled ray-hit buffer + origin/direction for the CCD swept test. */ + private readonly _ccdHits: RayHit[] = []; + private readonly _ccdOrigin = { x: 0, y: 0 }; + private readonly _ccdDir = { x: 0, y: 0 }; private _nextBodyId = 1; private _nextColliderId = 1; @@ -159,6 +190,10 @@ export class PhysicsWorld implements BodyOwner { this.subStepCount = subStepCount; this.contactHertz = options.contactHertz ?? 30; this.dampingRatio = options.dampingRatio ?? 10; + this.enableSleeping = options.enableSleeping ?? true; + this.sleepLinearVelocity = options.sleepLinearVelocity ?? 5; + this.sleepAngularVelocity = options.sleepAngularVelocity ?? 0.06; + this.timeToSleep = options.timeToSleep ?? 0.5; this._query = new QueryEngine(this._colliders); } @@ -246,6 +281,44 @@ export class PhysicsWorld implements BodyOwner { this._defer(() => this._removeCollider(collider)); } + /** Live joints (read-only view). */ + public get joints(): readonly Joint[] { + return this._joints; + } + + /** + * Add a constraint joint. Construct it first (`new DistanceJoint({ … })`), + * then add it. Wakes both bodies; safe inside a callback (registration is + * deferred). Returns the joint. + */ + public addJoint(joint: T): T { + this._assertAlive(); + joint.bodyA.wake(); + joint.bodyB.wake(); + + this._defer(() => { + if (!this._joints.includes(joint)) { + this._joints.push(joint); + } + }); + + return joint; + } + + /** Remove a joint, waking both bodies so they respond to the lost constraint. Deferred when called inside a callback. */ + public removeJoint(joint: Joint): void { + joint.bodyA.wake(); + joint.bodyB.wake(); + + this._defer(() => { + const index = this._joints.indexOf(joint); + + if (index !== -1) { + this._joints.splice(index, 1); + } + }); + } + // ── stepping ─────────────────────────────────────────────────────────── /** @@ -267,14 +340,32 @@ export class PhysicsWorld implements BodyOwner { const gravityY = this.gravity.y; const contactHertz = this.contactHertz; const dampingRatio = this.dampingRatio; + const hasJoints = this._joints.length > 0; + const hasBullets = this._hasBullets(); for (let step = 0; step < steps; step++) { // Detection runs once per fixed step (collider geometry is already current // from the previous frame's finalize / attach / setTransform). TGS-Soft // reuses the manifolds across the sub-steps below. this._backend.detect(this._colliders); + + // Sleep decision runs after detection (islands need the current contact + // set) and before the solver (so sleeping contacts are skipped, and a + // sleeping island touched by an awake body is woken first). + if (this.enableSleeping) { + this._updateSleeping(this.timeStepper.fixedDelta); + } + this._backend.prepareSolve(h, contactHertz, dampingRatio); + if (hasJoints) { + this._prepareJoints(h); + } + + if (hasBullets) { + this._recordBulletPositions(); + } + for (let subStep = 0; subStep < subStepCount; subStep++) { // Integrate gravity/forces over the sub-step (forces persist across // sub-steps; cleared once per frame by `_finalizePosition`). @@ -289,15 +380,28 @@ export class PhysicsWorld implements BodyOwner { // load, which is what keeps tall stacks from pumping energy. this._backend.warmStart(); + if (hasJoints) { + this._warmStartJoints(); + } + // Main soft-bias velocity solve, integrate positions (accumulating - // per-body delta), then the bias-free relax pass. + // per-body delta), then the bias-free relax pass. Joints solve right + // after the contacts in each pass (contacts are the stiffer constraint). this._backend.solveVelocities(true); + if (hasJoints) { + this._solveJoints(true); + } + for (const body of this._bodies) { body._integratePosition(h); } this._backend.solveVelocities(false); + + if (hasJoints) { + this._solveJoints(false); + } } // Separate restitution pass, then write the accumulated delta into each @@ -307,6 +411,10 @@ export class PhysicsWorld implements BodyOwner { for (const body of this._bodies) { body._finalizePosition(); } + + if (hasBullets) { + this._advanceBullets(); + } } this._dispatchEvents(); @@ -374,6 +482,7 @@ export class PhysicsWorld implements BodyOwner { this._bodies.length = 0; this._colliders.length = 0; + this._joints.length = 0; this._commands.length = 0; this._bindings.clear(); this._backend.destroy(); @@ -428,6 +537,240 @@ export class PhysicsWorld implements BodyOwner { this._dispatching = false; } + /** + * Accumulate per-body sleep timers and put/keep islands of resting bodies + * asleep so a stack sleeps and wakes as one unit. An island is a connected + * component of dynamic bodies joined by touching solid contacts (static and + * kinematic bodies are boundaries, not nodes); it sleeps once every member has + * stayed below the sleep thresholds for `timeToSleep`, and wakes the instant + * any member does (e.g. an awake body merges into it via a new contact). + * Deterministic: union-find roots break ties by lower index and the contact + * set is id-sorted. + */ + private _updateSleeping(dt: number): void { + const bodies = this._bodies; + const count = bodies.length; + const parent = this._islandParent; + const minSleep = this._islandMinSleep; + + // Assign dense indices, reset the union-find, and accumulate sleep timers for + // awake dynamic bodies (a sleeping body's timer stays frozen ≥ timeToSleep). + for (let i = 0; i < count; i++) { + const body = bodies[i]!; + + body._islandIndex = i; + parent[i] = i; + minSleep[i] = Infinity; + + if (body.type === 'dynamic' && !body.isSleeping) { + body._accumulateSleepTime(dt, this.sleepLinearVelocity, this.sleepAngularVelocity); + } + } + + parent.length = count; + minSleep.length = count; + + // Union dynamic↔dynamic solid contacts into islands. + for (const contact of this._backend.contactGraph.solidContacts) { + const bodyA = contact.a.body; + const bodyB = contact.b.body; + + if (bodyA.type === 'dynamic' && bodyB.type === 'dynamic') { + this._union(bodyA._islandIndex, bodyB._islandIndex); + } + } + + // Joints couple their two bodies into the same island (sleep/wake together). + for (const joint of this._joints) { + const bodyA = joint.bodyA; + const bodyB = joint.bodyB; + + if (joint.enabled && bodyA.type === 'dynamic' && bodyB.type === 'dynamic') { + this._union(bodyA._islandIndex, bodyB._islandIndex); + } + } + + // Per-island minimum sleep time over its dynamic members. + for (let i = 0; i < count; i++) { + const body = bodies[i]!; + + if (body.type === 'dynamic') { + const root = this._find(i); + + if (body._sleepTime < minSleep[root]!) { + minSleep[root] = body._sleepTime; + } + } + } + + // Sleep an island iff every member has rested for `timeToSleep`; otherwise + // wake it (which also wakes any member dragged awake by a fresh contact). + const timeToSleep = this.timeToSleep; + + for (let i = 0; i < count; i++) { + const body = bodies[i]!; + + if (body.type === 'dynamic') { + body._setSleeping(minSleep[this._find(i)]! >= timeToSleep); + } + } + } + + /** Union-find union by lower index (deterministic roots). */ + private _union(a: number, b: number): void { + const rootA = this._find(a); + const rootB = this._find(b); + + if (rootA < rootB) { + this._islandParent[rootB] = rootA; + } else if (rootB < rootA) { + this._islandParent[rootA] = rootB; + } + } + + /** Union-find find with path halving. */ + private _find(index: number): number { + const parent = this._islandParent; + + while (parent[index]! !== index) { + const grandparent = parent[parent[index]!]!; + parent[index] = grandparent; + index = grandparent; + } + + return index; + } + + /** Build each joint's per-frame constraint data (once per fixed step). */ + private _prepareJoints(h: number): void { + for (const joint of this._joints) { + joint._prepare(h); + } + } + + /** Re-apply each joint's accumulated impulse (each sub-step). */ + private _warmStartJoints(): void { + for (const joint of this._joints) { + joint._warmStart(); + } + } + + /** One joint velocity pass (each sub-step, after the contacts). */ + private _solveJoints(useBias: boolean): void { + for (const joint of this._joints) { + joint._solve(useBias); + } + } + + /** Whether any dynamic body is flagged for continuous collision (bullet mode). */ + private _hasBullets(): boolean { + for (const body of this._bodies) { + if (body.isBullet && body.type === 'dynamic') { + return true; + } + } + + return false; + } + + /** Snapshot each bullet's centre of mass at the start of the fixed step (the swept-test origin). */ + private _recordBulletPositions(): void { + for (const body of this._bodies) { + if (body.isBullet && body.type === 'dynamic') { + body._ccdPrevX = body.worldCenterOfMassX; + body._ccdPrevY = body.worldCenterOfMassY; + } + } + } + + /** + * Sweep each bullet's centre of mass along this fixed step's motion against every + * other body's colliders; if it would cross one, clamp the body just short of the + * surface and resolve the impact about the surface normal (a slide for a non-bouncy + * body, an elastic reflection as restitution → 1) so it cannot tunnel. Sweeps the + * centre point — good for small/point-like projectiles; a full swept-shape TOI for + * large fast bodies is backlog (raise sub-steps or thicken geometry meanwhile). + */ + private _advanceBullets(): void { + for (const body of this._bodies) { + if (!body.isBullet || body.type !== 'dynamic' || body.isSleeping) { + continue; + } + + const newX = body.worldCenterOfMassX; + const newY = body.worldCenterOfMassY; + let dirX = newX - body._ccdPrevX; + let dirY = newY - body._ccdPrevY; + const distance = Math.hypot(dirX, dirY); + + if (distance < 1e-6) { + continue; + } + + dirX /= distance; + dirY /= distance; + + this._ccdOrigin.x = body._ccdPrevX; + this._ccdOrigin.y = body._ccdPrevY; + this._ccdDir.x = dirX; + this._ccdDir.y = dirY; + + const hits = this._query.rayCastAll(this._ccdOrigin, this._ccdDir, undefined, this._ccdHits, distance); + let blocked: RayHit | null = null; + + for (const hit of hits) { + // Sweep against every other body (static, kinematic, dynamic); sensors never + // block. Hits are distance-sorted, so the first match is the nearest surface. + if (hit.body !== body && !hit.collider.isSensor) { + blocked = hit; + break; + } + } + + if (blocked === null) { + continue; + } + + // Clamp the CoM just short of the surface (a pure translation — the rotation + // is already applied), then resolve the impact about the surface normal. + const clampDistance = Math.max(0, blocked.distance - ccdSkin); + const deltaX = body._ccdPrevX + dirX * clampDistance - newX; + const deltaY = body._ccdPrevY + dirY * clampDistance - newY; + + this._ccdOrigin.x = body.x + deltaX; + this._ccdOrigin.y = body.y + deltaY; + body.setTransform(this._ccdOrigin, body.angle); + + // Reflect about the true surface normal: a slide for a non-bouncy body + // (restitution 0), an elastic bounce as restitution → 1. The body's own + // restitution combines (max) with the surface's, matching the contact solver. + const nx = blocked.normal.x; + const ny = blocked.normal.y; + const vn = body.linearVelocityX * nx + body.linearVelocityY * ny; + + if (vn < 0) { + const restitution = vn < -ccdRestitutionThreshold ? Math.max(this._bulletRestitution(body), blocked.collider.restitution) : 0; + const impulse = -(1 + restitution) * vn; + + body.linearVelocityX += impulse * nx; + body.linearVelocityY += impulse * ny; + } + } + } + + /** The highest restitution among a body's colliders (its CCD bounce factor). */ + private _bulletRestitution(body: PhysicsBody): number { + let restitution = 0; + + for (const collider of body.colliders) { + if (collider.restitution > restitution) { + restitution = collider.restitution; + } + } + + return restitution; + } + /** Run `command` now, or queue it when inside an event dispatch (deferred to end of step). */ private _defer(command: () => void): void { if (this._dispatching) { diff --git a/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts b/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts index 23e433f1..6dd14ac1 100644 --- a/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts +++ b/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts @@ -25,6 +25,10 @@ export interface PhysicsDebugDrawOptions { drawCenters?: boolean; /** Broad-phase candidate links between AABB-overlapping colliders. Default `false`. */ drawBroadphase?: boolean; + /** Tint sleeping bodies distinctly (applies to the shape outline). Default `false`. */ + drawSleeping?: boolean; + /** Lines connecting the bodies of each joint. Default `false`. */ + drawJoints?: boolean; } const segments = 24; @@ -38,6 +42,8 @@ const colorContact = new Color(1, 0.2, 0.2, 1); const colorNormal = new Color(1, 0.6, 0.1, 1); const colorCenter = new Color(1, 1, 1, 0.9); const colorBroadphase = new Color(0.2, 0.8, 0.8, 0.5); +const colorSleeping = new Color(0.45, 0.45, 0.5, 0.7); +const colorJoint = new Color(0.9, 0.5, 1, 0.8); /** * `DebugLayer` that visualises a {@link PhysicsWorld} — shapes, AABBs, contacts, @@ -67,6 +73,8 @@ export class PhysicsDebugDraw extends DebugLayer { drawNormals: options.drawNormals ?? false, drawCenters: options.drawCenters ?? false, drawBroadphase: options.drawBroadphase ?? false, + drawSleeping: options.drawSleeping ?? false, + drawJoints: options.drawJoints ?? false, }; } @@ -113,9 +121,22 @@ export class PhysicsDebugDraw extends DebugLayer { this._renderContacts(gfx); } + if (options.drawJoints) { + this._renderJoints(gfx); + } + gfx.render(backend); } + private _renderJoints(gfx: Graphics): void { + gfx.lineColor = colorJoint; + + for (const joint of this._world.joints) { + gfx.moveTo(joint.bodyA.x, joint.bodyA.y); + gfx.lineTo(joint.bodyB.x, joint.bodyB.y); + } + } + public override destroy(): void { if (this._graphics !== null) { this._graphics.destroy(); @@ -125,8 +146,20 @@ export class PhysicsDebugDraw extends DebugLayer { // ── helpers ──────────────────────────────────────────────────────────── + private _outlineColor(collider: Collider): Color { + if (collider.isSensor) { + return colorSensor; + } + + if (this.options.drawSleeping && collider.body.isSleeping) { + return colorSleeping; + } + + return colorForType(collider.body.type); + } + private _strokeShape(gfx: Graphics, collider: Collider): void { - gfx.lineColor = collider.isSensor ? colorSensor : colorForType(collider.body.type); + gfx.lineColor = this._outlineColor(collider); if (collider.shape.type === 'circle') { const c = collider.worldCenter; diff --git a/packages/exojs-physics/src/joints/DistanceJoint.ts b/packages/exojs-physics/src/joints/DistanceJoint.ts new file mode 100644 index 00000000..4aa34f1d --- /dev/null +++ b/packages/exojs-physics/src/joints/DistanceJoint.ts @@ -0,0 +1,225 @@ +import { applyInverseTransform, applyTransform, type Mutable2D } from '../math'; +import type { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link DistanceJoint}. */ +export interface DistanceJointOptions { + /** First body (often a static anchor). */ + bodyA: PhysicsBody; + /** Second body. */ + bodyB: PhysicsBody; + /** World-space anchor on body A at creation. Default: body A's position. */ + anchorA?: VectorLike; + /** World-space anchor on body B at creation. Default: body B's position. */ + anchorB?: VectorLike; + /** Target distance between the anchors. Default: their initial distance. */ + length?: number; + /** Soft-spring frequency in Hz; `0` (default) makes it a rigid constraint. */ + hertz?: number; + /** Soft-spring damping ratio (used when `hertz > 0`). Default `1`. */ + dampingRatio?: number; + /** + * Minimum allowed distance. Specifying `minLength` and/or `maxLength` turns the + * joint into a **rope/limit** (distance kept within `[minLength, maxLength]`, + * slack between, rigid limits). Otherwise the joint is a rigid/soft **equality** + * at `length`. Defaults to `0` in limit mode. + */ + minLength?: number; + /** Maximum allowed distance (the rope length). See {@link minLength}. Defaults to `Infinity` in limit mode. */ + maxLength?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +/** + * Holds the anchor points on two bodies at a target {@link length} along their + * connecting axis. With `hertz === 0` it is rigid; with `hertz > 0` it behaves + * as a damped spring. Solved as a soft constraint in the sub-step loop, warm- + * started across frames. + */ +export class DistanceJoint extends Joint { + /** Target distance between the anchors. */ + public length: number; + /** Soft-spring frequency in Hz (`0` = rigid). */ + public hertz: number; + /** Soft-spring damping ratio. */ + public dampingRatio: number; + /** Minimum allowed distance (rope/limit mode). */ + public minLength: number; + /** Maximum allowed distance (rope/limit mode). */ + public maxLength: number; + + private readonly _limited: boolean; + private _minImpulse = -Infinity; + private _maxImpulse = Infinity; + private readonly _localAnchorAx: number; + private readonly _localAnchorAy: number; + private readonly _localAnchorBx: number; + private readonly _localAnchorBy: number; + + private _rAx = 0; + private _rAy = 0; + private _rBx = 0; + private _rBy = 0; + private _nx = 0; + private _ny = 0; + private _separation = 0; + private _effMass = 0; + private _biasRate = 0; + private _massScale = 1; + private _impulseScale = 0; + private _impulse = 0; + + public constructor(options: DistanceJointOptions) { + super(options.bodyA, options.bodyB); + + const ax = options.anchorA?.x ?? options.bodyA.x; + const ay = options.anchorA?.y ?? options.bodyA.y; + const bx = options.anchorB?.x ?? options.bodyB.x; + const by = options.anchorB?.y ?? options.bodyB.y; + + applyInverseTransform(options.bodyA.transform, ax, ay, scratch); + this._localAnchorAx = scratch.x; + this._localAnchorAy = scratch.y; + applyInverseTransform(options.bodyB.transform, bx, by, scratch); + this._localAnchorBx = scratch.x; + this._localAnchorBy = scratch.y; + + this.length = options.length ?? Math.hypot(bx - ax, by - ay); + this.hertz = options.hertz ?? 0; + this.dampingRatio = options.dampingRatio ?? 1; + this._limited = options.minLength !== undefined || options.maxLength !== undefined; + this.minLength = options.minLength ?? (this._limited ? 0 : this.length); + this.maxLength = options.maxLength ?? (this._limited ? Infinity : this.length); + } + + public override _prepare(h: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + this._active = this.enabled && !bodyA.isSleeping && !bodyB.isSleeping && (bodyA.invMass > 0 || bodyB.invMass > 0); + + if (!this._active) { + return; + } + + applyTransform(bodyA.transform, this._localAnchorAx, this._localAnchorAy, scratch); + const pAx = scratch.x; + const pAy = scratch.y; + applyTransform(bodyB.transform, this._localAnchorBx, this._localAnchorBy, scratch); + const pBx = scratch.x; + const pBy = scratch.y; + + this._rAx = pAx - bodyA.worldCenterOfMassX; + this._rAy = pAy - bodyA.worldCenterOfMassY; + this._rBx = pBx - bodyB.worldCenterOfMassX; + this._rBy = pBy - bodyB.worldCenterOfMassY; + + let dx = pBx - pAx; + let dy = pBy - pAy; + const len = Math.hypot(dx, dy); + + if (len > 1e-9) { + dx /= len; + dy /= len; + } else { + dx = 1; + dy = 0; + } + + this._nx = dx; + this._ny = dy; + + const crA = this._rAx * dy - this._rAy * dx; + const crB = this._rBx * dy - this._rBy * dx; + const k = bodyA.invMass + bodyB.invMass + bodyA.invInertia * crA * crA + bodyB.invInertia * crB * crB; + this._effMass = k > 0 ? 1 / k : 0; + + if (this._limited) { + // Rope/limit: solve only the violated bound; slack between min and max. + if (len > this.maxLength) { + this._separation = len - this.maxLength; + this._minImpulse = -Infinity; // pull together only (tension) + this._maxImpulse = 0; + } else if (len < this.minLength) { + this._separation = len - this.minLength; + this._minImpulse = 0; // push apart only (compression) + this._maxImpulse = Infinity; + } else { + this._active = false; // slack — nothing to solve this frame + this._impulse = 0; + + return; + } + } else { + this._separation = len - this.length; + this._minImpulse = -Infinity; + this._maxImpulse = Infinity; + } + + // Soft spring only for the equality joint; rope limits are rigid. + if (this.hertz > 0 && !this._limited) { + // Box2D-v3 soft factors from a damped spring at the sub-step `h`. + const omega = 2 * Math.PI * this.hertz; + const a1 = 2 * this.dampingRatio + h * omega; + const a2 = h * omega * a1; + const a3 = 1 / (1 + a2); + + this._biasRate = omega / a1; + this._massScale = a2 * a3; + this._impulseScale = a3; + } else { + // Rigid: full mass, Baumgarte position bias, no impulse decay. + this._biasRate = 0.2 / h; + this._massScale = 1; + this._impulseScale = 0; + } + } + + public override _warmStart(): void { + if (this._active) { + this._applyAxisImpulse(this._impulse); + } + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + // Relative velocity of the anchors projected onto the axis. + const vax = bodyA.linearVelocityX - bodyA.angularVelocity * this._rAy; + const vay = bodyA.linearVelocityY + bodyA.angularVelocity * this._rAx; + const vbx = bodyB.linearVelocityX - bodyB.angularVelocity * this._rBy; + const vby = bodyB.linearVelocityY + bodyB.angularVelocity * this._rBx; + const cdot = (vbx - vax) * this._nx + (vby - vay) * this._ny; + + const bias = useBias ? this._biasRate * this._separation : 0; + const raw = -this._effMass * this._massScale * (cdot + bias) - this._impulseScale * this._impulse; + // Clamp the accumulated impulse to the limit's sign range (±Infinity = equality, no clamp). + const clamped = Math.min(this._maxImpulse, Math.max(this._minImpulse, this._impulse + raw)); + const applied = clamped - this._impulse; + + this._impulse = clamped; + this._applyAxisImpulse(applied); + } + + private _applyAxisImpulse(impulse: number): void { + const jx = impulse * this._nx; + const jy = impulse * this._ny; + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + bodyA.linearVelocityX -= bodyA.invMass * jx; + bodyA.linearVelocityY -= bodyA.invMass * jy; + bodyA.angularVelocity -= bodyA.invInertia * (this._rAx * jy - this._rAy * jx); + bodyB.linearVelocityX += bodyB.invMass * jx; + bodyB.linearVelocityY += bodyB.invMass * jy; + bodyB.angularVelocity += bodyB.invInertia * (this._rBx * jy - this._rBy * jx); + } +} diff --git a/packages/exojs-physics/src/joints/Joint.ts b/packages/exojs-physics/src/joints/Joint.ts new file mode 100644 index 00000000..58ddf847 --- /dev/null +++ b/packages/exojs-physics/src/joints/Joint.ts @@ -0,0 +1,32 @@ +import type { PhysicsBody } from '../PhysicsBody'; + +/** + * Base class for a two-body constraint solved alongside contacts in the + * sub-step loop. Concrete joints (distance, revolute, weld) implement the + * three solver hooks; the world owns the joint list and drives them, and joins + * the two bodies into one sleep island so a jointed pair sleeps and wakes + * together. + */ +export abstract class Joint { + /** First constrained body. */ + public readonly bodyA: PhysicsBody; + /** Second constrained body. */ + public readonly bodyB: PhysicsBody; + /** When `false`, the joint is skipped by the solver (but still tracked by the world). */ + public enabled = true; + + /** Whether this joint solves this frame — set in {@link _prepare} (disabled, sleeping or two static bodies → `false`). */ + protected _active = false; + + protected constructor(bodyA: PhysicsBody, bodyB: PhysicsBody) { + this.bodyA = bodyA; + this.bodyB = bodyB; + } + + /** @internal — build this frame's constraint data; called once per fixed step after detection. */ + public abstract _prepare(h: number): void; + /** @internal — re-apply the accumulated impulse; called each sub-step (TGS-Soft warm-start). */ + public abstract _warmStart(): void; + /** @internal — one velocity pass; `useBias` is the soft-bias pass, `false` the relax pass. */ + public abstract _solve(useBias: boolean): void; +} diff --git a/packages/exojs-physics/src/joints/MouseJoint.ts b/packages/exojs-physics/src/joints/MouseJoint.ts new file mode 100644 index 00000000..a744d169 --- /dev/null +++ b/packages/exojs-physics/src/joints/MouseJoint.ts @@ -0,0 +1,179 @@ +import { applyInverseTransform, applyTransform, type Mutable2D } from '../math'; +import { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link MouseJoint}. */ +export interface MouseJointOptions { + /** The body to drag. */ + body: PhysicsBody; + /** World point to pull the body toward — also the grab point on the body at creation. */ + target: VectorLike; + /** Soft-spring frequency in Hz (higher = snappier). Default `5`. */ + hertz?: number; + /** Soft-spring damping ratio. Default `0.7`. */ + dampingRatio?: number; + /** Maximum pulling force — clamps the per-step impulse so heavy bodies lag. Default `Infinity`. */ + maxForce?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +/** + * Softly pulls a single body's grab point toward a movable **target** point + * (typically the mouse cursor). The grab point is fixed on the body at creation; + * update {@link target} each frame to drag. A soft constraint bounded by + * {@link maxForce} — solved in the sub-step loop, warm-started. Internally the + * "other" body is a private static ground sentinel, so this is a single-body + * constraint that touches only the dragged body. + */ +export class MouseJoint extends Joint { + /** Soft-spring frequency in Hz. */ + public hertz: number; + /** Soft-spring damping ratio. */ + public dampingRatio: number; + /** Maximum pulling force. */ + public maxForce: number; + + private readonly _localAnchorX: number; + private readonly _localAnchorY: number; + private _targetX: number; + private _targetY: number; + + private _rx = 0; + private _ry = 0; + private _cx = 0; + private _cy = 0; + private _invK11 = 0; + private _invK12 = 0; + private _invK22 = 0; + private _biasRate = 0; + private _massScale = 1; + private _impulseScale = 0; + private _impulseX = 0; + private _impulseY = 0; + private _maxImpulse = 0; + + public constructor(options: MouseJointOptions) { + // Single-body constraint: a private static ground stands in for bodyA so the + // island/solver machinery (which assumes two bodies) sees a static anchor that + // it never integrates, unions or mutates. + super(new PhysicsBody({ type: 'static', position: options.target }), options.body); + + applyInverseTransform(options.body.transform, options.target.x, options.target.y, scratch); + this._localAnchorX = scratch.x; + this._localAnchorY = scratch.y; + this._targetX = options.target.x; + this._targetY = options.target.y; + + this.hertz = options.hertz ?? 5; + this.dampingRatio = options.dampingRatio ?? 0.7; + this.maxForce = options.maxForce ?? Infinity; + } + + /** The world point the body is pulled toward. Reassigning wakes the body so a drag tracks live. */ + public get target(): VectorLike { + return { x: this._targetX, y: this._targetY }; + } + + public set target(value: VectorLike) { + this._targetX = value.x; + this._targetY = value.y; + this.bodyB.wake(); + } + + public override _prepare(h: number): void { + const body = this.bodyB; + + this._active = this.enabled && !body.isSleeping && body.invMass > 0; + + if (!this._active) { + return; + } + + applyTransform(body.transform, this._localAnchorX, this._localAnchorY, scratch); + this._rx = scratch.x - body.worldCenterOfMassX; + this._ry = scratch.y - body.worldCenterOfMassY; + this._cx = scratch.x - this._targetX; + this._cy = scratch.y - this._targetY; + + const m = body.invMass; + const i = body.invInertia; + + // 2×2 effective-mass matrix K (only the dragged body contributes) and its inverse. + const k11 = m + i * this._ry * this._ry; + const k12 = -i * this._rx * this._ry; + const k22 = m + i * this._rx * this._rx; + const det = k11 * k22 - k12 * k12; + const invDet = det !== 0 ? 1 / det : 0; + + this._invK11 = invDet * k22; + this._invK12 = -invDet * k12; + this._invK22 = invDet * k11; + this._maxImpulse = this.maxForce * h; + + const omega = 2 * Math.PI * this.hertz; + const a1 = 2 * this.dampingRatio + h * omega; + const a2 = h * omega * a1; + const a3 = 1 / (1 + a2); + + this._biasRate = omega / a1; + this._massScale = a2 * a3; + this._impulseScale = a3; + } + + public override _warmStart(): void { + if (!this._active) { + return; + } + + this._applyImpulse(this._impulseX, this._impulseY); + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const body = this.bodyB; + + // Velocity of the grab point. + const cdotX = body.linearVelocityX - body.angularVelocity * this._ry; + const cdotY = body.linearVelocityY + body.angularVelocity * this._rx; + const rhsX = cdotX + (useBias ? this._biasRate * this._cx : 0); + const rhsY = cdotY + (useBias ? this._biasRate * this._cy : 0); + + const solvedX = this._invK11 * rhsX + this._invK12 * rhsY; + const solvedY = this._invK12 * rhsX + this._invK22 * rhsY; + let impulseX = -this._massScale * solvedX - this._impulseScale * this._impulseX; + let impulseY = -this._massScale * solvedY - this._impulseScale * this._impulseY; + + // Clamp the accumulated impulse magnitude to maxForce·h (a heavy body lags). + const oldX = this._impulseX; + const oldY = this._impulseY; + this._impulseX += impulseX; + this._impulseY += impulseY; + + const magnitude = Math.hypot(this._impulseX, this._impulseY); + + if (magnitude > this._maxImpulse) { + const scale = this._maxImpulse / magnitude; + + this._impulseX *= scale; + this._impulseY *= scale; + } + + impulseX = this._impulseX - oldX; + impulseY = this._impulseY - oldY; + this._applyImpulse(impulseX, impulseY); + } + + private _applyImpulse(jx: number, jy: number): void { + const body = this.bodyB; + + body.linearVelocityX += body.invMass * jx; + body.linearVelocityY += body.invMass * jy; + body.angularVelocity += body.invInertia * (this._rx * jy - this._ry * jx); + } +} diff --git a/packages/exojs-physics/src/joints/PrismaticJoint.ts b/packages/exojs-physics/src/joints/PrismaticJoint.ts new file mode 100644 index 00000000..e51c162d --- /dev/null +++ b/packages/exojs-physics/src/joints/PrismaticJoint.ts @@ -0,0 +1,310 @@ +import { applyInverseRotation, applyInverseTransform, applyRotation, applyTransform, type Mutable2D } from '../math'; +import type { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link PrismaticJoint}. */ +export interface PrismaticJointOptions { + /** First body (often a static rail anchor). */ + bodyA: PhysicsBody; + /** Second body (the slider). */ + bodyB: PhysicsBody; + /** Shared world-space anchor at creation. */ + anchor: VectorLike; + /** Slide axis in world space at creation (normalised internally). The body may only translate along this axis. */ + axis: VectorLike; + /** Enable the linear motor (drives translation along the axis toward {@link motorSpeed}). Default `false`. */ + enableMotor?: boolean; + /** Target translation speed along the axis (px/s). Default `0`. */ + motorSpeed?: number; + /** Maximum motor force — clamps the per-step motor impulse. Default `0`. */ + maxMotorForce?: number; + /** Enable the translation limit (keeps the axis translation in `[lowerTranslation, upperTranslation]`). Default `false`. */ + enableLimit?: boolean; + /** Lower translation limit along the axis (relative to the creation position). Default `0`. */ + lowerTranslation?: number; + /** Upper translation limit along the axis. Default `0`. */ + upperTranslation?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +/** + * Constrains a body to **slide along a single axis** relative to another: the + * perpendicular translation and the relative rotation are locked (a 2×2 block); + * only translation along the axis is free, optionally driven by a motor and/or + * bounded by a translation limit. Solved in the sub-step loop, warm-started. + */ +export class PrismaticJoint extends Joint { + /** When `true`, the motor drives the axis translation toward {@link motorSpeed}. */ + public enableMotor: boolean; + /** Target translation speed along the axis (px/s). */ + public motorSpeed: number; + /** Maximum motor force. */ + public maxMotorForce: number; + /** When `true`, the axis translation is constrained to `[lowerTranslation, upperTranslation]`. */ + public enableLimit: boolean; + /** Lower translation limit along the axis. */ + public lowerTranslation: number; + /** Upper translation limit along the axis. */ + public upperTranslation: number; + + private readonly _localAnchorAx: number; + private readonly _localAnchorAy: number; + private readonly _localAnchorBx: number; + private readonly _localAnchorBy: number; + private readonly _localAxisAx: number; + private readonly _localAxisAy: number; + private readonly _referenceAngle: number; + + private _axisX = 1; + private _axisY = 0; + private _perpX = 0; + private _perpY = 1; + private _a1 = 0; + private _a2 = 0; + private _s1 = 0; + private _s2 = 0; + private _k11 = 0; + private _k12 = 0; + private _k22 = 0; + private _cPerp = 0; + private _cAngle = 0; + private _translation = 0; + private _axialMass = 0; + private _h = 0; + private _invH = 0; + private _perpImpulse = 0; + private _angularImpulse = 0; + private _motorImpulse = 0; + private _lowerImpulse = 0; + private _upperImpulse = 0; + + public constructor(options: PrismaticJointOptions) { + super(options.bodyA, options.bodyB); + + applyInverseTransform(options.bodyA.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorAx = scratch.x; + this._localAnchorAy = scratch.y; + applyInverseTransform(options.bodyB.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorBx = scratch.x; + this._localAnchorBy = scratch.y; + + const axisLength = Math.hypot(options.axis.x, options.axis.y) || 1; + applyInverseRotation(options.bodyA.transform, options.axis.x / axisLength, options.axis.y / axisLength, scratch); + this._localAxisAx = scratch.x; + this._localAxisAy = scratch.y; + + this._referenceAngle = options.bodyB.angle - options.bodyA.angle; + this.enableMotor = options.enableMotor ?? false; + this.motorSpeed = options.motorSpeed ?? 0; + this.maxMotorForce = options.maxMotorForce ?? 0; + this.enableLimit = options.enableLimit ?? false; + this.lowerTranslation = options.lowerTranslation ?? 0; + this.upperTranslation = options.upperTranslation ?? 0; + } + + public override _prepare(h: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + this._active = this.enabled && !bodyA.isSleeping && !bodyB.isSleeping && (bodyA.invMass > 0 || bodyB.invMass > 0); + + if (!this._active) { + return; + } + + // World axis + perpendicular (axis is local to body A). + applyRotation(bodyA.transform, this._localAxisAx, this._localAxisAy, scratch); + const axisX = scratch.x; + const axisY = scratch.y; + const perpX = -axisY; + const perpY = axisX; + + this._axisX = axisX; + this._axisY = axisY; + this._perpX = perpX; + this._perpY = perpY; + + applyTransform(bodyA.transform, this._localAnchorAx, this._localAnchorAy, scratch); + const pAx = scratch.x; + const pAy = scratch.y; + applyTransform(bodyB.transform, this._localAnchorBx, this._localAnchorBy, scratch); + const pBx = scratch.x; + const pBy = scratch.y; + + const rAx = pAx - bodyA.worldCenterOfMassX; + const rAy = pAy - bodyA.worldCenterOfMassY; + const rBx = pBx - bodyB.worldCenterOfMassX; + const rBy = pBy - bodyB.worldCenterOfMassY; + const dx = pBx - pAx; + const dy = pBy - pAy; + + // Jacobian cross terms for the axis (motor/limit) and the perpendicular (lock). + this._a1 = (dx + rAx) * axisY - (dy + rAy) * axisX; + this._a2 = rBx * axisY - rBy * axisX; + this._s1 = (dx + rAx) * perpY - (dy + rAy) * perpX; + this._s2 = rBx * perpY - rBy * perpX; + + const mA = bodyA.invMass; + const mB = bodyB.invMass; + const iA = bodyA.invInertia; + const iB = bodyB.invInertia; + + const kAxial = mA + mB + iA * this._a1 * this._a1 + iB * this._a2 * this._a2; + this._axialMass = kAxial > 0 ? 1 / kAxial : 0; + + // Perpendicular + angular 2×2 block. + this._k11 = mA + mB + iA * this._s1 * this._s1 + iB * this._s2 * this._s2; + this._k12 = iA * this._s1 + iB * this._s2; + this._k22 = iA + iB; + + if (this._k22 === 0) { + this._k22 = 1; // both bodies rotation-locked — keep the block invertible + } + + this._cPerp = dx * perpX + dy * perpY; + this._cAngle = bodyB.angle - bodyA.angle - this._referenceAngle; + this._translation = dx * axisX + dy * axisY; + this._h = h; + this._invH = 1 / h; + + if (!this.enableMotor) { + this._motorImpulse = 0; + } + + if (!this.enableLimit) { + this._lowerImpulse = 0; + this._upperImpulse = 0; + } + } + + public override _warmStart(): void { + if (!this._active) { + return; + } + + const axial = this._motorImpulse + this._lowerImpulse - this._upperImpulse; + this._applyBlock(this._perpImpulse, this._angularImpulse); + this._applyAxial(axial); + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + // Motor along the axis. + if (this.enableMotor) { + const cdot = this._axisVelocity() - this.motorSpeed; + const max = this.maxMotorForce * this._h; + const old = this._motorImpulse; + + this._motorImpulse = Math.min(max, Math.max(-max, old - this._axialMass * cdot)); + this._applyAxial(this._motorImpulse - old); + } + + // Translation limits along the axis. + if (this.enableLimit) { + // Lower limit (translation ≥ lowerTranslation): positive impulse pushes along +axis. + const cLower = this._translation - this.lowerTranslation; + let biasLower = 0; + + if (cLower > 0) { + biasLower = cLower * this._invH; + } else if (useBias) { + biasLower = 0.2 * this._invH * cLower; + } + + const oldLower = this._lowerImpulse; + this._lowerImpulse = Math.max(0, oldLower - this._axialMass * (this._axisVelocity() + biasLower)); + this._applyAxial(this._lowerImpulse - oldLower); + + // Upper limit (translation ≤ upperTranslation): impulse pushes along −axis. + const cUpper = this.upperTranslation - this._translation; + let biasUpper = 0; + + if (cUpper > 0) { + biasUpper = cUpper * this._invH; + } else if (useBias) { + biasUpper = 0.2 * this._invH * cUpper; + } + + const oldUpper = this._upperImpulse; + this._upperImpulse = Math.max(0, oldUpper - this._axialMass * (-this._axisVelocity() + biasUpper)); + this._applyAxial(-(this._upperImpulse - oldUpper)); + } + + // Perpendicular + angular lock (2×2 block). + const cdotPerp = this._perpVelocity(); + const cdotAngle = bodyB.angularVelocity - bodyA.angularVelocity; + const rhs1 = -(cdotPerp + (useBias ? 0.2 * this._invH * this._cPerp : 0)); + const rhs2 = -(cdotAngle + (useBias ? 0.2 * this._invH * this._cAngle : 0)); + const det = this._k11 * this._k22 - this._k12 * this._k12; + const invDet = det !== 0 ? 1 / det : 0; + const dPerp = invDet * (this._k22 * rhs1 - this._k12 * rhs2); + const dAngle = invDet * (-this._k12 * rhs1 + this._k11 * rhs2); + + this._perpImpulse += dPerp; + this._angularImpulse += dAngle; + this._applyBlock(dPerp, dAngle); + } + + /** Relative velocity projected onto the axis (plus the rotation cross terms). */ + private _axisVelocity(): number { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + return ( + this._axisX * (bodyB.linearVelocityX - bodyA.linearVelocityX) + + this._axisY * (bodyB.linearVelocityY - bodyA.linearVelocityY) + + this._a2 * bodyB.angularVelocity - + this._a1 * bodyA.angularVelocity + ); + } + + /** Relative velocity projected onto the perpendicular (plus the rotation cross terms). */ + private _perpVelocity(): number { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + return ( + this._perpX * (bodyB.linearVelocityX - bodyA.linearVelocityX) + + this._perpY * (bodyB.linearVelocityY - bodyA.linearVelocityY) + + this._s2 * bodyB.angularVelocity - + this._s1 * bodyA.angularVelocity + ); + } + + private _applyAxial(impulse: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + const px = impulse * this._axisX; + const py = impulse * this._axisY; + + bodyA.linearVelocityX -= bodyA.invMass * px; + bodyA.linearVelocityY -= bodyA.invMass * py; + bodyA.angularVelocity -= bodyA.invInertia * impulse * this._a1; + bodyB.linearVelocityX += bodyB.invMass * px; + bodyB.linearVelocityY += bodyB.invMass * py; + bodyB.angularVelocity += bodyB.invInertia * impulse * this._a2; + } + + private _applyBlock(perpImpulse: number, angularImpulse: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + const px = perpImpulse * this._perpX; + const py = perpImpulse * this._perpY; + + bodyA.linearVelocityX -= bodyA.invMass * px; + bodyA.linearVelocityY -= bodyA.invMass * py; + bodyA.angularVelocity -= bodyA.invInertia * (perpImpulse * this._s1 + angularImpulse); + bodyB.linearVelocityX += bodyB.invMass * px; + bodyB.linearVelocityY += bodyB.invMass * py; + bodyB.angularVelocity += bodyB.invInertia * (perpImpulse * this._s2 + angularImpulse); + } +} diff --git a/packages/exojs-physics/src/joints/RevoluteJoint.ts b/packages/exojs-physics/src/joints/RevoluteJoint.ts new file mode 100644 index 00000000..8b0ac7f9 --- /dev/null +++ b/packages/exojs-physics/src/joints/RevoluteJoint.ts @@ -0,0 +1,282 @@ +import { applyInverseTransform, applyTransform, type Mutable2D } from '../math'; +import type { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link RevoluteJoint}. */ +export interface RevoluteJointOptions { + /** First body (often a static anchor). */ + bodyA: PhysicsBody; + /** Second body. */ + bodyB: PhysicsBody; + /** Shared world-space pivot point at creation. The two bodies are pinned here and may rotate freely about it. */ + anchor: VectorLike; + /** Soft-spring frequency in Hz; `0` (default) makes it a rigid pin. */ + hertz?: number; + /** Soft-spring damping ratio (used when `hertz > 0`). Default `1`. */ + dampingRatio?: number; + /** Enable the angular motor (drives `ωB − ωA` toward {@link motorSpeed}). Default `false`. */ + enableMotor?: boolean; + /** Target relative angular velocity in rad/s when the motor is enabled. Default `0`. */ + motorSpeed?: number; + /** Maximum motor torque — clamps the per-step motor impulse. Default `0`. */ + maxMotorTorque?: number; + /** Enable the angle limit (keeps the relative angle in `[lowerAngle, upperAngle]`). Default `false`. */ + enableLimit?: boolean; + /** Lower relative-angle limit in radians (relative to the angle at creation). Default `0`. */ + lowerAngle?: number; + /** Upper relative-angle limit in radians. Default `0`. */ + upperAngle?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +/** + * Pins a shared anchor point on two bodies (a hinge): the bodies may rotate + * freely about the pivot but the anchor points stay coincident. Solved as a + * 2-DOF point constraint (a 2×2 block) in the sub-step loop, warm-started. + */ +export class RevoluteJoint extends Joint { + /** Soft-spring frequency in Hz (`0` = rigid). */ + public hertz: number; + /** Soft-spring damping ratio. */ + public dampingRatio: number; + /** When `true`, the motor drives `ωB − ωA` toward {@link motorSpeed}. */ + public enableMotor: boolean; + /** Target relative angular velocity (rad/s) for the motor. */ + public motorSpeed: number; + /** Maximum motor torque. */ + public maxMotorTorque: number; + /** When `true`, the relative angle is constrained to `[lowerAngle, upperAngle]`. */ + public enableLimit: boolean; + /** Lower relative-angle limit (radians, relative to the creation angle). */ + public lowerAngle: number; + /** Upper relative-angle limit (radians). */ + public upperAngle: number; + + private readonly _localAnchorAx: number; + private readonly _localAnchorAy: number; + private readonly _localAnchorBx: number; + private readonly _localAnchorBy: number; + + private _rAx = 0; + private _rAy = 0; + private _rBx = 0; + private _rBy = 0; + private _cx = 0; + private _cy = 0; + private _invK11 = 0; + private _invK12 = 0; + private _invK22 = 0; + private _biasRate = 0; + private _massScale = 1; + private _impulseScale = 0; + private _impulseX = 0; + private _impulseY = 0; + private readonly _referenceAngle: number; + private _axialMass = 0; + private _h = 0; + private _invH = 0; + private _motorImpulse = 0; + private _lowerImpulse = 0; + private _upperImpulse = 0; + + public constructor(options: RevoluteJointOptions) { + super(options.bodyA, options.bodyB); + + applyInverseTransform(options.bodyA.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorAx = scratch.x; + this._localAnchorAy = scratch.y; + applyInverseTransform(options.bodyB.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorBx = scratch.x; + this._localAnchorBy = scratch.y; + + this.hertz = options.hertz ?? 0; + this.dampingRatio = options.dampingRatio ?? 1; + this.enableMotor = options.enableMotor ?? false; + this.motorSpeed = options.motorSpeed ?? 0; + this.maxMotorTorque = options.maxMotorTorque ?? 0; + this.enableLimit = options.enableLimit ?? false; + this.lowerAngle = options.lowerAngle ?? 0; + this.upperAngle = options.upperAngle ?? 0; + this._referenceAngle = options.bodyB.angle - options.bodyA.angle; + } + + public override _prepare(h: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + this._active = this.enabled && !bodyA.isSleeping && !bodyB.isSleeping && (bodyA.invMass > 0 || bodyB.invMass > 0); + + if (!this._active) { + return; + } + + applyTransform(bodyA.transform, this._localAnchorAx, this._localAnchorAy, scratch); + const pAx = scratch.x; + const pAy = scratch.y; + applyTransform(bodyB.transform, this._localAnchorBx, this._localAnchorBy, scratch); + const pBx = scratch.x; + const pBy = scratch.y; + + this._rAx = pAx - bodyA.worldCenterOfMassX; + this._rAy = pAy - bodyA.worldCenterOfMassY; + this._rBx = pBx - bodyB.worldCenterOfMassX; + this._rBy = pBy - bodyB.worldCenterOfMassY; + + // Position error (the anchors should coincide). + this._cx = pBx - pAx; + this._cy = pBy - pAy; + + const mA = bodyA.invMass; + const mB = bodyB.invMass; + const iA = bodyA.invInertia; + const iB = bodyB.invInertia; + + // 2×2 effective-mass matrix K and its inverse. + const k11 = mA + mB + iA * this._rAy * this._rAy + iB * this._rBy * this._rBy; + const k12 = -iA * this._rAx * this._rAy - iB * this._rBx * this._rBy; + const k22 = mA + mB + iA * this._rAx * this._rAx + iB * this._rBx * this._rBx; + const det = k11 * k22 - k12 * k12; + const invDet = det !== 0 ? 1 / det : 0; + + this._invK11 = invDet * k22; + this._invK12 = -invDet * k12; + this._invK22 = invDet * k11; + + // Angular (motor/limit) effective mass + sub-step rate. + this._axialMass = iA + iB > 0 ? 1 / (iA + iB) : 0; + this._h = h; + this._invH = 1 / h; + + if (!this.enableMotor) { + this._motorImpulse = 0; + } + + if (!this.enableLimit) { + this._lowerImpulse = 0; + this._upperImpulse = 0; + } + + if (this.hertz > 0) { + const omega = 2 * Math.PI * this.hertz; + const a1 = 2 * this.dampingRatio + h * omega; + const a2 = h * omega * a1; + const a3 = 1 / (1 + a2); + + this._biasRate = omega / a1; + this._massScale = a2 * a3; + this._impulseScale = a3; + } else { + this._biasRate = 0.2 / h; + this._massScale = 1; + this._impulseScale = 0; + } + } + + public override _warmStart(): void { + if (!this._active) { + return; + } + + // Angular warm-start: motor + limits (lower pushes +, upper pushes −). + const axial = this._motorImpulse + this._lowerImpulse - this._upperImpulse; + this.bodyA.angularVelocity -= this.bodyA.invInertia * axial; + this.bodyB.angularVelocity += this.bodyB.invInertia * axial; + + this._applyImpulse(this._impulseX, this._impulseY); + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + const iA = bodyA.invInertia; + const iB = bodyB.invInertia; + + // Angular motor: drive ωB − ωA toward motorSpeed, clamped to ±maxMotorTorque·h. + if (this.enableMotor) { + const cdot = bodyB.angularVelocity - bodyA.angularVelocity - this.motorSpeed; + const max = this.maxMotorTorque * this._h; + const old = this._motorImpulse; + + this._motorImpulse = Math.min(max, Math.max(-max, old - this._axialMass * cdot)); + + const applied = this._motorImpulse - old; + bodyA.angularVelocity -= iA * applied; + bodyB.angularVelocity += iB * applied; + } + + // Angle limits: one-sided constraints keeping the relative angle in [lower, upper]. + if (this.enableLimit) { + const angle = bodyB.angle + bodyB._deltaAngle - (bodyA.angle + bodyA._deltaAngle) - this._referenceAngle; + + // Lower limit (angle ≥ lowerAngle): a positive impulse increases the angle. + const cLower = angle - this.lowerAngle; + let biasLower = 0; + + if (cLower > 0) { + biasLower = cLower * this._invH; // speculative: allow approach, engage at the surface + } else if (useBias) { + biasLower = 0.2 * this._invH * cLower; // Baumgarte push-back when violated + } + + const oldLower = this._lowerImpulse; + this._lowerImpulse = Math.max(0, oldLower - this._axialMass * (bodyB.angularVelocity - bodyA.angularVelocity + biasLower)); + + const appliedLower = this._lowerImpulse - oldLower; + bodyA.angularVelocity -= iA * appliedLower; + bodyB.angularVelocity += iB * appliedLower; + + // Upper limit (angle ≤ upperAngle): a positive impulse decreases the angle. + const cUpper = this.upperAngle - angle; + let biasUpper = 0; + + if (cUpper > 0) { + biasUpper = cUpper * this._invH; + } else if (useBias) { + biasUpper = 0.2 * this._invH * cUpper; + } + + const oldUpper = this._upperImpulse; + this._upperImpulse = Math.max(0, oldUpper - this._axialMass * (bodyA.angularVelocity - bodyB.angularVelocity + biasUpper)); + + const appliedUpper = this._upperImpulse - oldUpper; + bodyA.angularVelocity += iA * appliedUpper; + bodyB.angularVelocity -= iB * appliedUpper; + } + + // Relative velocity of the anchors. + const cdotX = bodyB.linearVelocityX - bodyB.angularVelocity * this._rBy - (bodyA.linearVelocityX - bodyA.angularVelocity * this._rAy); + const cdotY = bodyB.linearVelocityY + bodyB.angularVelocity * this._rBx - (bodyA.linearVelocityY + bodyA.angularVelocity * this._rAx); + + const rhsX = cdotX + (useBias ? this._biasRate * this._cx : 0); + const rhsY = cdotY + (useBias ? this._biasRate * this._cy : 0); + + // Solve K·λ = −rhs, then apply the soft mass/impulse scaling. + const solvedX = this._invK11 * rhsX + this._invK12 * rhsY; + const solvedY = this._invK12 * rhsX + this._invK22 * rhsY; + const impulseX = -this._massScale * solvedX - this._impulseScale * this._impulseX; + const impulseY = -this._massScale * solvedY - this._impulseScale * this._impulseY; + + this._impulseX += impulseX; + this._impulseY += impulseY; + this._applyImpulse(impulseX, impulseY); + } + + private _applyImpulse(jx: number, jy: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + bodyA.linearVelocityX -= bodyA.invMass * jx; + bodyA.linearVelocityY -= bodyA.invMass * jy; + bodyA.angularVelocity -= bodyA.invInertia * (this._rAx * jy - this._rAy * jx); + bodyB.linearVelocityX += bodyB.invMass * jx; + bodyB.linearVelocityY += bodyB.invMass * jy; + bodyB.angularVelocity += bodyB.invInertia * (this._rBx * jy - this._rBy * jx); + } +} diff --git a/packages/exojs-physics/src/joints/WeldJoint.ts b/packages/exojs-physics/src/joints/WeldJoint.ts new file mode 100644 index 00000000..c3a52b0f --- /dev/null +++ b/packages/exojs-physics/src/joints/WeldJoint.ts @@ -0,0 +1,211 @@ +import { applyInverseTransform, applyTransform, type Mutable2D } from '../math'; +import type { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link WeldJoint}. */ +export interface WeldJointOptions { + /** First body. */ + bodyA: PhysicsBody; + /** Second body. */ + bodyB: PhysicsBody; + /** World-space anchor the linear constraint acts at. Default: the midpoint of the two bodies. */ + anchor?: VectorLike; + /** Locked relative angle `angleB − angleA`. Default: the current relative angle at creation. */ + referenceAngle?: number; + /** Soft frequency (Hz) for the position lock; `0` (default) is rigid. */ + linearHertz?: number; + /** Soft frequency (Hz) for the angle lock; `0` (default) is rigid. */ + angularHertz?: number; + /** Soft damping ratio (used when a hertz is `> 0`). Default `1`. */ + dampingRatio?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +interface SoftFactors { + biasRate: number; + massScale: number; + impulseScale: number; +} + +/** Box2D-v3 soft-constraint factors at sub-step `h`, or rigid Baumgarte when `hertz === 0`. */ +const computeSoftFactors = (hertz: number, dampingRatio: number, h: number, out: SoftFactors): void => { + if (hertz > 0) { + const omega = 2 * Math.PI * hertz; + const a1 = 2 * dampingRatio + h * omega; + const a2 = h * omega * a1; + const a3 = 1 / (1 + a2); + + out.biasRate = omega / a1; + out.massScale = a2 * a3; + out.impulseScale = a3; + } else { + out.biasRate = 0.2 / h; + out.massScale = 1; + out.impulseScale = 0; + } +}; + +/** + * Rigidly fixes the relative position and orientation of two bodies (they move + * as one rigid body). A 2-DOF point constraint (like {@link RevoluteJoint}) plus + * a 1-DOF angular constraint, solved in the sub-step loop. Both locks default to + * rigid; set `linearHertz`/`angularHertz` for a springy weld. + */ +export class WeldJoint extends Joint { + /** Locked relative angle `angleB − angleA`. */ + public referenceAngle: number; + /** Soft frequency for the position lock (`0` = rigid). */ + public linearHertz: number; + /** Soft frequency for the angle lock (`0` = rigid). */ + public angularHertz: number; + /** Soft damping ratio. */ + public dampingRatio: number; + + private readonly _localAnchorAx: number; + private readonly _localAnchorAy: number; + private readonly _localAnchorBx: number; + private readonly _localAnchorBy: number; + + private _rAx = 0; + private _rAy = 0; + private _rBx = 0; + private _rBy = 0; + private _cx = 0; + private _cy = 0; + private _invK11 = 0; + private _invK12 = 0; + private _invK22 = 0; + private _angleError = 0; + private _effMassAngle = 0; + private readonly _linear: SoftFactors = { biasRate: 0, massScale: 1, impulseScale: 0 }; + private readonly _angular: SoftFactors = { biasRate: 0, massScale: 1, impulseScale: 0 }; + private _impulseX = 0; + private _impulseY = 0; + private _impulseAngle = 0; + + public constructor(options: WeldJointOptions) { + super(options.bodyA, options.bodyB); + + const ax = options.anchor?.x ?? (options.bodyA.x + options.bodyB.x) / 2; + const ay = options.anchor?.y ?? (options.bodyA.y + options.bodyB.y) / 2; + + applyInverseTransform(options.bodyA.transform, ax, ay, scratch); + this._localAnchorAx = scratch.x; + this._localAnchorAy = scratch.y; + applyInverseTransform(options.bodyB.transform, ax, ay, scratch); + this._localAnchorBx = scratch.x; + this._localAnchorBy = scratch.y; + + this.referenceAngle = options.referenceAngle ?? options.bodyB.angle - options.bodyA.angle; + this.linearHertz = options.linearHertz ?? 0; + this.angularHertz = options.angularHertz ?? 0; + this.dampingRatio = options.dampingRatio ?? 1; + } + + public override _prepare(h: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + this._active = this.enabled && !bodyA.isSleeping && !bodyB.isSleeping && (bodyA.invMass > 0 || bodyB.invMass > 0); + + if (!this._active) { + return; + } + + applyTransform(bodyA.transform, this._localAnchorAx, this._localAnchorAy, scratch); + const pAx = scratch.x; + const pAy = scratch.y; + applyTransform(bodyB.transform, this._localAnchorBx, this._localAnchorBy, scratch); + const pBx = scratch.x; + const pBy = scratch.y; + + this._rAx = pAx - bodyA.worldCenterOfMassX; + this._rAy = pAy - bodyA.worldCenterOfMassY; + this._rBx = pBx - bodyB.worldCenterOfMassX; + this._rBy = pBy - bodyB.worldCenterOfMassY; + this._cx = pBx - pAx; + this._cy = pBy - pAy; + + const mA = bodyA.invMass; + const mB = bodyB.invMass; + const iA = bodyA.invInertia; + const iB = bodyB.invInertia; + + const k11 = mA + mB + iA * this._rAy * this._rAy + iB * this._rBy * this._rBy; + const k12 = -iA * this._rAx * this._rAy - iB * this._rBx * this._rBy; + const k22 = mA + mB + iA * this._rAx * this._rAx + iB * this._rBx * this._rBx; + const det = k11 * k22 - k12 * k12; + const invDet = det !== 0 ? 1 / det : 0; + + this._invK11 = invDet * k22; + this._invK12 = -invDet * k12; + this._invK22 = invDet * k11; + + this._angleError = bodyB.angle - bodyA.angle - this.referenceAngle; + const kAngle = iA + iB; + this._effMassAngle = kAngle > 0 ? 1 / kAngle : 0; + + computeSoftFactors(this.linearHertz, this.dampingRatio, h, this._linear); + computeSoftFactors(this.angularHertz, this.dampingRatio, h, this._angular); + } + + public override _warmStart(): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + bodyA.angularVelocity -= bodyA.invInertia * this._impulseAngle; + bodyB.angularVelocity += bodyB.invInertia * this._impulseAngle; + this._applyLinearImpulse(this._impulseX, this._impulseY); + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + // Angular lock first. + const cdotAngle = bodyB.angularVelocity - bodyA.angularVelocity; + const biasAngle = useBias ? this._angular.biasRate * this._angleError : 0; + const impulseAngle = -this._effMassAngle * this._angular.massScale * (cdotAngle + biasAngle) - this._angular.impulseScale * this._impulseAngle; + + this._impulseAngle += impulseAngle; + bodyA.angularVelocity -= bodyA.invInertia * impulseAngle; + bodyB.angularVelocity += bodyB.invInertia * impulseAngle; + + // Linear (point) lock. + const cdotX = bodyB.linearVelocityX - bodyB.angularVelocity * this._rBy - (bodyA.linearVelocityX - bodyA.angularVelocity * this._rAy); + const cdotY = bodyB.linearVelocityY + bodyB.angularVelocity * this._rBx - (bodyA.linearVelocityY + bodyA.angularVelocity * this._rAx); + const rhsX = cdotX + (useBias ? this._linear.biasRate * this._cx : 0); + const rhsY = cdotY + (useBias ? this._linear.biasRate * this._cy : 0); + const solvedX = this._invK11 * rhsX + this._invK12 * rhsY; + const solvedY = this._invK12 * rhsX + this._invK22 * rhsY; + const impulseX = -this._linear.massScale * solvedX - this._linear.impulseScale * this._impulseX; + const impulseY = -this._linear.massScale * solvedY - this._linear.impulseScale * this._impulseY; + + this._impulseX += impulseX; + this._impulseY += impulseY; + this._applyLinearImpulse(impulseX, impulseY); + } + + private _applyLinearImpulse(jx: number, jy: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + bodyA.linearVelocityX -= bodyA.invMass * jx; + bodyA.linearVelocityY -= bodyA.invMass * jy; + bodyA.angularVelocity -= bodyA.invInertia * (this._rAx * jy - this._rAy * jx); + bodyB.linearVelocityX += bodyB.invMass * jx; + bodyB.linearVelocityY += bodyB.invMass * jy; + bodyB.angularVelocity += bodyB.invInertia * (this._rBx * jy - this._rBy * jx); + } +} diff --git a/packages/exojs-physics/src/joints/WheelJoint.ts b/packages/exojs-physics/src/joints/WheelJoint.ts new file mode 100644 index 00000000..bb28028c --- /dev/null +++ b/packages/exojs-physics/src/joints/WheelJoint.ts @@ -0,0 +1,280 @@ +import { applyInverseRotation, applyInverseTransform, applyRotation, applyTransform, type Mutable2D } from '../math'; +import type { PhysicsBody } from '../PhysicsBody'; +import type { VectorLike } from '../types'; +import { Joint } from './Joint'; + +/** Construction options for a {@link WheelJoint}. */ +export interface WheelJointOptions { + /** First body (the chassis). */ + bodyA: PhysicsBody; + /** Second body (the wheel). */ + bodyB: PhysicsBody; + /** Shared world-space anchor at creation (the wheel hub). */ + anchor: VectorLike; + /** Suspension axis in world space at creation (normalised internally). */ + axis: VectorLike; + /** Suspension spring frequency in Hz (`0` makes the axis rigid). Default `0`. */ + hertz?: number; + /** Suspension spring damping ratio. Default `1`. */ + dampingRatio?: number; + /** Enable the rotation motor (drives the wheel's spin). Default `false`. */ + enableMotor?: boolean; + /** Target relative angular velocity (rad/s) for the motor. Default `0`. */ + motorSpeed?: number; + /** Maximum motor torque. Default `0`. */ + maxMotorTorque?: number; +} + +/** Reused output sink — physics steps single-threaded, so a shared scratch is safe. */ +const scratch: Mutable2D = { x: 0, y: 0 }; + +/** + * A wheel attached to a chassis: free to **spin** (no rotation lock) and sprung + * along a **suspension axis** (a soft spring), but locked **laterally** (it + * cannot slide perpendicular to the axis). Optionally driven by a rotation + * motor. Used for vehicles. Solved in the sub-step loop, warm-started. + */ +export class WheelJoint extends Joint { + /** Suspension spring frequency in Hz (`0` = rigid axis). */ + public hertz: number; + /** Suspension spring damping ratio. */ + public dampingRatio: number; + /** When `true`, the motor drives the wheel's spin toward {@link motorSpeed}. */ + public enableMotor: boolean; + /** Target relative angular velocity (rad/s) for the motor. */ + public motorSpeed: number; + /** Maximum motor torque. */ + public maxMotorTorque: number; + + private readonly _localAnchorAx: number; + private readonly _localAnchorAy: number; + private readonly _localAnchorBx: number; + private readonly _localAnchorBy: number; + private readonly _localAxisAx: number; + private readonly _localAxisAy: number; + + private _axisX = 1; + private _axisY = 0; + private _perpX = 0; + private _perpY = 1; + private _a1 = 0; + private _a2 = 0; + private _s1 = 0; + private _s2 = 0; + private _perpMass = 0; + private _axialMass = 0; + private _angularMass = 0; + private _springBiasRate = 0; + private _springMassScale = 1; + private _springImpulseScale = 0; + private _cPerp = 0; + private _translation = 0; + private _h = 0; + private _invH = 0; + private _perpImpulse = 0; + private _springImpulse = 0; + private _motorImpulse = 0; + + public constructor(options: WheelJointOptions) { + super(options.bodyA, options.bodyB); + + applyInverseTransform(options.bodyA.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorAx = scratch.x; + this._localAnchorAy = scratch.y; + applyInverseTransform(options.bodyB.transform, options.anchor.x, options.anchor.y, scratch); + this._localAnchorBx = scratch.x; + this._localAnchorBy = scratch.y; + + const axisLength = Math.hypot(options.axis.x, options.axis.y) || 1; + applyInverseRotation(options.bodyA.transform, options.axis.x / axisLength, options.axis.y / axisLength, scratch); + this._localAxisAx = scratch.x; + this._localAxisAy = scratch.y; + + this.hertz = options.hertz ?? 0; + this.dampingRatio = options.dampingRatio ?? 1; + this.enableMotor = options.enableMotor ?? false; + this.motorSpeed = options.motorSpeed ?? 0; + this.maxMotorTorque = options.maxMotorTorque ?? 0; + } + + public override _prepare(h: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + this._active = this.enabled && !bodyA.isSleeping && !bodyB.isSleeping && (bodyA.invMass > 0 || bodyB.invMass > 0); + + if (!this._active) { + return; + } + + applyRotation(bodyA.transform, this._localAxisAx, this._localAxisAy, scratch); + const axisX = scratch.x; + const axisY = scratch.y; + const perpX = -axisY; + const perpY = axisX; + + this._axisX = axisX; + this._axisY = axisY; + this._perpX = perpX; + this._perpY = perpY; + + applyTransform(bodyA.transform, this._localAnchorAx, this._localAnchorAy, scratch); + const pAx = scratch.x; + const pAy = scratch.y; + applyTransform(bodyB.transform, this._localAnchorBx, this._localAnchorBy, scratch); + const pBx = scratch.x; + const pBy = scratch.y; + + const rAx = pAx - bodyA.worldCenterOfMassX; + const rAy = pAy - bodyA.worldCenterOfMassY; + const rBx = pBx - bodyB.worldCenterOfMassX; + const rBy = pBy - bodyB.worldCenterOfMassY; + const dx = pBx - pAx; + const dy = pBy - pAy; + + this._a1 = (dx + rAx) * axisY - (dy + rAy) * axisX; + this._a2 = rBx * axisY - rBy * axisX; + this._s1 = (dx + rAx) * perpY - (dy + rAy) * perpX; + this._s2 = rBx * perpY - rBy * perpX; + + const mA = bodyA.invMass; + const mB = bodyB.invMass; + const iA = bodyA.invInertia; + const iB = bodyB.invInertia; + + const kPerp = mA + mB + iA * this._s1 * this._s1 + iB * this._s2 * this._s2; + this._perpMass = kPerp > 0 ? 1 / kPerp : 0; + const kAxial = mA + mB + iA * this._a1 * this._a1 + iB * this._a2 * this._a2; + this._axialMass = kAxial > 0 ? 1 / kAxial : 0; + this._angularMass = iA + iB > 0 ? 1 / (iA + iB) : 0; + + this._cPerp = dx * perpX + dy * perpY; + this._translation = dx * axisX + dy * axisY; + this._h = h; + this._invH = 1 / h; + + if (this.hertz > 0) { + const omega = 2 * Math.PI * this.hertz; + const a1 = 2 * this.dampingRatio + h * omega; + const a2 = h * omega * a1; + const a3 = 1 / (1 + a2); + + this._springBiasRate = omega / a1; + this._springMassScale = a2 * a3; + this._springImpulseScale = a3; + } else { + this._springBiasRate = 0.2 / h; + this._springMassScale = 1; + this._springImpulseScale = 0; + } + + if (!this.enableMotor) { + this._motorImpulse = 0; + } + } + + public override _warmStart(): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + // Rotation motor (angular). + bodyA.angularVelocity -= bodyA.invInertia * this._motorImpulse; + bodyB.angularVelocity += bodyB.invInertia * this._motorImpulse; + + this._applyAxial(this._springImpulse); + this._applyPerp(this._perpImpulse); + } + + public override _solve(useBias: boolean): void { + if (!this._active) { + return; + } + + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + // Rotation motor — drives the wheel's spin; rotation is otherwise free. + if (this.enableMotor) { + const cdot = bodyB.angularVelocity - bodyA.angularVelocity - this.motorSpeed; + const max = this.maxMotorTorque * this._h; + const old = this._motorImpulse; + + this._motorImpulse = Math.min(max, Math.max(-max, old - this._angularMass * cdot)); + + const applied = this._motorImpulse - old; + bodyA.angularVelocity -= bodyA.invInertia * applied; + bodyB.angularVelocity += bodyB.invInertia * applied; + } + + // Suspension spring along the axis (soft). + const cdotAxis = this._axisVelocity(); + const springImpulse = + -this._axialMass * this._springMassScale * (cdotAxis + this._springBiasRate * this._translation) - this._springImpulseScale * this._springImpulse; + + this._springImpulse += springImpulse; + this._applyAxial(springImpulse); + + // Lateral lock (perpendicular, rigid). + const cdotPerp = this._perpVelocity(); + const perpImpulse = -this._perpMass * (cdotPerp + (useBias ? 0.2 * this._invH * this._cPerp : 0)); + + this._perpImpulse += perpImpulse; + this._applyPerp(perpImpulse); + } + + private _axisVelocity(): number { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + return ( + this._axisX * (bodyB.linearVelocityX - bodyA.linearVelocityX) + + this._axisY * (bodyB.linearVelocityY - bodyA.linearVelocityY) + + this._a2 * bodyB.angularVelocity - + this._a1 * bodyA.angularVelocity + ); + } + + private _perpVelocity(): number { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + + return ( + this._perpX * (bodyB.linearVelocityX - bodyA.linearVelocityX) + + this._perpY * (bodyB.linearVelocityY - bodyA.linearVelocityY) + + this._s2 * bodyB.angularVelocity - + this._s1 * bodyA.angularVelocity + ); + } + + private _applyAxial(impulse: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + const px = impulse * this._axisX; + const py = impulse * this._axisY; + + bodyA.linearVelocityX -= bodyA.invMass * px; + bodyA.linearVelocityY -= bodyA.invMass * py; + bodyA.angularVelocity -= bodyA.invInertia * impulse * this._a1; + bodyB.linearVelocityX += bodyB.invMass * px; + bodyB.linearVelocityY += bodyB.invMass * py; + bodyB.angularVelocity += bodyB.invInertia * impulse * this._a2; + } + + private _applyPerp(impulse: number): void { + const bodyA = this.bodyA; + const bodyB = this.bodyB; + const px = impulse * this._perpX; + const py = impulse * this._perpY; + + bodyA.linearVelocityX -= bodyA.invMass * px; + bodyA.linearVelocityY -= bodyA.invMass * py; + bodyA.angularVelocity -= bodyA.invInertia * impulse * this._s1; + bodyB.linearVelocityX += bodyB.invMass * px; + bodyB.linearVelocityY += bodyB.invMass * py; + bodyB.angularVelocity += bodyB.invInertia * impulse * this._s2; + } +} diff --git a/packages/exojs-physics/src/public.ts b/packages/exojs-physics/src/public.ts index a516973a..d2f9023d 100644 --- a/packages/exojs-physics/src/public.ts +++ b/packages/exojs-physics/src/public.ts @@ -9,6 +9,13 @@ export { PhysicsBinding } from './binding/PhysicsBinding'; export type { ColliderOptions } from './Collider'; export { Collider } from './Collider'; export type { CollisionEvent, ContactPoint, SensorEvent } from './events'; +export { DistanceJoint, type DistanceJointOptions } from './joints/DistanceJoint'; +export { Joint } from './joints/Joint'; +export { MouseJoint, type MouseJointOptions } from './joints/MouseJoint'; +export { PrismaticJoint, type PrismaticJointOptions } from './joints/PrismaticJoint'; +export { RevoluteJoint, type RevoluteJointOptions } from './joints/RevoluteJoint'; +export { WeldJoint, type WeldJointOptions } from './joints/WeldJoint'; +export { WheelJoint, type WheelJointOptions } from './joints/WheelJoint'; export type { BodyOptions } from './PhysicsBody'; export { PhysicsBody } from './PhysicsBody'; export { type PhysicsBuildInfo,physicsBuildInfo } from './physicsBuildInfo'; diff --git a/packages/exojs-physics/src/solver/ContactSolver.ts b/packages/exojs-physics/src/solver/ContactSolver.ts index ea1f3cfc..04231f08 100644 --- a/packages/exojs-physics/src/solver/ContactSolver.ts +++ b/packages/exojs-physics/src/solver/ContactSolver.ts @@ -133,6 +133,15 @@ export class ContactSolver { const bodyA = contact.a.body; const bodyB = contact.b.body; + + // Skip contacts where either body is asleep: a sleeping island's solid + // contacts are all sleeping↔sleeping or sleeping↔static (both at rest), so + // there is nothing to solve. A sleeping body touched by an awake one is + // woken (island merge) before this runs, so it is never skipped wrongly. + if (bodyA.isSleeping || bodyB.isSleeping) { + continue; + } + const constraint = this._acquire(); const nx = manifold.normalX; const ny = manifold.normalY; diff --git a/packages/exojs-physics/test/ccd.test.ts b/packages/exojs-physics/test/ccd.test.ts new file mode 100644 index 00000000..e744955d --- /dev/null +++ b/packages/exojs-physics/test/ccd.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '../src/index'; + +/** + * Continuous collision detection / bullet-mode (spec `03-ccd.md`, gate C-1). + * A body flagged `isBullet` is swept against static geometry each step so it + * cannot tunnel through thin walls. SG-C prefix, +Y down. + */ + +const FRAME = 1 / 60; + +/** A world with a thin static wall at `x = 200` and a small ball fired at it from the left. */ +const fireAtWall = (bullet: boolean): { world: PhysicsWorld; ball: PhysicsBody } => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + + world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] })); + + const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(6) }] }); + ball.isBullet = bullet; + world.add(ball); + ball.linearVelocityX = 6000; // ~100 px per fixed step — far more than the 4px wall thickness + + return { world, ball }; +}; + +describe('SG-C — continuous collision (bullet mode)', () => { + it('SG-C1: a fast bullet does not tunnel through a thin static wall', () => { + // Without CCD the body tunnels straight through (the documented limit). + const plain = fireAtWall(false); + for (let frame = 0; frame < 30; frame++) { + plain.world.step(FRAME); + } + expect(plain.ball.x).toBeGreaterThan(220); // passed clean through the wall + + // With isBullet the swept test stops it at the wall. + const ccd = fireAtWall(true); + for (let frame = 0; frame < 30; frame++) { + ccd.world.step(FRAME); + } + expect(ccd.ball.x).toBeLessThan(200); // never crossed the wall plane at x = 200 + expect(Number.isFinite(ccd.ball.x)).toBe(true); + }); + + it('SG-C2: a bullet that does not cross a wall is not falsely stopped', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + // The wall spans y ∈ [−200, 200]; the bullet flies past well above it. + world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] })); + + const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 300 }, colliders: [{ shape: new CircleShape(6) }] }); + ball.isBullet = true; + world.add(ball); + ball.linearVelocityX = 6000; + + for (let frame = 0; frame < 10; frame++) { + world.step(FRAME); + } + + expect(ball.x).toBeGreaterThan(400); // flew freely past the wall's x without being clamped + }); + + it('SG-C3: a fast bullet stays finite and contained in a closed static box', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const wall = (x: number, y: number, w: number, h: number): void => { + world.add(new PhysicsBody({ type: 'static', position: { x, y }, colliders: [{ shape: new BoxShape(w, h) }] })); + }; + wall(0, -110, 240, 20); + wall(0, 110, 240, 20); + wall(-110, 0, 20, 240); + wall(110, 0, 20, 240); + + const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(6), restitution: 0.5 }] }); + ball.isBullet = true; + world.add(ball); + ball.linearVelocityX = 5000; + ball.linearVelocityY = 3000; + + for (let frame = 0; frame < 120; frame++) { + world.step(FRAME); + } + + // CCD keeps it inside the thin-walled box — never escaping, never NaN. + expect(Number.isFinite(ball.x)).toBe(true); + expect(Number.isFinite(ball.y)).toBe(true); + expect(Math.abs(ball.x)).toBeLessThan(105); + expect(Math.abs(ball.y)).toBeLessThan(105); + }); + + it('SG-C4: a fast bullet does not tunnel through a thin *dynamic* body', () => { + // A thin dynamic target the bullet's per-step landings straddle without ever + // landing inside it, so discrete detection misses it — only a swept test catches it. + const make = (bullet: boolean): { world: PhysicsWorld; ball: PhysicsBody } => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + // Heavy thin dynamic target at x = 200 (barely shoved within the short run). + world.add(new PhysicsBody({ type: 'dynamic', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(8, 160), density: 50 }] })); + + const ball = new PhysicsBody({ type: 'dynamic', position: { x: 55, y: 0 }, colliders: [{ shape: new CircleShape(6) }] }); + ball.isBullet = bullet; + world.add(ball); + ball.linearVelocityX = 6000; // step landings 55 → 155 → 255 straddle the target without landing in it + + return { world, ball }; + }; + + // A non-bullet skips clean through the thin target (discrete detection misses it). + const plain = make(false); + for (let frame = 0; frame < 6; frame++) { + plain.world.step(FRAME); + } + expect(plain.ball.x).toBeGreaterThan(210); // tunnelled straight past the dynamic target + + // The bullet is swept against the dynamic target too and stops just short of it. + const ccd = make(true); + for (let frame = 0; frame < 6; frame++) { + ccd.world.step(FRAME); + } + expect(ccd.ball.x).toBeLessThan(196); // clamped on the near side of the target's left face + expect(Number.isFinite(ccd.ball.x)).toBe(true); + }); + + it('SG-C5: a bullet hitting a wall obliquely deflects (keeps its tangential velocity)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 800) }] })); + + // Fired right-and-down at a vertical wall: the wall normal is horizontal, so a + // correct impact resolves the x-component and leaves the y (tangential) intact. + const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(6) }] }); + ball.isBullet = true; + world.add(ball); + ball.linearVelocityX = 6000; + ball.linearVelocityY = 2000; + + for (let frame = 0; frame < 5; frame++) { + world.step(FRAME); + } + + expect(ball.x).toBeLessThan(200); // stopped at the wall, did not tunnel + // It slides down the wall — a velocity-kill (stripping the whole travel vector) + // would have dead-stopped it at vy ≈ 0. + expect(ball.linearVelocityY).toBeGreaterThan(1000); + }); +}); diff --git a/packages/exojs-physics/test/joints.test.ts b/packages/exojs-physics/test/joints.test.ts new file mode 100644 index 00000000..2f08a3c6 --- /dev/null +++ b/packages/exojs-physics/test/joints.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; + +import { BoxShape, CircleShape, DistanceJoint, MouseJoint, PhysicsBody, PhysicsWorld, PrismaticJoint, RevoluteJoint, WeldJoint, WheelJoint } from '../src/index'; + +/** + * Joints (spec `02-joints.md`). Soft constraints solved in the sub-step loop + * alongside contacts. SG-J prefix, default solver config, +Y down. + */ + +const GRAVITY = 1000; // px/s² +const FRAME = 1 / 60; + +const advance = (world: PhysicsWorld, seconds: number): void => { + const frames = Math.round(seconds / FRAME); + + for (let frame = 0; frame < frames; frame++) { + world.step(FRAME); + } +}; + +describe('SG-J — joints', () => { + it('SG-J1: a distance joint holds a hanging body at the rest length', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // Bob starts straight below the anchor, past the rest length: the joint pulls + // it up to 100 and holds it there against gravity. + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 150 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); + + advance(world, 3); + + // The bob hangs straight down at the rest length under gravity. + const distance = Math.hypot(bob.x - anchor.x, bob.y - anchor.y); + expect(distance).toBeCloseTo(100, 0); // within ~1px + expect(bob.x).toBeCloseTo(0, 0); + expect(bob.y).toBeGreaterThan(50); // below the anchor (+Y down) + }); + + it('SG-J2: a soft distance joint (hertz>0) settles bounded near the rest length', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 100 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100, hertz: 2.5, dampingRatio: 1 })); + + advance(world, 4); + + // A damped spring sags under gravity but stays bounded near the rest length. + const distance = Math.hypot(bob.x - anchor.x, bob.y - anchor.y); + expect(distance).toBeGreaterThan(95); + expect(distance).toBeLessThan(160); + expect(bob.x).toBeCloseTo(0, 0); + }); + + it('SG-J3: waking one jointed body wakes the other (island edge)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const a = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + const b = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 100, y: 0 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new DistanceJoint({ bodyA: a, bodyB: b, length: 100 })); + + advance(world, 2); + expect(a.isSleeping).toBe(true); + expect(b.isSleeping).toBe(true); + + a.applyImpulse(2000, 0); // wake only a directly + world.step(1 / 60); // the island pass propagates the wake across the joint + + expect(a.isSleeping).toBe(false); + expect(b.isSleeping).toBe(false); + }); + + it('SG-J4: joint simulation is deterministic across identical runs', () => { + const run = (): string => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 60, y: 120 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); + + const trace: string[] = []; + + for (let frame = 0; frame < 180; frame++) { + world.step(FRAME); + trace.push(`${bob.x.toFixed(4)},${bob.y.toFixed(4)}`); + } + + return trace.join('|'); + }; + + expect(run()).toBe(run()); + }); + + it('SG-J5: removeJoint releases the body (it falls freely)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 100 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + const joint = world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); + + advance(world, 1); + expect(Math.hypot(bob.x - anchor.x, bob.y - anchor.y)).toBeCloseTo(100, 0); // held + + world.removeJoint(joint); + advance(world, 1); + + expect(bob.y).toBeGreaterThan(250); // now falls freely under gravity + }); + + it('SG-J6: a revolute joint pins the bob to the pivot as it swings', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // Bob hangs down-right of the pivot at the world origin; it swings under gravity. + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 70, y: 70 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: bob, anchor: { x: 0, y: 0 } })); + + const radius = Math.hypot(70, 70); + let minX = bob.x; + + for (let frame = 0; frame < 240; frame++) { + world.step(FRAME); + // The pinned point holds: the bob's centre stays at a fixed radius from the pivot. + expect(Math.abs(Math.hypot(bob.x, bob.y) - radius)).toBeLessThan(1.5); + minX = Math.min(minX, bob.x); + } + + expect(minX).toBeLessThan(-30); // it swung through the bottom to the far side + }); + + it('SG-J7: a two-link revolute chain keeps its shared hinge coincident', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const link1 = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, colliders: [{ shape: new BoxShape(100, 8) }] })); + const link2 = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 150, y: 0 }, colliders: [{ shape: new BoxShape(100, 8) }] })); + + world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: link1, anchor: { x: 0, y: 0 } })); // link1 left ↔ origin + world.addJoint(new RevoluteJoint({ bodyA: link1, bodyB: link2, anchor: { x: 100, y: 0 } })); // link2 left ↔ link1 right + + advance(world, 4); + + // link1's right end and link2's left end (the shared hinge) stay coincident. + const r1x = link1.x + Math.cos(link1.angle) * 50; + const r1y = link1.y + Math.sin(link1.angle) * 50; + const l2x = link2.x - Math.cos(link2.angle) * 50; + const l2y = link2.y - Math.sin(link2.angle) * 50; + + expect(Math.hypot(r1x - l2x, r1y - l2y)).toBeLessThan(2); + expect(Number.isFinite(link2.y)).toBe(true); + }); + + it('SG-J8: a weld joint holds a body rigidly to a static anchor (position + angle)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 30 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + world.addJoint(new WeldJoint({ bodyA: anchor, bodyB: box })); + + advance(world, 3); + + // Welded to an immovable anchor → it holds both position and angle against gravity. + expect(box.x).toBeCloseTo(50, 0); + expect(box.y).toBeCloseTo(30, 0); + expect(Math.abs(box.angle)).toBeLessThan(0.02); + }); + + it('SG-J9: two welded dynamic bodies keep their relative pose while swinging', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const a = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 60, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + const b = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 100, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: a, anchor: { x: 0, y: 0 } })); // a swings about the origin + world.addJoint(new WeldJoint({ bodyA: a, bodyB: b })); // b welded rigidly to a + + const distance0 = Math.hypot(b.x - a.x, b.y - a.y); + const relativeAngle0 = b.angle - a.angle; + + advance(world, 3); + + // The weld keeps b rigid to a: same separation and same relative orientation. + expect(Math.abs(Math.hypot(b.x - a.x, b.y - a.y) - distance0)).toBeLessThan(2); + expect(Math.abs(b.angle - a.angle - relativeAngle0)).toBeLessThan(0.05); + }); + + it('SG-J10: a distance joint with maxLength acts as a rope — slack allowed, clamped at max', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // Bob starts above the rope's full length → slack → falls freely until taut. + const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 50 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + + world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, maxLength: 100 })); + + // While within maxLength the rope is slack → the bob falls freely (not held at 50). + advance(world, 0.05); + expect(Math.hypot(bob.x, bob.y)).toBeGreaterThan(50); + + advance(world, 2); + const distance = Math.hypot(bob.x, bob.y); + expect(distance).toBeLessThan(101); // never stretches past the rope length + expect(distance).toBeGreaterThan(95); // hangs taut at ~max + }); + + it('SG-J11: a motorized revolute joint reaches and holds its target speed', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(40, 40) }] })); + + world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: wheel, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 })); + + advance(world, 1); + + expect(wheel.angularVelocity).toBeCloseTo(5, 0); // driven to the target rad/s and held + }); + + it('SG-J12: a revolute joint angle limit caps the swing', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // A bar pinned at its left end; gravity swings it until the limit blocks it. + const bar = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] })); + const limit = Math.PI / 4; + + world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: bar, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -limit, upperAngle: limit })); + + advance(world, 3); + + expect(Math.abs(bar.angle)).toBeLessThan(limit + 0.05); // never past the limit + expect(Math.abs(bar.angle)).toBeGreaterThan(limit - 0.15); // swung to and rests at the limit + }); + + it('SG-J13: a prismatic joint constrains a body to its axis (no perpendicular drift, no rotation)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // Horizontal slide axis (1,0); gravity is perpendicular → must not move the body. + const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + world.addJoint(new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: 1, y: 0 } })); + + advance(world, 2); + + expect(Math.abs(slider.y)).toBeLessThan(1); // perpendicular locked — gravity can't pull it off the axis + expect(Math.abs(slider.angle)).toBeLessThan(0.02); // rotation locked + }); + + it('SG-J14: a prismatic limit caps travel along the axis', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + // Vertical slide axis (0,1) = gravity → the body slides down but the limit caps it at 100. + const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + world.addJoint( + new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: 0, y: 1 }, enableLimit: true, lowerTranslation: 0, upperTranslation: 100 }), + ); + + advance(world, 3); + + expect(slider.y).toBeGreaterThan(95); // slid down the axis to the limit + expect(slider.y).toBeLessThan(101); // capped at upperTranslation + expect(Math.abs(slider.x)).toBeLessThan(1); // perpendicular locked + }); + + it('SG-J15: a prismatic motor drives the body along its axis', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + world.addJoint( + new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: 1, y: 0 }, enableMotor: true, motorSpeed: 100, maxMotorForce: 1e8 }), + ); + + advance(world, 0.5); + + expect(slider.x).toBeGreaterThan(20); // motor drove it along +x + expect(Math.abs(slider.y)).toBeLessThan(1); // stayed on the axis + }); + + it('SG-J16: a wheel joint locks lateral motion but lets the wheel spin', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const chassis = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); + const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); + + // Suspension axis vertical (0,1): the wheel may travel along it (sprung) + spin, but not slide sideways. + world.addJoint(new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, hertz: 5, dampingRatio: 1 })); + + wheel.angularVelocity = 10; // give it spin + wheel.applyImpulse(5000, 0); // shove it sideways (perpendicular to the axis) + + advance(world, 1); + + expect(Math.abs(wheel.x)).toBeLessThan(2); // lateral locked — did not slide sideways + expect(Math.abs(wheel.angularVelocity)).toBeGreaterThan(5); // rotation is free — still spinning + }); + + it('SG-J17: a mouse joint drags a body to its target and tracks it when moved', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + + // Grab the body at its centre and pull it toward (50, 0). + const joint = world.addJoint(new MouseJoint({ body, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 1 })); + joint.target = { x: 50, y: 0 }; + + advance(world, 1); + expect(body.x).toBeGreaterThan(40); // converged near the target + + // Move the target — the body follows. + joint.target = { x: 50, y: 60 }; + advance(world, 1); + expect(body.y).toBeGreaterThan(45); + }); + + it('SG-J18: maxForce caps how hard a mouse joint can pull', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const heavy = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), density: 100 }] })); + + // A tiny force against a far target: it creeps but cannot snap across. + const joint = world.addJoint(new MouseJoint({ body: heavy, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 1, maxForce: 50 })); + joint.target = { x: 1000, y: 0 }; + + advance(world, 1); + + expect(heavy.x).toBeGreaterThan(0); // it did move toward the target + expect(heavy.x).toBeLessThan(200); // but maxForce kept it from reaching the far target + }); +}); diff --git a/packages/exojs-physics/test/perf.test.ts b/packages/exojs-physics/test/perf.test.ts index 8eb7ef5e..fb505a0a 100644 --- a/packages/exojs-physics/test/perf.test.ts +++ b/packages/exojs-physics/test/perf.test.ts @@ -25,8 +25,8 @@ import { measureAllocationRate } from './allocationSampler'; const FRAME = 1 / 60; /** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ -const buildField = (columns: number, rows: number): { world: PhysicsWorld; bodies: PhysicsBody[] } => { - const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); const size = 16; const spacing = 20; const floorTop = 1000; @@ -129,4 +129,38 @@ describe('physics dynamics performance (P-1 / P-2)', () => { // boxing variance. expect(bytesPerStep).toBeLessThan(600 * 1024); }); + + it('5,000-mostly-sleeping field: sleeping sharply cuts step time (P-3)', () => { + // Baseline: the identical field with sleeping disabled stays fully active. + const awake = buildField(1000, 5, { enableSleeping: false }); + + for (let i = 0; i < 240; i++) { + awake.world.step(FRAME); + } + + const awakeMs = stepTimes(awake.world, 120); + + // Sleeping on (default): let the field settle and nap. + const sleeping = buildField(1000, 5, { enableSleeping: true }); + + for (let i = 0; i < 360; i++) { + sleeping.world.step(FRAME); + } + + const sleptCount = sleeping.bodies.filter(body => body.isSleeping).length; + const sleepingMs = stepTimes(sleeping.world, 120); + + expect(sleeping.bodies.length).toBe(5000); + console.log( + `[P-3] awake ${awakeMs.toFixed(3)} ms/step vs sleeping ${sleepingMs.toFixed(3)} ms/step · ${sleptCount}/5000 asleep (${(awakeMs / sleepingMs).toFixed(1)}× faster)`, + ); + + // The vast majority of a settled field naps, and skipping their integration + // and constraint solve sharply cuts the per-step cost (measured ~3.4× faster + // on the reference machine — the remainder is detection, which still runs). + // The gate is a relative ratio (same machine, sleeping vs awake), so it is + // machine-independent; ≥2× leaves headroom for variance. + expect(sleptCount).toBeGreaterThan(4500); + expect(sleepingMs).toBeLessThan(awakeMs * 0.5); + }, 60_000); }); diff --git a/packages/exojs-physics/test/sleeping.test.ts b/packages/exojs-physics/test/sleeping.test.ts new file mode 100644 index 00000000..0c23da45 --- /dev/null +++ b/packages/exojs-physics/test/sleeping.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; + +import { BoxShape, PhysicsWorld } from '../src/index'; +import { PhysicsBody } from '../src/PhysicsBody'; + +/** + * Sleeping & islands (spec `01-sleeping-islands.md`, gate P-3). Bodies that come + * to rest stop integrating/solving; connected bodies sleep and wake as a unit + * via an island graph. SG-SL prefix, default solver config, +Y down. + */ + +const GRAVITY = 1000; // px/s² +const FRAME = 1 / 60; + +const advance = (world: PhysicsWorld, seconds: number): void => { + const frames = Math.round(seconds / FRAME); + + for (let frame = 0; frame < frames; frame++) { + world.step(FRAME); + } +}; + +/** A wide static floor whose top surface sits at `topY`. */ +const addFloor = (world: PhysicsWorld, topY: number): PhysicsBody => + world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: topY + 20 }, colliders: [{ shape: new BoxShape(1200, 40) }] })); + +/** A 32×32 dynamic box centred at `(x, y)`. */ +const addBox = (world: PhysicsWorld, x: number, y: number): PhysicsBody => + world.add(new PhysicsBody({ type: 'dynamic', position: { x, y }, colliders: [{ shape: new BoxShape(32, 32), friction: 0.5 }] })); + +/** A vertical stack of `count` boxes resting bottom-up from `floorTopY`. */ +const addStack = (world: PhysicsWorld, count: number, floorTopY: number): PhysicsBody[] => { + const boxes: PhysicsBody[] = []; + + for (let i = 0; i < count; i++) { + boxes.push(addBox(world, 0, floorTopY - 16 - i * 32)); + } + + return boxes; +}; + +describe('SG-SL — sleeping', () => { + it('SG-SL1: a box that comes to rest falls asleep after timeToSleep', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const box = addBox(world, 0, 300 - 16 - 2); // 2px above its resting height + + // Still settling within the first 0.1s → must be awake. + advance(world, 0.1); + expect(box.isSleeping).toBe(false); + + // After resting longer than the default timeToSleep (0.5s) → asleep. + advance(world, 2); + expect(box.isSleeping).toBe(true); + expect(box.linearVelocityX).toBe(0); // sleeping zeroes velocity + expect(box.linearVelocityY).toBe(0); + expect(box.angularVelocity).toBe(0); + }); + + it('SG-SL2: a body dropped onto a sleeping body wakes it and is supported (no tunnelling)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const bottom = addBox(world, 0, 300 - 16 - 2); + + advance(world, 2); + expect(bottom.isSleeping).toBe(true); + + // Drop a second box from above onto the sleeping one. + const top = addBox(world, 0, 300 - 16 - 64); + advance(world, 2); + + // The top box rests ON the bottom box — if wake-on-contact failed, the + // solver would skip the contact and the top box would tunnel through. + expect(top.y).toBeLessThan(bottom.y - 24); // a box-height above + expect(bottom.y).toBeGreaterThan(300 - 16 - 5); // bottom still on the floor + expect(bottom.y).toBeLessThan(300 - 16 + 5); + }); + + it('SG-SL3: an impulse wakes a sleeping body immediately', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const box = addBox(world, 0, 300 - 16 - 2); + + advance(world, 2); + expect(box.isSleeping).toBe(true); + + box.applyImpulse(30000, 0); // horizontal kick + expect(box.isSleeping).toBe(false); // woken on the spot + expect(box.linearVelocityX).toBeGreaterThan(0); + }); + + it('SG-SL4: a settling stack falls asleep', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const boxes = addStack(world, 4, 300); + + advance(world, 3); + + for (const box of boxes) { + expect(box.isSleeping).toBe(true); + } + }); + + it('SG-SL5: allowSleep=false on one stack member keeps the whole island awake', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const boxes = addStack(world, 3, 300); + boxes[1]!.allowSleep = false; // the middle box never sleeps + + advance(world, 3); + + // The island sleeps as a unit, so one non-sleeping member keeps all awake. + for (const box of boxes) { + expect(box.isSleeping).toBe(false); + } + }); + + it('SG-SL6: sleep transitions are deterministic across identical runs', () => { + const run = (): string => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + addFloor(world, 300); + const boxes = addStack(world, 4, 300); + const trace: string[] = []; + + for (let frame = 0; frame < 240; frame++) { + world.step(FRAME); + trace.push(boxes.map(box => (box.isSleeping ? '1' : '0')).join('')); + } + + return trace.join('|'); + }; + + expect(run()).toBe(run()); + }); +}); diff --git a/packages/exojs-react/LICENSE b/packages/exojs-react/LICENSE new file mode 100644 index 00000000..dfb7cd04 --- /dev/null +++ b/packages/exojs-react/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-react/README.md b/packages/exojs-react/README.md new file mode 100644 index 00000000..26fc75c7 --- /dev/null +++ b/packages/exojs-react/README.md @@ -0,0 +1,99 @@ +# @codexo/exojs-react + +React 18 / 19 bindings for [ExoJS](https://exojs.dev) — mount an ExoJS +`Application` into your React tree, drive scenes declaratively, and overlay React +HUD on the canvas. + +## Installation + +```sh +npm install @codexo/exojs @codexo/exojs-react react +``` + +`@codexo/exojs` and `react` (>= 18) are peer dependencies; `react-dom` is an +optional peer. The package ships pre-built ESM (`dist/esm`) with type +declarations and works on both `@types/react` 18 and 19. + +## Two layers, pick what you need + +This package is intentionally layered: + +- **`useExoApplication` — headless.** Creates and owns the `Application`, binds + it to a `` you render yourself. No DOM, no wrapper, no styling + opinions — full control. +- **`` — batteries-included.** Renders a positioned wrapper `
` + + a React-managed `` and provides the app via context, so HUD overlays + work out of the box. + +## Quick start — `` + +```tsx +import { ExoCanvas, Scenes, Scene, useExoApp } from '@codexo/exojs-react'; +import { TitleScene, GameScene } from './scenes'; + +function Game() { + return ( + + + + + {/* absolutely-positioned React overlay, over the canvas */} + + + + ); +} + +function Hud() { + const app = useExoApp(); + return
FPS overlay…
; +} +``` + +Layout props (`style`, `className`, …) apply to the **wrapper**; size it to +drive `'fill'`/`'letterbox'` sizing. Style the canvas itself via `canvasProps`. + +## Quick start — headless hook (full control) + +```tsx +import { useExoApplication } from '@codexo/exojs-react'; + +function Game() { + const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } }); + // Render the canvas however and wherever you want. + return ; +} +``` + +## API + +| Export | Kind | Purpose | +|---|---|---| +| `ExoCanvas` | component | Batteries-included canvas host (wrapper div + canvas + context). | +| `useExoApplication(options?, onReady?)` | hook | Headless: owns the `Application`, returns `{ app, canvasRef }`. | +| `useExoApp()` | hook | The `Application` from the nearest ``/provider. Throws if absent. | +| `useExoContext()` | hook | Like `useExoApp` but returns `Application \| null` (no throw). | +| `ExoContext` | context | The underlying context (advanced / testing). | +| `useScene(SceneClass, deps?)` | hook | Instantiate + activate a single scene; returns it once live. | +| `Scenes` / `Scene` | components | Declarative scene switch over the one-active-scene model. | +| `useActiveScene()` | hook | The active scene instance from the nearest ``. | + +### Reactivity model + +The `Application` is recreated only when an **identity** option changes — the +render `backend` (WebGL2 ↔ WebGPU cannot be hot-swapped). Other supported options +are applied **live**: + +- `canvas.width` / `canvas.height` → `app.resize(...)` +- `canvas.sizingMode` → `app.sizingMode` +- `clearColor` → `app.clearColor` + +Options without a live setter (`canvas.pixelRatio`, `seed`, `extensions`, …) are +captured at creation; change the `backend` or remount to apply them. + +## License + +MIT © Codexo diff --git a/packages/exojs-react/package.json b/packages/exojs-react/package.json new file mode 100644 index 00000000..dd389ada --- /dev/null +++ b/packages/exojs-react/package.json @@ -0,0 +1,49 @@ +{ + "name": "@codexo/exojs-react", + "version": "0.14.0", + "description": "React integration for ExoJS.", + "repository": { + "type": "git", + "url": "git+https://github.com/Exoridus/ExoJS.git", + "directory": "packages/exojs-react" + }, + "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 ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:production", + "build:dev": "tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:development", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,tsx}\"" + }, + "peerDependencies": { + "@codexo/exojs": "0.14.x", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "devDependencies": { + "@codexo/exojs": "workspace:*", + "@codexo/exojs-config": "workspace:*", + "@types/react": "^18.0.0" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/exojs-react/rollup.config.ts b/packages/exojs-react/rollup.config.ts new file mode 100644 index 00000000..6f155ede --- /dev/null +++ b/packages/exojs-react/rollup.config.ts @@ -0,0 +1,10 @@ +import { createExtensionConfig } from '@codexo/exojs-config/rollup'; + +export default createExtensionConfig({ + root: import.meta.dirname, + sourceCondition: null, + // React integration has a single entry (no `register` side-effect module) and + // peers React out of the bundle. + inputs: ['src/index.ts'], + external: ['react', 'react-dom'], +}); diff --git a/packages/exojs-react/src/ExoCanvas.tsx b/packages/exojs-react/src/ExoCanvas.tsx new file mode 100644 index 00000000..86827874 --- /dev/null +++ b/packages/exojs-react/src/ExoCanvas.tsx @@ -0,0 +1,65 @@ +import type { Application } from '@codexo/exojs'; +import { type CanvasHTMLAttributes, type CSSProperties, type HTMLAttributes, type ReactElement } from 'react'; + +import { ExoContext } from './ExoContext'; +import { type ExoApplicationOptions, useExoApplication } from './useExoApplication'; + +/** Default canvas style: block layout avoids the inline-element baseline gap. */ +const defaultCanvasStyle: CSSProperties = { display: 'block' }; + +export interface ExoCanvasProps extends HTMLAttributes { + /** + * Options forwarded to the ExoJS {@link Application}. Pass + * `canvas.width`/`height`/`sizingMode`/etc.; most options are captured at + * creation, but `canvas.width`/`height`, `canvas.sizingMode` and `clearColor` + * are applied live (see {@link useExoApplication}). + */ + options?: ExoApplicationOptions; + /** + * Called once each time the {@link Application} is (re)created. The backend + * (WebGL2 / WebGPU) is not yet initialized at this point — that happens when + * the first {@link import('./useScene').useScene} child calls `app.start()`. + */ + onReady?: (app: Application) => void; + /** + * Props forwarded to the inner `` (e.g. its own `style`/`className`). + * `ref`, `width` and `height` are managed by the engine and cannot be set. + */ + canvasProps?: Omit, 'ref' | 'width' | 'height'>; +} + +/** + * Batteries-included canvas host. Renders a **positioned wrapper `
`** + * containing a React-managed `` bound to an ExoJS {@link Application}, + * and provides the app to descendant hooks via context. Because the wrapper is + * `position: relative`, absolutely-positioned `children` (HUD overlays, + * {@link import('./Scenes').Scenes}) sit over the canvas out of the box. + * + * Layout props (`style`, `className`, …) apply to the **wrapper**; size it to + * size the canvas in `'fill'`/`'letterbox'` modes. Use {@link canvasProps} to + * style the canvas itself. For full control with no wrapper element, use the + * headless {@link useExoApplication} hook directly. + * + * @example + * ```tsx + * + * // absolutely-positioned overlay; works because the wrapper is relative + * + * ``` + */ +export function ExoCanvas({ options, onReady, canvasProps, children, style, ...divProps }: ExoCanvasProps): ReactElement { + const { app, canvasRef } = useExoApplication(options, onReady); + + const { style: canvasStyle, ...restCanvasProps } = canvasProps ?? {}; + const wrapperStyle: CSSProperties = { position: 'relative', ...style }; + const mergedCanvasStyle: CSSProperties = { ...defaultCanvasStyle, ...canvasStyle }; + + return ( + +
+ + {app !== null && children} +
+
+ ); +} diff --git a/packages/exojs-react/src/ExoContext.ts b/packages/exojs-react/src/ExoContext.ts new file mode 100644 index 00000000..e2df9680 --- /dev/null +++ b/packages/exojs-react/src/ExoContext.ts @@ -0,0 +1,22 @@ +import type { Application } from '@codexo/exojs'; +import { createContext, useContext } from 'react'; + +/** + * Internal React context that carries the active {@link Application} instance + * created by {@link ExoCanvas}. Consumers should use the {@link useExoApp} + * hook rather than reading this context directly; the context object is + * exported for advanced use (e.g. testing, custom providers). + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ExoContext = createContext(null); +ExoContext.displayName = 'ExoContext'; + +/** + * Returns the nearest {@link Application} from the React tree, or `null` + * when called outside of an {@link ExoCanvas}. Prefer {@link useExoApp} for + * component-level use — it throws an actionable error instead of returning + * null. + */ +export function useExoContext(): Application | null { + return useContext(ExoContext); +} diff --git a/packages/exojs-react/src/Scenes.tsx b/packages/exojs-react/src/Scenes.tsx new file mode 100644 index 00000000..43195e38 --- /dev/null +++ b/packages/exojs-react/src/Scenes.tsx @@ -0,0 +1,136 @@ +import { ApplicationStatus, type Scene as ExoScene, type SceneTransition } from '@codexo/exojs'; +import { + Children, + createContext, + isValidElement, + type ReactElement, + type ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { useExoApp } from './useExoApp'; + +/** Carries the active {@link ExoScene} instance to descendants (HUD overlays). */ +const ActiveSceneContext = createContext(null); +ActiveSceneContext.displayName = 'ExoActiveScene'; + +/** + * Returns the currently-active scene instance from the nearest {@link Scenes}, + * or `null` while none is live. Useful for HUD/overlay components that need to + * read scene state. + */ +export function useActiveScene(): T | null { + return useContext(ActiveSceneContext) as T | null; +} + +/** Props for a {@link Scene} declaration. */ +export interface SceneProps { + /** Unique name used to select this scene via {@link ScenesProps.active}. */ + readonly name: string; + /** Scene class to instantiate when this scene becomes active. */ + readonly component: new () => ExoScene; + /** React overlay (HUD) rendered only while this scene is active. */ + readonly children?: ReactNode; +} + +/** + * Declares one scene inside a {@link Scenes} switch. Renders nothing on its own — + * {@link Scenes} reads its props and renders its {@link SceneProps.children} only + * while the scene is active. + */ +export function Scene(_props: SceneProps): ReactElement | null { + return null; +} + +/** Props for the {@link Scenes} switch. */ +export interface ScenesProps { + /** Name of the active {@link Scene}. Changing it switches scenes. */ + readonly active: string; + /** Optional transition (e.g. a fade) applied when switching scenes. */ + readonly transition?: SceneTransition; + /** {@link Scene} declarations. */ + readonly children?: ReactNode; +} + +/** + * Declarative scene switch over the one-active-scene model. Renders a set of + * {@link Scene} declarations and activates the one whose `name` equals `active` + * via `app.start()` (first activation) or `app.scene.setScene()` (subsequent + * switches, with the optional `transition`). The active scene's React children + * (HUD overlay) render alongside, and can read the instance via + * {@link useActiveScene}. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * + * ``` + */ +export function Scenes({ active, transition, children }: ScenesProps): ReactElement { + const app = useExoApp(); + const [instance, setInstance] = useState(null); + + // Collect the declarations from children (keyed by name). + const registry = useMemo(() => { + const map = new Map(); + Children.forEach(children, child => { + if (isValidElement(child) && child.type === Scene) { + const props = child.props as SceneProps; + map.set(props.name, props); + } + }); + return map; + }, [children]); + + const entry = registry.get(active); + const SceneClass = entry?.component ?? null; + + useEffect(() => { + if (SceneClass === null) { + setInstance(null); + void app.scene.setScene(null); + return; + } + + let cancelled = false; + const scene = new SceneClass(); + + const apply = async (): Promise => { + if (app.status === ApplicationStatus.Stopped) { + // First activation initializes the backend and starts the frame loop; + // transitions only apply to subsequent switches. + await app.start(scene); + } else { + await app.scene.setScene(scene, transition !== undefined ? { transition } : {}); + } + if (!cancelled) { + setInstance(scene); + } + }; + + void apply(); + + return () => { + cancelled = true; + setInstance(null); + }; + // Re-activate when the active name changes. SceneClass/transition derive + // from `active`; keying on app + active avoids re-instantiating each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app, active]); + + return ( + + {instance !== null && entry?.children} + + ); +} diff --git a/packages/exojs-react/src/index.ts b/packages/exojs-react/src/index.ts new file mode 100644 index 00000000..eb851c0f --- /dev/null +++ b/packages/exojs-react/src/index.ts @@ -0,0 +1,9 @@ +export type { ExoCanvasProps } from './ExoCanvas'; +export { ExoCanvas } from './ExoCanvas'; +export { ExoContext, useExoContext } from './ExoContext'; +export type { SceneProps, ScenesProps } from './Scenes'; +export { Scene, Scenes, useActiveScene } from './Scenes'; +export { useExoApp } from './useExoApp'; +export type { ExoApplicationOptions, UseExoApplicationResult } from './useExoApplication'; +export { useExoApplication } from './useExoApplication'; +export { useScene } from './useScene'; diff --git a/packages/exojs-react/src/useExoApp.ts b/packages/exojs-react/src/useExoApp.ts new file mode 100644 index 00000000..37fcad5c --- /dev/null +++ b/packages/exojs-react/src/useExoApp.ts @@ -0,0 +1,28 @@ +import type { Application } from '@codexo/exojs'; + +import { useExoContext } from './ExoContext'; + +/** + * Returns the {@link Application} instance from the nearest {@link ExoCanvas} + * ancestor. Throws an informative error when called outside of an + * `` tree. + * + * @throws {Error} When no `` ancestor is present. + * + * @example + * ```tsx + * function HudOverlay() { + * const app = useExoApp(); + * return Frame: {app.frameCount}; + * } + * ``` + */ +export function useExoApp(): Application { + const app = useExoContext(); + + if (app === null) { + throw new Error('useExoApp must be used inside an component.'); + } + + return app; +} diff --git a/packages/exojs-react/src/useExoApplication.ts b/packages/exojs-react/src/useExoApplication.ts new file mode 100644 index 00000000..7e1907c1 --- /dev/null +++ b/packages/exojs-react/src/useExoApplication.ts @@ -0,0 +1,138 @@ +import { Application, type ApplicationOptions, type CanvasApplicationOptions, type Color } from '@codexo/exojs'; +import { type Ref, useEffect, useRef, useState } from 'react'; + +/** + * Options for {@link useExoApplication} / {@link import('./ExoCanvas').ExoCanvas}. + * + * Same as {@link ApplicationOptions} but the `canvas.element` and `canvas.mount` + * fields are managed for you (the Application binds to the canvas the hook + * references), so they are omitted. You may still pass + * `canvas.width`/`height`/`sizingMode`/etc. + */ +export type ExoApplicationOptions = Omit & { + readonly canvas?: Omit; +}; + +/** Return value of {@link useExoApplication}. */ +export interface UseExoApplicationResult { + /** The Application instance, or `null` until it has been created. */ + readonly app: Application | null; + /** + * Attach this to the `` element the Application should bind to. Typed + * as `Ref` (not `RefObject`) so the same code type-checks against both + * `@types/react` 18 and 19, whose `useRef`/`RefObject` nullability differ. + */ + readonly canvasRef: Ref; +} + +/** Stable string key for the colour so the sync effect can depend on its value. */ +function colorKey(color: Color | undefined): string | undefined { + return color === undefined ? undefined : `${color.r},${color.g},${color.b},${color.a}`; +} + +/** + * Creates and owns an ExoJS {@link Application}, binding it to a `` you + * render yourself and attach the returned `canvasRef` to. The hook renders no + * DOM of its own — you keep full control over the canvas element, its container, + * and its styling. + * + * ```tsx + * function Game() { + * const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } }); + * return ; + * } + * ``` + * + * **Reactivity model.** The Application is recreated only when an *identity* + * option changes — currently the render `backend` (you cannot hot-swap WebGL2 ↔ + * WebGPU). All other supported options are applied *live* without tearing the + * app down: + * + * - `canvas.width` / `canvas.height` → `app.resize(...)` + * - `canvas.sizingMode` → `app.sizingMode` + * - `clearColor` → `app.clearColor` + * + * Options without a live setter (e.g. `canvas.pixelRatio`, `seed`, `extensions`) + * are captured at creation; change the `backend` or remount to apply them. + * + * Styling note: with the default `'fixed'` sizing mode the engine never touches + * the canvas CSS, so you may style it freely. The `'fit'`/`'shrink'`/`'letterbox'` + * modes manage `canvas.style` themselves — don't fight them with a `style` prop. + * + * @param options - Application options (the canvas element is the one you render). + * @param onReady - Called once each time an Application is created. + */ +export function useExoApplication( + options?: ExoApplicationOptions, + onReady?: (app: Application) => void, +): UseExoApplicationResult { + const canvasRef = useRef(null); + const [app, setApp] = useState(null); + + // Latest onReady without retriggering the lifecycle effect. Updated in an + // effect (not during render) so the ref-write happens after commit. + const onReadyRef = useRef(onReady); + useEffect(() => { + onReadyRef.current = onReady; + }); + + // Identity: only the backend type forces a full recreation. + const backendKey = options?.backend?.type ?? 'auto'; + + // ── Lifecycle: create on mount / recreate on backend change ─────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + // Bind to the React-rendered canvas. The engine never removes a canvas it + // did not create (Application.destroy leaves it in the DOM), so React stays + // the sole owner of the element's lifecycle. + const application = new Application({ + ...options, + canvas: { ...options?.canvas, element: canvas }, + }); + + setApp(application); + onReadyRef.current?.(application); + + return () => { + application.destroy(); + setApp(null); + }; + // Recreate only when the backend identity changes; live options are synced + // by the effects below. `options` is intentionally read at (re)create time. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [backendKey]); + + // ── Live sync: size ─────────────────────────────────────────────────────── + const width = options?.canvas?.width; + const height = options?.canvas?.height; + useEffect(() => { + if (app !== null && width !== undefined && height !== undefined) { + app.resize(width, height); + } + }, [app, width, height]); + + // ── Live sync: sizing mode ──────────────────────────────────────────────── + const sizingMode = options?.canvas?.sizingMode; + useEffect(() => { + if (app !== null && sizingMode !== undefined) { + app.sizingMode = sizingMode; + } + }, [app, sizingMode]); + + // ── Live sync: clear colour ─────────────────────────────────────────────── + const clearColor = options?.clearColor; + const clearKey = colorKey(clearColor); + useEffect(() => { + if (app !== null && clearColor !== undefined) { + app.clearColor = clearColor; + } + // clearColor identity is unstable; depend on its value key instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app, clearKey]); + + return { app, canvasRef }; +} diff --git a/packages/exojs-react/src/useScene.ts b/packages/exojs-react/src/useScene.ts new file mode 100644 index 00000000..9d96f353 --- /dev/null +++ b/packages/exojs-react/src/useScene.ts @@ -0,0 +1,70 @@ +import { ApplicationStatus, type Scene } from '@codexo/exojs'; +import { type DependencyList, useEffect, useState } from 'react'; + +import { useExoApp } from './useExoApp'; + +/** + * Creates an instance of `SceneClass`, activates it on the ExoJS + * {@link Application}, and returns it once the scene is live. + * + * On first call (engine not yet started) this hook calls `app.start(scene)`, + * which initializes the render backend and begins the per-frame loop. On + * subsequent dep-change remounts it calls `app.scene.setScene(scene)` to + * switch scenes without restarting the engine. + * + * The scene is cleared (`setScene(null)`) when the component unmounts or + * when `deps` change — mirroring `useEffect` semantics. + * + * @param SceneClass - Constructor for the scene to instantiate. + * @param deps - Extra deps that trigger scene replacement when changed, in + * addition to the stable `app` reference (same semantics as `useEffect`). + * @returns The active scene instance, or `null` while it is loading. + * + * @example + * ```tsx + * function GameScreen() { + * const scene = useScene(MyGameScene); + * if (!scene) return null; + * return ; + * } + * ``` + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export function useScene(SceneClass: new () => T, deps: DependencyList = []): T | null { + const app = useExoApp(); + const [scene, setScene] = useState(null); + + useEffect(() => { + let cancelled = false; + const s = new SceneClass(); + + const apply = async (): Promise => { + if (app.status === ApplicationStatus.Stopped) { + // First activation — initialize the backend and start the frame loop. + await app.start(s); + } else { + // Engine already running — switch scenes without restarting. + await app.scene.setScene(s); + } + + if (!cancelled) { + setScene(s); + } + }; + + void apply(); + + return () => { + cancelled = true; + setScene(null); + // Best-effort scene clear; the Application.destroy() called by + // ExoCanvas cleanup will also handle any remaining active scene. + void app.scene.setScene(null); + }; + // SceneClass is intentionally excluded from deps: a new class reference + // (e.g. inline arrow class) on every render would recreate the scene + // each frame. Pass an explicit deps array to react to changes. + }, [app, ...deps]); + + return scene; +} diff --git a/packages/exojs-react/test/ExoCanvas.test.tsx b/packages/exojs-react/test/ExoCanvas.test.tsx new file mode 100644 index 00000000..ce6c3399 --- /dev/null +++ b/packages/exojs-react/test/ExoCanvas.test.tsx @@ -0,0 +1,88 @@ +import { render } from '@testing-library/react'; +import { type ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ExoCanvas } from '../src/ExoCanvas'; +import { useExoApp } from '../src/useExoApp'; +import { MockApplication } from './support/mock-application'; + +// The mock module is imported INSIDE the factory because `vi.mock` is hoisted +// above this file's imports (top-level bindings are not initialised yet). +vi.mock('@codexo/exojs', async importActual => { + const actual = await importActual(); + const { MockApplication: MockApp, configureApplicationStatus } = await import('./support/mock-application'); + configureApplicationStatus(actual.ApplicationStatus); + return { ...actual, Application: MockApp }; +}); + +/** Overlay child that proves the surrounding ExoCanvas context carries the app. */ +function Hud(): ReactElement { + const app = useExoApp(); + + return
{app === MockApplication.instances[0] ? 'has-app' : 'wrong-app'}
; +} + +beforeEach(() => { + MockApplication.reset(); +}); + +describe('', () => { + it('renders a positioned wrapper div containing exactly one canvas', () => { + const { container, getByTestId } = render( + , + ); + + const host = getByTestId('host'); + expect(host.tagName).toBe('DIV'); + expect(host.style.position).toBe('relative'); + + const canvases = container.querySelectorAll('canvas'); + expect(canvases).toHaveLength(1); + expect(host.contains(canvases[0]!)).toBe(true); + }); + + it('forwards arbitrary div props (className, data-*) and merges style onto the wrapper', () => { + const { getByTestId } = render( + , + ); + + const host = getByTestId('host'); + expect(host.className).toBe('game-host'); + expect(host.getAttribute('data-testid')).toBe('host'); + expect(host.style.width).toBe('640px'); + expect(host.style.background).toBe('black'); + // Caller style must not clobber the relative positioning the overlay relies on. + expect(host.style.position).toBe('relative'); + }); + + it('forwards canvasProps to the inner canvas and keeps the default block display', () => { + const { container } = render( + , + ); + + const canvas = container.querySelector('canvas')!; + expect(canvas.className).toBe('pixelated'); + expect(canvas.getAttribute('data-role')).toBe('surface'); + expect(canvas.style.display).toBe('block'); + expect(canvas.style.imageRendering).toBe('pixelated'); + }); + + it('binds the Application to the actual rendered canvas element', () => { + const { container } = render(); + + expect(MockApplication.instances).toHaveLength(1); + const canvas = container.querySelector('canvas'); + expect(MockApplication.instances[0]!.options.canvas?.element).toBe(canvas); + }); + + it('renders children as an overlay and provides the app to them via context', () => { + const { getByTestId } = render( + + + , + ); + + const hud = getByTestId('hud'); + expect(hud.textContent).toBe('has-app'); + }); +}); diff --git a/packages/exojs-react/test/Scenes.test.tsx b/packages/exojs-react/test/Scenes.test.tsx new file mode 100644 index 00000000..e6935bae --- /dev/null +++ b/packages/exojs-react/test/Scenes.test.tsx @@ -0,0 +1,102 @@ +import { Application, Scene as ExoScene, type SceneTransition } from '@codexo/exojs'; +import { render, waitFor } from '@testing-library/react'; +import { type ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ExoContext } from '../src/ExoContext'; +import { Scene, Scenes, useActiveScene } from '../src/Scenes'; +import { MockApplication } from './support/mock-application'; + +// The mock module is imported INSIDE the factory because `vi.mock` is hoisted +// above this file's imports (top-level bindings are not initialised yet). +vi.mock('@codexo/exojs', async importActual => { + const actual = await importActual(); + const { MockApplication: MockApp, configureApplicationStatus } = await import('./support/mock-application'); + configureApplicationStatus(actual.ApplicationStatus); + return { ...actual, Application: MockApp }; +}); + +class TitleScene extends ExoScene {} +class GameScene extends ExoScene {} + +/** Reads the active scene from the Scenes context and prints its class name. */ +function ActiveProbe(): ReactElement { + const scene = useActiveScene(); + + return {scene?.constructor.name ?? 'none'}; +} + +function Tree({ app, active, transition }: { app: Application; active: string; transition?: SceneTransition }): ReactElement { + return ( + + + + title-hud + + + + game-hud + + + + + ); +} + +const makeApp = (): MockApplication => new Application() as unknown as MockApplication; + +beforeEach(() => { + MockApplication.reset(); +}); + +describe(' / / useActiveScene', () => { + it('activates the first scene via app.start() (engine stopped) and exposes it through useActiveScene', async () => { + const app = makeApp(); + const { findByTestId } = render(); + + // start() is invoked synchronously inside the activation effect. + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start.mock.calls[0]![0]).toBeInstanceOf(TitleScene); + expect(app.scene.setScene).not.toHaveBeenCalled(); + + // The overlay + active-scene context appear once the start() promise resolves. + const active = await findByTestId('active'); + expect(active.textContent).toBe('TitleScene'); + expect((await findByTestId('hud')).textContent).toBe('title-hud'); + }); + + it('renders only the active scene’s children as the overlay', async () => { + const app = makeApp(); + const { findByTestId } = render(); + + expect((await findByTestId('hud')).textContent).toBe('title-hud'); + }); + + it('switches scenes via app.scene.setScene() (engine running) and forwards the transition', async () => { + const app = makeApp(); + const view = render(); + await view.findByTestId('active'); + + const transition: SceneTransition = { type: 'fade', duration: 300 }; + view.rerender(); + + await waitFor(() => expect(app.scene.setScene).toHaveBeenCalled()); + const lastCall = app.scene.setScene.mock.calls.at(-1)!; + expect(lastCall[0]).toBeInstanceOf(GameScene); + expect(lastCall[1]).toEqual({ transition }); + + expect((await view.findByTestId('hud')).textContent).toBe('game-hud'); + expect((await view.findByTestId('active')).textContent).toBe('GameScene'); + }); + + it('clears the active scene (setScene(null)) when the active name matches no ', async () => { + const app = makeApp(); + const view = render(); + await view.findByTestId('active'); + + view.rerender(); + + await waitFor(() => expect(app.scene.setScene).toHaveBeenCalledWith(null)); + expect(view.queryByTestId('hud')).toBeNull(); + }); +}); diff --git a/packages/exojs-react/test/context.test.tsx b/packages/exojs-react/test/context.test.tsx new file mode 100644 index 00000000..2d547223 --- /dev/null +++ b/packages/exojs-react/test/context.test.tsx @@ -0,0 +1,54 @@ +import { type Application } from '@codexo/exojs'; +import { renderHook } from '@testing-library/react'; +import { type ReactElement, type ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ExoContext, useExoContext } from '../src/ExoContext'; +import { useExoApp } from '../src/useExoApp'; + +// A stand-in Application identity; these hooks only pass the value through the +// React context, so no behaviour is exercised on it. +const fakeApp = {} as Application; + +function withProvider(app: Application | null): ({ children }: { children: ReactNode }) => ReactElement { + return function Wrapper({ children }: { children: ReactNode }): ReactElement { + return {children}; + }; +} + +describe('ExoContext / useExoContext', () => { + it('useExoContext returns null when rendered outside any provider', () => { + const { result } = renderHook(() => useExoContext()); + + expect(result.current).toBeNull(); + }); + + it('useExoContext returns the provided app when inside a provider', () => { + const { result } = renderHook(() => useExoContext(), { wrapper: withProvider(fakeApp) }); + + expect(result.current).toBe(fakeApp); + }); +}); + +describe('useExoApp', () => { + it('returns the app from the nearest provider', () => { + const { result } = renderHook(() => useExoApp(), { wrapper: withProvider(fakeApp) }); + + expect(result.current).toBe(fakeApp); + }); + + it('throws an actionable error when used outside an tree', () => { + // React logs the thrown render error; silence it so the suite output stays clean. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + expect(() => renderHook(() => useExoApp())).toThrow('useExoApp must be used inside an component.'); + } finally { + consoleError.mockRestore(); + } + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/packages/exojs-react/test/setup.ts b/packages/exojs-react/test/setup.ts new file mode 100644 index 00000000..bd891643 --- /dev/null +++ b/packages/exojs-react/test/setup.ts @@ -0,0 +1,10 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +// Unmount any React tree rendered with @testing-library/react after each test so +// the jsdom document — and the ExoContext providers / effects mounted into it — +// do not leak between cases. RTL auto-registers this when it can detect a global +// `afterEach`; we wire it explicitly so the suite does not depend on that. +afterEach(() => { + cleanup(); +}); diff --git a/packages/exojs-react/test/support/mock-application.ts b/packages/exojs-react/test/support/mock-application.ts new file mode 100644 index 00000000..096c797a --- /dev/null +++ b/packages/exojs-react/test/support/mock-application.ts @@ -0,0 +1,107 @@ +import { vi } from 'vitest'; + +// Values mirrored from the real `ApplicationStatus` enum. They are injected by +// each test file's `vi.mock('@codexo/exojs', …)` factory via +// `configureApplicationStatus(actual.ApplicationStatus)` so the mock never +// hard-codes the enum's numeric members (and can't drift from the real engine). +const status = { stopped: 4, running: 2 }; + +/** Inject the real enum values so the mock's status matches what the hooks compare against. */ +export function configureApplicationStatus(applicationStatus: { Stopped: number; Running: number }): void { + status.stopped = applicationStatus.Stopped; + status.running = applicationStatus.Running; +} + +interface MockSceneManager { + current: unknown; + setScene: ReturnType; +} + +interface MockCanvasOptions { + element?: unknown; + sizingMode?: string; +} + +interface MockApplicationOptions { + canvas?: MockCanvasOptions; + backend?: { type?: string }; + clearColor?: unknown; + [key: string]: unknown; +} + +/** + * Minimal stand-in for the engine {@link Application}. It owns no GPU backend; + * it only records the calls the React glue makes (construction, resize, + * sizingMode / clearColor assignment, start / setScene, destroy) so the tests + * can assert the bridge behaviour without a real renderer. + */ +export class MockApplication { + /** Every instance constructed within the current test file, in creation order. */ + public static readonly instances: MockApplication[] = []; + + /** Clear the per-file instance registry (call in `beforeEach`). */ + public static reset(): void { + MockApplication.instances.length = 0; + } + + /** The exact options object the hook passed to `new Application(...)`. */ + public readonly options: MockApplicationOptions; + + public status: number = status.stopped; + public destroyed = false; + + private _sizingMode: string; + /** Values assigned to `sizingMode` AFTER construction (live-sync writes). */ + public readonly sizingModeAssignments: string[] = []; + + private _clearColor: unknown = undefined; + /** Values assigned to `clearColor` AFTER construction (live-sync writes). */ + public readonly clearColorAssignments: unknown[] = []; + + public readonly resize = vi.fn(); + + public readonly destroy = vi.fn((): void => { + this.destroyed = true; + }); + + public readonly scene: MockSceneManager = { + current: null, + setScene: vi.fn(async (scene: unknown): Promise => { + this.scene.current = scene; + return this.scene; + }), + }; + + public readonly start = vi.fn(async (scene: unknown): Promise => { + this.status = status.running; + this.scene.current = scene; + return this; + }); + + public constructor(options: MockApplicationOptions = {}) { + this.options = options; + // Mirror the real ctor: the initial sizing mode is written straight to the + // backing field, NOT through the setter, so `sizingModeAssignments` only + // captures the later live-sync writes the hook performs. + this._sizingMode = options.canvas?.sizingMode ?? 'fixed'; + MockApplication.instances.push(this); + } + + public get sizingMode(): string { + return this._sizingMode; + } + + public set sizingMode(mode: string) { + this._sizingMode = mode; + this.sizingModeAssignments.push(mode); + } + + public get clearColor(): unknown { + return this._clearColor; + } + + public set clearColor(color: unknown) { + this._clearColor = color; + this.clearColorAssignments.push(color); + } +} diff --git a/packages/exojs-react/test/useExoApplication.test.tsx b/packages/exojs-react/test/useExoApplication.test.tsx new file mode 100644 index 00000000..6d65f6d7 --- /dev/null +++ b/packages/exojs-react/test/useExoApplication.test.tsx @@ -0,0 +1,164 @@ +import { type Application, Color } from '@codexo/exojs'; +import { render } from '@testing-library/react'; +import { type ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type ExoApplicationOptions, useExoApplication, type UseExoApplicationResult } from '../src/useExoApplication'; +import { MockApplication } from './support/mock-application'; + +// Replace ONLY the GPU-bound `Application`; every pure export (Color, Scene, +// ApplicationStatus, …) stays real via `importActual`. The mock module is +// imported INSIDE the factory because `vi.mock` is hoisted above the file's +// imports, so top-level bindings are not yet initialised when it runs. +vi.mock('@codexo/exojs', async importActual => { + const actual = await importActual(); + const { MockApplication: MockApp, configureApplicationStatus } = await import('./support/mock-application'); + configureApplicationStatus(actual.ApplicationStatus); + return { ...actual, Application: MockApp }; +}); + +interface HarnessProps { + options?: ExoApplicationOptions; + onReady?: (app: Application) => void; + expose: (result: UseExoApplicationResult) => void; +} + +/** + * Drives `useExoApplication` and — crucially — attaches the returned `canvasRef` + * to a real ``, which is what makes the hook's mount effect create the + * Application (it bails out when `canvasRef.current` is null). `expose` hands the + * latest hook result back to the test on every render. + */ +function Harness({ options, onReady, expose }: HarnessProps): ReactElement { + const result = useExoApplication(options, onReady); + expose(result); + + return ; +} + +function mount(initial: Omit): { + result: () => UseExoApplicationResult; + rerender: (next: Omit) => void; + unmount: () => void; + getCanvas: () => HTMLElement; +} { + let latest: UseExoApplicationResult | undefined; + const expose = (r: UseExoApplicationResult): void => { + latest = r; + }; + + const utils = render(); + + return { + result: () => latest!, + rerender: next => utils.rerender(), + unmount: () => utils.unmount(), + getCanvas: () => utils.getByTestId('exo-canvas'), + }; +} + +const onlyInstance = (): MockApplication => { + expect(MockApplication.instances).toHaveLength(1); + return MockApplication.instances[0]!; +}; + +beforeEach(() => { + MockApplication.reset(); +}); + +describe('useExoApplication — construction & wiring', () => { + it('constructs the Application once, binds it to the rendered canvas, and calls onReady with it', () => { + const onReady = vi.fn(); + const harness = mount({ options: { canvas: { width: 800, height: 600 } }, onReady }); + + const app = onlyInstance(); + expect(app.options.canvas?.element).toBe(harness.getCanvas()); + expect(harness.result().app).toBe(app); + expect(onReady).toHaveBeenCalledTimes(1); + expect(onReady).toHaveBeenCalledWith(app); + }); + + it('returns a canvasRef whose identity is stable across re-renders', () => { + const harness = mount({ options: { canvas: { width: 320, height: 240 } } }); + const refBefore = harness.result().canvasRef; + + harness.rerender({ options: { canvas: { width: 640, height: 480 } } }); + + expect(harness.result().canvasRef).toBe(refBefore); + // A live prop change must NOT tear down and rebuild the Application. + expect(MockApplication.instances).toHaveLength(1); + }); +}); + +describe('useExoApplication — identity vs live options', () => { + it('recreates (and destroys) the Application when the backend type changes', () => { + const harness = mount({ options: { backend: { type: 'webgl2' }, canvas: { width: 800, height: 600 } } }); + const first = onlyInstance(); + + harness.rerender({ options: { backend: { type: 'webgpu' }, canvas: { width: 800, height: 600 } } }); + + expect(MockApplication.instances).toHaveLength(2); + expect(first.destroy).toHaveBeenCalledTimes(1); + expect(harness.result().app).toBe(MockApplication.instances[1]); + }); + + it('live-syncs canvas size via app.resize without recreating the Application', () => { + const harness = mount({ options: { canvas: { width: 800, height: 600 } } }); + const app = onlyInstance(); + expect(app.resize).toHaveBeenLastCalledWith(800, 600); + + harness.rerender({ options: { canvas: { width: 1024, height: 768 } } }); + + expect(MockApplication.instances).toHaveLength(1); + expect(app.resize).toHaveBeenLastCalledWith(1024, 768); + }); + + it('live-syncs sizingMode via the setter without recreating the Application', () => { + const harness = mount({ options: { canvas: { width: 800, height: 600 } } }); + const app = onlyInstance(); + // No sizingMode given at mount → no assignment yet. + expect(app.sizingModeAssignments).toEqual([]); + + harness.rerender({ options: { canvas: { width: 800, height: 600, sizingMode: 'letterbox' } } }); + + expect(MockApplication.instances).toHaveLength(1); + expect(app.sizingModeAssignments).toEqual(['letterbox']); + expect(app.sizingMode).toBe('letterbox'); + }); + + it('live-syncs clearColor via the setter without recreating the Application', () => { + const harness = mount({ options: { canvas: { width: 800, height: 600 } } }); + const app = onlyInstance(); + expect(app.clearColorAssignments).toEqual([]); + + const red = new Color(255, 0, 0, 1); + harness.rerender({ options: { canvas: { width: 800, height: 600 }, clearColor: red } }); + + expect(MockApplication.instances).toHaveLength(1); + expect(app.clearColorAssignments).toEqual([red]); + expect(app.clearColor).toBe(red); + }); + + it('keys clearColor on its VALUE — a new Color with identical channels does not re-assign', () => { + const harness = mount({ options: { canvas: { width: 800, height: 600 }, clearColor: new Color(10, 20, 30, 1) } }); + const app = onlyInstance(); + expect(app.clearColorAssignments).toHaveLength(1); + + // Different object, same r,g,b,a → colorKey is unchanged → effect must not refire. + harness.rerender({ options: { canvas: { width: 800, height: 600 }, clearColor: new Color(10, 20, 30, 1) } }); + + expect(app.clearColorAssignments).toHaveLength(1); + }); +}); + +describe('useExoApplication — teardown', () => { + it('destroys the Application when the component unmounts', () => { + const harness = mount({ options: { canvas: { width: 800, height: 600 } } }); + const app = onlyInstance(); + + harness.unmount(); + + expect(app.destroy).toHaveBeenCalledTimes(1); + expect(app.destroyed).toBe(true); + }); +}); diff --git a/packages/exojs-react/test/useScene.test.tsx b/packages/exojs-react/test/useScene.test.tsx new file mode 100644 index 00000000..dbc606ec --- /dev/null +++ b/packages/exojs-react/test/useScene.test.tsx @@ -0,0 +1,70 @@ +import { Application, Scene as ExoScene } from '@codexo/exojs'; +import { render, waitFor } from '@testing-library/react'; +import { type DependencyList, type ReactElement, type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ExoContext } from '../src/ExoContext'; +import { useScene } from '../src/useScene'; +import { MockApplication } from './support/mock-application'; + +// The mock module is imported INSIDE the factory because `vi.mock` is hoisted +// above this file's imports (top-level bindings are not initialised yet). +vi.mock('@codexo/exojs', async importActual => { + const actual = await importActual(); + const { MockApplication: MockApp, configureApplicationStatus } = await import('./support/mock-application'); + configureApplicationStatus(actual.ApplicationStatus); + return { ...actual, Application: MockApp }; +}); + +class LevelScene extends ExoScene {} + +function SceneProbe({ sceneClass, deps }: { sceneClass: new () => ExoScene; deps?: DependencyList }): ReactElement { + const scene = useScene(sceneClass, deps); + + return {scene?.constructor.name ?? 'loading'}; +} + +function provide(app: Application, children: ReactNode): ReactElement { + return {children}; +} + +const makeApp = (): MockApplication => new Application() as unknown as MockApplication; + +beforeEach(() => { + MockApplication.reset(); +}); + +describe('useScene', () => { + it('starts the engine on first activation and returns the live scene', async () => { + const app = makeApp(); + const { findByText } = render(provide(app, )); + + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start.mock.calls[0]![0]).toBeInstanceOf(LevelScene); + expect(app.scene.setScene).not.toHaveBeenCalled(); + + expect(await findByText('LevelScene')).toBeTruthy(); + }); + + it('switches scenes via setScene (not a restart) when deps change', async () => { + const app = makeApp(); + const view = render(provide(app, )); + await view.findByText('LevelScene'); + + view.rerender(provide(app, )); + + // The new scene is installed through setScene; the engine is NOT started again. + await waitFor(() => expect(app.scene.setScene.mock.calls.some(call => call[0] instanceof LevelScene)).toBe(true)); + expect(app.start).toHaveBeenCalledTimes(1); + }); + + it('clears the scene (setScene(null)) on unmount', async () => { + const app = makeApp(); + const view = render(provide(app, )); + await view.findByText('LevelScene'); + + view.unmount(); + + expect(app.scene.setScene).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/exojs-react/tsconfig.json b/packages/exojs-react/tsconfig.json new file mode 100644 index 00000000..0a313286 --- /dev/null +++ b/packages/exojs-react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "jsx": "react-jsx", + "customConditions": ["@codexo/source"], + "paths": { + "@codexo/exojs": ["../../src/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/exojs-tiled/src/TiledMap.ts b/packages/exojs-tiled/src/TiledMap.ts index 370ab948..74055884 100644 --- a/packages/exojs-tiled/src/TiledMap.ts +++ b/packages/exojs-tiled/src/TiledMap.ts @@ -1,17 +1,18 @@ -import { TextureRegion } from '@codexo/exojs'; -import type { ObjectPoint, ResolvedTile, TileMapObject, TileProperties, TilePropertyValue, TileTransform } from '@codexo/exojs-tilemap'; -import { ObjectLayer, TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; +import { type Texture, TextureRegion } from '@codexo/exojs'; +import type { ObjectPoint, ResolvedTile, TextStyle, TileAnimationFrame, TileDefinition, TileMapObject, TileProperties, TilePropertyValue, TileTransform } from '@codexo/exojs-tilemap'; +import { ImageLayer, ObjectLayer, TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; -import type { TiledMapData, TiledOrientation, TiledPropertyData, TiledRenderOrder } from './data'; +import type { TiledMapData, TiledObjectData, TiledOrientation, TiledPropertyData, TiledRenderOrder, TiledTileData } from './data'; import { maskTiledGid, TILED_FLIPPED_DIAGONALLY_FLAG, TILED_FLIPPED_HORIZONTALLY_FLAG, TILED_FLIPPED_VERTICALLY_FLAG, } from './gid'; -import { createTiledLayer, TiledGroupLayer, type TiledLayer,TiledObjectLayer, TiledTileLayer } from './TiledLayer'; +import { createTiledLayer, TiledGroupLayer, TiledImageLayer, type TiledLayer,TiledObjectLayer, TiledTileLayer } from './TiledLayer'; import type { TiledObject } from './TiledObject'; import type { TiledTileset } from './TiledTileset'; +import { resolveTiledUrl } from './url'; import { TiledFormatError } from './validate'; /** @@ -51,7 +52,14 @@ export class TiledMap { public readonly tilesets: readonly TiledTileset[]; public readonly properties: readonly TiledPropertyData[]; - public constructor(source: string, data: TiledMapData, tilesets: readonly TiledTileset[]) { + private readonly _imageTextures: ReadonlyMap; + + public constructor( + source: string, + data: TiledMapData, + tilesets: readonly TiledTileset[], + imageTextures: ReadonlyMap = new Map(), + ) { this.source = source; this.data = data; this.orientation = data.orientation; @@ -64,6 +72,7 @@ export class TiledMap { this.infinite = data.infinite; this.backgroundColor = data.backgroundcolor; this.properties = data.properties ?? []; + this._imageTextures = imageTextures; this.layers = data.layers.map(createTiledLayer); this.tilesets = sortAndValidateTilesetRanges(tilesets, source); @@ -106,9 +115,9 @@ export class TiledMap { * Only finite orthogonal maps with atlas tilesets are supported. A * non-orthogonal or infinite map, or a collection-of-images tileset, throws * {@link TiledFormatError} rather than silently producing wrong (misplaced) - * or empty geometry. Tile layers become renderable `TileLayer`s and object - * groups become data-only `ObjectLayer`s; group layer children are flattened - * in document order. Image layers are not yet converted. + * or empty geometry. Tile layers become renderable `TileLayer`s, object + * groups become data-only `ObjectLayer`s, and image layers become data-only + * `ImageLayer`s; group layer children are flattened in document order. * * The returned `TileMap` does **not** own the tileset textures — they remain * in the Loader cache. Destroying the returned map does not unload textures. @@ -170,14 +179,25 @@ export class TiledMap { columns: tiledTs.columns, spacing: tiledTs.spacing, margin: tiledTs.margin, + class: tiledTs.class, + offsetX: tiledTs.tileOffset.x, + offsetY: tiledTs.tileOffset.y, }); + // Carry per-tile metadata (properties + animation frames) into the runtime + // tileset. Without this, Tiled tile animations and per-tile properties are + // lost at conversion. Out-of-range entries are skipped defensively. + const defs = buildTileDefinitions(tiledTs.tiles, tiledTs.tileCount); + if (defs.length > 0) { + rts._setDefinitions(defs); + } runtimeTilesets.push(rts); indexToRuntime.push(rts); } - // Collect and convert tile + object layers, flattening group layers. + // Collect and convert tile + object + image layers, flattening group layers. const runtimeLayers: TileLayer[] = []; const runtimeObjectLayers: ObjectLayer[] = []; + const runtimeImageLayers: ImageLayer[] = []; const convertLayers = (layers: readonly TiledLayer[]): void => { for (const layer of layers) { if (layer instanceof TiledGroupLayer) { @@ -195,6 +215,10 @@ export class TiledMap { opacity: layer.opacity, offsetX: layer.offsetX, offsetY: layer.offsetY, + parallaxX: layer.parallaxX, + parallaxY: layer.parallaxY, + class: layer.class, + tintColor: parseTiledColor(layer.tintColor), }); if (layer.data) { populateTileLayer(rLayer, layer.data, this.tilesets, indexToRuntime); @@ -202,8 +226,24 @@ export class TiledMap { runtimeLayers.push(rLayer); } else if (layer instanceof TiledObjectLayer) { runtimeObjectLayers.push(convertObjectLayer(layer, this.tilesets, indexToRuntime)); + } else if (layer instanceof TiledImageLayer) { + runtimeImageLayers.push(new ImageLayer({ + id: layer.id, + name: layer.name, + class: layer.class, + image: resolveTiledUrl(layer.image, this.source), + texture: this._imageTextures.get(layer.id) ?? null, + visible: layer.visible, + opacity: layer.opacity, + offsetX: layer.offsetX, + offsetY: layer.offsetY, + parallaxX: layer.parallaxX, + parallaxY: layer.parallaxY, + tintColor: parseTiledColor(layer.tintColor), + repeatX: layer.repeatX, + repeatY: layer.repeatY, + })); } - // ImageLayer: not yet converted. } }; convertLayers(this.layers); @@ -217,6 +257,10 @@ export class TiledMap { tilesets: runtimeTilesets, layers: runtimeLayers, objectLayers: runtimeObjectLayers, + imageLayers: runtimeImageLayers, + class: this.class, + backgroundColor: parseTiledColor(this.backgroundColor), + renderOrder: this.renderOrder ?? 'right-down', }); } @@ -298,24 +342,21 @@ function convertObjectLayer( opacity: layer.opacity, offsetX: layer.offsetX, offsetY: layer.offsetY, + drawOrder: layer.drawOrder, objects, properties: convertProperties(layer.properties), }); } /** - * Convert one `TiledObject` to a `TileMapObject`. Text objects (and tile - * objects whose tileset was skipped) are dropped — returns `null`. + * Convert one `TiledObject` to a `TileMapObject`. Tile objects whose tileset + * was skipped are dropped — returns `null`. */ function convertObject( object: TiledObject, tiledTilesets: readonly TiledTileset[], indexToRuntime: ReadonlyArray, ): TileMapObject | null { - if (object.text) { - return null; // text objects are not represented in the data-only model - } - const base = { id: object.id, name: object.name, @@ -329,6 +370,25 @@ function convertObject( properties: convertProperties(object.properties), }; + if (object.text) { + const t = object.text; + const color = t.color !== undefined ? parseTiledColor(t.color) : undefined; + const textStyle: TextStyle = { + text: t.text, + ...(color !== undefined && { color }), + ...(t.fontfamily !== undefined && { fontFamily: t.fontfamily }), + ...(t.pixelsize !== undefined && { pixelSize: t.pixelsize }), + ...(t.bold !== undefined && { bold: t.bold }), + ...(t.italic !== undefined && { italic: t.italic }), + ...(t.underline !== undefined && { underline: t.underline }), + ...(t.strikeout !== undefined && { strikeout: t.strikeout }), + ...(t.wrap !== undefined && { wrap: t.wrap }), + ...(t.halign !== undefined && { halign: t.halign }), + ...(t.valign !== undefined && { valign: t.valign }), + }; + return { ...base, kind: 'text', text: textStyle }; + } + if (object.gid !== undefined) { const tile = resolveGid(object.gid, tiledTilesets, indexToRuntime); if (!tile) return null; @@ -351,6 +411,98 @@ function convertObject( const toPoints = (points: ReadonlyArray<{ x: number; y: number }>): ObjectPoint[] => points.map(p => ({ x: p.x, y: p.y })); +/** + * Parse a Tiled colour string (`#RRGGBB` or `#AARRGGBB`) into a `0xRRGGBB` + * integer, dropping any alpha. Returns `null` for an absent or malformed value. + */ +function parseTiledColor(value: string | undefined): number | null { + if (value === undefined || value === '') return null; + const hex = value.startsWith('#') ? value.slice(1) : value; + let rrggbb: string; + if (hex.length === 8) { + rrggbb = hex.slice(2); // drop leading alpha + } else if (hex.length === 6) { + rrggbb = hex; + } else { + return null; + } + const parsed = Number.parseInt(rrggbb, 16); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * Build runtime {@link TileDefinition}s from a Tiled tileset's per-tile data, + * carrying over per-tile properties, animation frames, and collision shapes + * (the tile's `objectgroup`). Tiles whose `id` is out of range, and animation + * frames referencing out-of-range local ids, are skipped so the runtime tileset + * never receives malformed definitions. + */ +function buildTileDefinitions(tiles: readonly TiledTileData[], tileCount: number): TileDefinition[] { + const defs: TileDefinition[] = []; + for (const tile of tiles) { + if (tile.id < 0 || tile.id >= tileCount) continue; + + const properties = tile.properties ? convertProperties(tile.properties) : undefined; + const hasProps = properties !== undefined && Object.keys(properties).length > 0; + + let animation: readonly TileAnimationFrame[] | undefined; + if (tile.animation && tile.animation.length > 0) { + const frames = tile.animation + .filter(frame => frame.tileid >= 0 && frame.tileid < tileCount) + .map(frame => ({ localTileId: frame.tileid, duration: frame.duration })); + if (frames.length > 0) animation = frames; + } + + let collision: readonly TileMapObject[] | undefined; + if (tile.objectgroup && tile.objectgroup.objects.length > 0) { + const shapes = tile.objectgroup.objects + .map(obj => convertCollisionObject(obj)) + .filter((obj): obj is TileMapObject => obj !== null); + if (shapes.length > 0) collision = shapes; + } + + if (!hasProps && animation === undefined && collision === undefined) continue; + + defs.push({ + localTileId: tile.id, + ...(hasProps && { properties }), + ...(animation !== undefined && { animation }), + ...(collision !== undefined && { collision }), + }); + } + return defs; +} + +/** + * Convert a raw `TiledObjectData` to a runtime `TileMapObject` for use as a + * per-tile collision shape. Text objects and tile-object (gid) references are + * dropped — returns `null`. Does not perform GID resolution; collision shapes + * in tile objectgroups are almost exclusively plain geometry. + */ +function convertCollisionObject(obj: TiledObjectData): TileMapObject | null { + if (obj.text) return null; // text not representable as a collision shape + if (obj.gid !== undefined) return null; // tile objects require GID resolution not available here + + const base = { + id: obj.id, + name: obj.name, + type: obj.type, + x: obj.x, + y: obj.y, + width: obj.width, + height: obj.height, + rotation: obj.rotation, + visible: obj.visible, + properties: convertProperties(obj.properties ?? []), + }; + + if (obj.point === true) return { ...base, kind: 'point' }; + if (obj.ellipse === true) return { ...base, kind: 'ellipse' }; + if (obj.polygon) return { ...base, kind: 'polygon', points: toPoints(obj.polygon) }; + if (obj.polyline) return { ...base, kind: 'polyline', points: toPoints(obj.polyline) }; + return { ...base, kind: 'rectangle' }; +} + /** * Project Tiled custom properties to the generic flat property bag. Class / * object-valued properties are not representable and are skipped. diff --git a/packages/exojs-tiled/src/TiledTileset.ts b/packages/exojs-tiled/src/TiledTileset.ts index 0e1ddaa0..1e4f48b5 100644 --- a/packages/exojs-tiled/src/TiledTileset.ts +++ b/packages/exojs-tiled/src/TiledTileset.ts @@ -1,6 +1,6 @@ import type { Texture } from '@codexo/exojs'; -import type { TiledPointData, TiledPropertyData, TiledTileData, TiledTilesetData } from './data'; +import type { TiledPointData, TiledPropertyData, TiledTileData, TiledTilesetData, TiledWangSetData } from './data'; /** * Loader-resolved resources for a {@link TiledTileset}: the absolute image @@ -49,6 +49,8 @@ export class TiledTileset { public readonly tileOffset: TiledPointData; public readonly objectAlignment?: string | undefined; public readonly tiles: readonly TiledTileData[]; + /** Wang sets (terrain/auto-tile definitions) declared on this tileset. Empty when none are defined. */ + public readonly wangSets: readonly TiledWangSetData[]; /** Textures loaded for collection-of-images tiles, keyed by local tile id. Loader-owned. */ public readonly tileTextures: ReadonlyMap; public readonly properties: readonly TiledPropertyData[]; @@ -71,6 +73,7 @@ export class TiledTileset { this.tileOffset = data.tileoffset ?? { x: 0, y: 0 }; this.objectAlignment = data.objectalignment; this.tiles = data.tiles ?? []; + this.wangSets = data.wangsets ?? []; this.tileTextures = resources.tileTextures ?? new Map(); this.properties = data.properties ?? []; } diff --git a/packages/exojs-tiled/src/data.ts b/packages/exojs-tiled/src/data.ts index 38d5b681..66fa6c6f 100644 --- a/packages/exojs-tiled/src/data.ts +++ b/packages/exojs-tiled/src/data.ts @@ -8,8 +8,9 @@ // Field coverage targets the orthogonal-map feature set described in the // v0.13 Tiled phase-2 spec (tile/object/image/group layers, embedded and // external tilesets, custom properties, tile animations). Hex/isometric-only -// fields (grid, hexsidelength, staggeraxis/-index, wangsets, terrains, -// transformations, parallax origin) are intentionally not modelled. +// fields (grid, hexsidelength, staggeraxis/-index, transformations, parallax +// origin) are intentionally not modelled. Wangsets (terrain/auto-tile +// definitions) are modelled via {@link TiledWangSetData}. /** A 2D point as written by Tiled (used for tile offsets and object shapes). */ export interface TiledPointData { @@ -78,6 +79,52 @@ export interface TiledTileData { readonly imageheight?: number | undefined; } +// ── Wangsets (terrains / auto-tile definitions) ─────────────────────────────── + +/** + * One color (terrain) entry within a wangset's `colors` array. + * + * `tile` is the representative tile local id for this color (–1 if unset). + * `probability` is the relative spawn weight used by Tiled's random fill. + */ +export interface TiledWangColorData { + readonly name: string; + readonly color: string; + readonly tile: number; + readonly probability: number; +} + +/** + * One wang-tile entry within a wangset's `wangtiles` array. + * + * `tileid` is the local tile id within the owning tileset. + * + * `wangid` is an 8-element array of color indices (1-based; 0 = unset) whose + * positions correspond to (in order): top, top-right, right, bottom-right, + * bottom, bottom-left, left, top-left. + */ +export interface TiledWangTileData { + readonly tileid: number; + readonly wangid: readonly number[]; +} + +/** + * A wangset entry within a tileset's `wangsets` array. + * + * `type` is `'corner'`, `'edge'`, or `'mixed'` (Tiled 1.5+). Older files may + * omit it or use other strings — treat unknown values as-is. + * + * `tile` is the representative tile local id for this wangset (–1 if unset). + */ +export interface TiledWangSetData { + readonly name: string; + readonly type: string; + readonly tile: number; + readonly colors: readonly TiledWangColorData[]; + readonly wangtiles: readonly TiledWangTileData[]; + readonly properties?: readonly TiledPropertyData[] | undefined; +} + // ── Tileset ────────────────────────────────────────────────────────────────── /** @@ -100,6 +147,7 @@ export interface TiledTilesetData { readonly tileoffset?: TiledPointData | undefined; readonly objectalignment?: string | undefined; readonly tiles?: readonly TiledTileData[] | undefined; + readonly wangsets?: readonly TiledWangSetData[] | undefined; readonly properties?: readonly TiledPropertyData[] | undefined; readonly tiledversion?: string | undefined; readonly version?: string | number | undefined; diff --git a/packages/exojs-tiled/src/decodeLayerData.ts b/packages/exojs-tiled/src/decodeLayerData.ts new file mode 100644 index 00000000..35ab1f13 --- /dev/null +++ b/packages/exojs-tiled/src/decodeLayerData.ts @@ -0,0 +1,115 @@ +// Pre-validation decode pass for Tiled tile-layer data. +// +// Tiled can store tile-layer GIDs as plain CSV (a JSON array), or as a base64 +// string that is optionally gzip/zlib/zstd compressed. The rest of the pipeline +// (validation, `TiledMap`, `toTileMap`) only deals with the decoded `number[]` +// GID array, so this pass runs first — during the async load phase — and +// rewrites any base64/compressed `data` (and infinite-map `chunks[].data`) into +// plain GID arrays in place. After it runs, the document looks like a CSV map to +// every downstream stage, which stays synchronous and unchanged. + +import { Codec } from '@codexo/exojs'; + +import { TiledFormatError } from './validate'; + +/** Read a little-endian Uint32 GID array out of a decoded byte buffer. */ +function bytesToGids(bytes: Uint8Array, source: string, path: string): number[] { + if (bytes.length % 4 !== 0) { + throw new TiledFormatError(source, path, `decoded tile data length ${bytes.length} is not a multiple of 4`); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const count = bytes.length / 4; + const gids = new Array(count); + for (let i = 0; i < count; i++) { + gids[i] = view.getUint32(i * 4, true); // Tiled writes little-endian. + } + return gids; +} + +/** Decode one base64 `data` string into a GID array, applying any compression. */ +async function decodeBase64Gids( + data: string, + compression: unknown, + source: string, + path: string, +): Promise { + let bytes = Codec.decodeBase64(data); + + if (compression === 'gzip') { + bytes = await Codec.decompress(bytes, 'gzip'); + } else if (compression === 'zlib') { + bytes = await Codec.decompress(bytes, 'deflate'); + } else if (compression === 'zstd') { + throw new TiledFormatError( + source, + path, + 'zstd-compressed tile data is not supported (no native decoder; re-export with gzip/zlib or uncompressed)', + ); + } else if (compression !== undefined && compression !== '') { + throw new TiledFormatError(source, path, `unsupported tile layer compression ${JSON.stringify(compression)}`); + } + + return bytesToGids(bytes, source, path); +} + +/** Decode a single tile layer's `data` and/or `chunks[].data` in place. */ +async function decodeTileLayer(layer: Record, source: string, path: string): Promise { + if (layer.encoding !== 'base64') { + return; // CSV / plain array — nothing to decode. + } + + if (typeof layer.data === 'string') { + layer.data = await decodeBase64Gids(layer.data, layer.compression, source, `${path}.data`); + } + + if (Array.isArray(layer.chunks)) { + await Promise.all( + (layer.chunks as unknown[]).map(async (chunk, i) => { + if (typeof chunk !== 'object' || chunk === null) return; + const c = chunk as Record; + if (typeof c.data === 'string') { + c.data = await decodeBase64Gids(c.data, layer.compression, source, `${path}.chunks[${i}].data`); + } + }), + ); + } + + // Now that data is a plain GID array, drop the encoding/compression markers so + // downstream validation treats it like a CSV layer. + delete layer.encoding; + delete layer.compression; +} + +/** Recursively decode every tile layer under a `layers` array (groups nest). */ +async function decodeLayers(layers: unknown, source: string, path: string): Promise { + if (!Array.isArray(layers)) { + return; + } + await Promise.all( + (layers as unknown[]).map(async (layer, i) => { + if (typeof layer !== 'object' || layer === null) return; + const l = layer as Record; + const layerPath = `${path}[${i}]`; + if (l.type === 'tilelayer') { + await decodeTileLayer(l, source, layerPath); + } else if (l.type === 'group') { + await decodeLayers(l.layers, source, `${layerPath}.layers`); + } + }), + ); +} + +/** + * Decode any base64/compressed tile-layer data in a raw Tiled map document into + * plain GID arrays, mutating `raw` in place and returning it. Safe to call on a + * pure-CSV map (it does nothing). Runs before validation. + * + * @throws {TiledFormatError} On zstd/unknown compression or malformed data. + * @internal + */ +export async function decodeTiledLayerData(raw: unknown, source: string): Promise { + if (typeof raw === 'object' && raw !== null) { + await decodeLayers((raw as Record).layers, source, 'layers'); + } + return raw; +} diff --git a/packages/exojs-tiled/src/loadTiledMap.ts b/packages/exojs-tiled/src/loadTiledMap.ts index efcbb1de..5aea04b3 100644 --- a/packages/exojs-tiled/src/loadTiledMap.ts +++ b/packages/exojs-tiled/src/loadTiledMap.ts @@ -1,6 +1,7 @@ import { type AssetLoaderContext,Texture } from '@codexo/exojs'; -import type { TiledTilesetData, TiledTilesetRefData } from './data'; +import type { TiledLayerData, TiledTilesetData, TiledTilesetRefData } from './data'; +import { decodeTiledLayerData } from './decodeLayerData'; import { TiledMap } from './TiledMap'; import { TiledTileset, type TiledTilesetResources } from './TiledTileset'; import { resolveTiledUrl } from './url'; @@ -64,6 +65,35 @@ async function loadTiledTileset(ref: TiledTilesetRefData, mapSource: string, con return new TiledTileset(ref, ref.firstgid, resources); } +/** + * Recursively walks the layer tree and loads the image for every + * `imagelayer` encountered. Returns a `Map` keyed by layer `id` so that + * {@link TiledMap.toTileMap} can attach the pre-loaded {@link Texture} to each + * runtime image layer without performing additional I/O. + */ +async function loadImageLayerTextures( + layers: readonly TiledLayerData[], + mapSource: string, + context: AssetLoaderContext, +): Promise> { + const result = new Map(); + + for (const layer of layers) { + if (layer.type === 'imagelayer' && layer.image) { + const imageUrl = resolveTiledUrl(layer.image, mapSource); + const texture: Texture = await context.loader.load(Texture, imageUrl); + result.set(layer.id, texture); + } else if (layer.type === 'group') { + const nested = await loadImageLayerTextures(layer.layers, mapSource, context); + for (const [id, tex] of nested) { + result.set(id, tex); + } + } + } + + return result; +} + /** * Loads and resolves a Tiled map: fetches and validates the `.tmj`, resolves * each tileset (external `.tsj` or embedded) and its image(s), and returns @@ -72,8 +102,14 @@ async function loadTiledTileset(ref: TiledTilesetRefData, mapSource: string, con */ export async function loadTiledMap(source: string, context: AssetLoaderContext): Promise { const raw = await context.fetchJson(source); + // Decode any base64/gzip/zlib tile-layer data into plain GID arrays before + // validation, so the rest of the pipeline stays CSV-shaped and synchronous. + await decodeTiledLayerData(raw, source); const data = validateTiledMapData(raw, source); - const tilesets = await Promise.all(data.tilesets.map(ref => loadTiledTileset(ref, source, context))); + const [tilesets, imageTextures] = await Promise.all([ + Promise.all(data.tilesets.map(ref => loadTiledTileset(ref, source, context))), + loadImageLayerTextures(data.layers, source, context), + ]); - return new TiledMap(source, data, tilesets); + return new TiledMap(source, data, tilesets, imageTextures); } diff --git a/packages/exojs-tiled/src/public.ts b/packages/exojs-tiled/src/public.ts index c48e510a..85de314d 100644 --- a/packages/exojs-tiled/src/public.ts +++ b/packages/exojs-tiled/src/public.ts @@ -40,6 +40,7 @@ export type { TilePropertyValue, TileSetOptions, TileTransform, + WangSetOptions, } from '@codexo/exojs-tilemap'; export { ObjectLayer, @@ -52,6 +53,7 @@ export { TileMapNode, TileMapView, TileSet, + WangSet, } from '@codexo/exojs-tilemap'; // ── Raw Tiled JSON (TMJ/TSJ) types ────────────────────────────────────────── @@ -78,6 +80,9 @@ export type { TiledTileLayerData, TiledTilesetData, TiledTilesetRefData, + TiledWangColorData, + TiledWangSetData, + TiledWangTileData, } from './data'; // ── Parsed source model ───────────────────────────────────────────────────── @@ -95,6 +100,9 @@ export { TiledObject } from './TiledObject'; export type { TiledTilesetResources } from './TiledTileset'; export { TiledTileset } from './TiledTileset'; +// ── Wangset conversion ──────────────────────────────────────────────────────── +export { tiledWangSetToWangSet } from './wangSets'; + // ── Validation ─────────────────────────────────────────────────────────────── export { TiledFormatError } from './validate'; diff --git a/packages/exojs-tiled/src/validate.ts b/packages/exojs-tiled/src/validate.ts index 8d7920d6..3e08297d 100644 --- a/packages/exojs-tiled/src/validate.ts +++ b/packages/exojs-tiled/src/validate.ts @@ -27,6 +27,9 @@ import type { TiledTileLayerData, TiledTilesetData, TiledTilesetRefData, + TiledWangColorData, + TiledWangSetData, + TiledWangTileData, } from './data'; /** @@ -348,6 +351,10 @@ function validateTiledChunkData(raw: unknown, source: string, path: string): Til } function validateTiledTileLayerData(obj: Record, base: TiledLayerDataBase, source: string, path: string): TiledTileLayerData { + // Validation runs AFTER the async decode pass (`decodeLayerData.ts`), which + // turns base64/gzip/zlib `data` into a plain GID array and strips these + // markers. Reaching here with them still set means the data was not decoded + // (e.g. validate was called directly) — reject rather than mis-parse. if (obj.compression !== undefined) { throw new TiledFormatError(source, joinPath(path, 'compression'), `compressed tile layer data is not supported (compression: ${JSON.stringify(obj.compression)})`); } @@ -490,6 +497,48 @@ export function validateTiledTileData(raw: unknown, source: string, path: string }; } +// ── Wangsets ───────────────────────────────────────────────────────────────── + +function validateTiledWangColorData(raw: unknown, source: string, path: string): TiledWangColorData { + const obj = expectObject(raw, source, path); + return { + name: expectString(obj.name, source, joinPath(path, 'name')), + color: expectString(obj.color, source, joinPath(path, 'color')), + tile: expectInteger(obj.tile, source, joinPath(path, 'tile')), + probability: expectNumber(obj.probability, source, joinPath(path, 'probability')), + }; +} + +function validateTiledWangTileData(raw: unknown, source: string, path: string): TiledWangTileData { + const obj = expectObject(raw, source, path); + const tileid = expectNonNegativeInteger(obj.tileid, source, joinPath(path, 'tileid')); + const wangid = mapArray(obj.wangid, source, joinPath(path, 'wangid'), (item, itemPath) => + expectNonNegativeInteger(item, source, itemPath), + ); + return { tileid, wangid }; +} + +function validateTiledWangSetData(raw: unknown, source: string, path: string): TiledWangSetData { + const obj = expectObject(raw, source, path); + return { + name: expectString(obj.name, source, joinPath(path, 'name')), + // type is accepted as any string — unknown values are treated as-is per spec + type: expectString(obj.type, source, joinPath(path, 'type')), + tile: expectInteger(obj.tile, source, joinPath(path, 'tile')), + colors: mapArray(obj.colors, source, joinPath(path, 'colors'), (item, itemPath) => + validateTiledWangColorData(item, source, itemPath), + ), + wangtiles: mapArray(obj.wangtiles, source, joinPath(path, 'wangtiles'), (item, itemPath) => + validateTiledWangTileData(item, source, itemPath), + ), + properties: validateTiledPropertiesArray(obj.properties, source, joinPath(path, 'properties')), + }; +} + +function validateTiledWangSets(value: unknown, source: string, path: string): readonly TiledWangSetData[] | undefined { + return optionalMapArray(value, source, path, (item, itemPath) => validateTiledWangSetData(item, source, itemPath)); +} + // ── Tilesets ───────────────────────────────────────────────────────────────── function validateTiledVersion(value: unknown, source: string, path: string): string | number { @@ -516,6 +565,7 @@ export function validateTiledTilesetData(obj: Record, source: s tileoffset: obj.tileoffset === undefined ? undefined : validateTiledPointData(obj.tileoffset, source, joinPath(path, 'tileoffset')), objectalignment: optionalString(obj, 'objectalignment', source, path), tiles: optionalMapArray(obj.tiles, source, joinPath(path, 'tiles'), (item, itemPath) => validateTiledTileData(item, source, itemPath)), + wangsets: validateTiledWangSets(obj.wangsets, source, joinPath(path, 'wangsets')), properties: validateTiledPropertiesArray(obj.properties, source, joinPath(path, 'properties')), tiledversion: optionalString(obj, 'tiledversion', source, path), version: obj.version === undefined ? undefined : validateTiledVersion(obj.version, source, joinPath(path, 'version')), diff --git a/packages/exojs-tiled/src/wangSets.ts b/packages/exojs-tiled/src/wangSets.ts new file mode 100644 index 00000000..60790112 --- /dev/null +++ b/packages/exojs-tiled/src/wangSets.ts @@ -0,0 +1,136 @@ +import { WangSet } from '@codexo/exojs-tilemap'; + +import type { TiledWangSetData } from './data'; + +/** + * Best-effort conversion of a {@link TiledWangSetData} to a runtime + * {@link WangSet} from `@codexo/exojs-tilemap`. + * + * ## Supported types + * + * - **`'corner'`** — Maps the four corner positions of each `wangtile` to the + * four corner bits of the 8-bit blob mask and returns a `WangSet` of type + * `'blob'`. Only color index `> 0` in the Tiled wangid is treated as "this + * terrain"; color index `0` means unset (not this terrain). + * + * Tiled corner wangid position → WangSet blob bit mapping: + * - `wangid[7]` (top-left) → bit 0 (value 1) + * - `wangid[1]` (top-right) → bit 2 (value 4) + * - `wangid[5]` (bottom-left) → bit 5 (value 32) + * - `wangid[3]` (bottom-right) → bit 7 (value 128) + * + * This is a best-effort conversion accurate for the common **single-color** + * corner terrain (blob-style). Multi-color terrains are accepted but only + * the presence/absence of any color (> 0 = set) is used; which specific + * color is set is ignored. + * + * - **`'edge'`** — Maps the four edge positions to the 4-bit edge mask and + * returns a `WangSet` of type `'edge'`. + * + * Tiled edge wangid position → WangSet edge bit mapping: + * - `wangid[0]` (top) → bit 0 (value 1) + * - `wangid[2]` (right) → bit 1 (value 2) + * - `wangid[4]` (bottom) → bit 2 (value 4) + * - `wangid[6]` (left) → bit 3 (value 8) + * + * - **`'mixed'` / unknown** — Returns `null`; mixed wangsets interleave corner + * and edge semantics in a way that cannot be faithfully represented by + * a single blob or edge mask without additional heuristics. + * + * @param wangSet The raw {@link TiledWangSetData} parsed from a `.tsj` file. + * @param tilesetIndex The index of the owning tileset within the layer's + * tileset list (forwarded to {@link WangSet} as-is). + * @returns A {@link WangSet}, or `null` when the wangset type is unsupported. + */ +export function tiledWangSetToWangSet(wangSet: TiledWangSetData, tilesetIndex: number): WangSet | null { + if (wangSet.type === 'corner') { + return convertCornerWangSet(wangSet, tilesetIndex); + } + + if (wangSet.type === 'edge') { + return convertEdgeWangSet(wangSet, tilesetIndex); + } + + // 'mixed' or any unknown type cannot be faithfully mapped. + return null; +} + +// ── Corner → blob ───────────────────────────────────────────────────────────── +// +// Tiled corner wangid positions (indices into the 8-element wangid array): +// Index order: [top(0), topright(1), right(2), bottomright(3), bottom(4), bottomleft(5), left(6), topleft(7)] +// +// WangSet blob bitmask values (bit layout: bit0=TL, bit1=T, bit2=TR, bit3=L, bit4=R, bit5=BL, bit6=B, bit7=BR): +// topleft → wangid[7] → blob bit 0 (value 1) +// topright → wangid[1] → blob bit 2 (value 4) +// bottomleft → wangid[5] → blob bit 5 (value 32) +// bottomright → wangid[3] → blob bit 7 (value 128) + +const cornerIndexTopLeft = 7; +const cornerIndexTopRight = 1; +const cornerIndexBottomRight = 3; +const cornerIndexBottomLeft = 5; + +const blobBitTopLeft = 1; // bit 0 +const blobBitTopRight = 4; // bit 2 +const blobBitBottomLeft = 32; // bit 5 +const blobBitBottomRight = 128; // bit 7 + +function convertCornerWangSet(wangSet: TiledWangSetData, tilesetIndex: number): WangSet { + const blobMap = new Map(); + + for (const wangTile of wangSet.wangtiles) { + const id = wangTile.wangid; + let mask = 0; + + if ((id[cornerIndexTopLeft] ?? 0) > 0) mask |= blobBitTopLeft; + if ((id[cornerIndexTopRight] ?? 0) > 0) mask |= blobBitTopRight; + if ((id[cornerIndexBottomLeft] ?? 0) > 0) mask |= blobBitBottomLeft; + if ((id[cornerIndexBottomRight] ?? 0) > 0) mask |= blobBitBottomRight; + + // Last writer wins when two wangtiles produce the same mask + // (shouldn't happen in well-formed data, but be lenient). + blobMap.set(mask, wangTile.tileid); + } + + return new WangSet({ tilesetIndex, blobMap, type: 'blob' }); +} + +// ── Edge → edge ─────────────────────────────────────────────────────────────── +// +// Tiled edge wangid positions: +// Index order: [top(0), topright(1), right(2), bottomright(3), bottom(4), bottomleft(5), left(6), topleft(7)] +// +// WangSet edge bitmask values (bit layout: bit0=T, bit1=R, bit2=B, bit3=L): +// top → wangid[0] → edge bit 0 (value 1) +// right → wangid[2] → edge bit 1 (value 2) +// bottom → wangid[4] → edge bit 2 (value 4) +// left → wangid[6] → edge bit 3 (value 8) + +const edgeIndexTop = 0; +const edgeIndexRight = 2; +const edgeIndexBottom = 4; +const edgeIndexLeft = 6; + +const edgeBitTop = 1; // bit 0 +const edgeBitRight = 2; // bit 1 +const edgeBitBottom = 4; // bit 2 +const edgeBitLeft = 8; // bit 3 + +function convertEdgeWangSet(wangSet: TiledWangSetData, tilesetIndex: number): WangSet { + const blobMap = new Map(); + + for (const wangTile of wangSet.wangtiles) { + const id = wangTile.wangid; + let mask = 0; + + if ((id[edgeIndexTop] ?? 0) > 0) mask |= edgeBitTop; + if ((id[edgeIndexRight] ?? 0) > 0) mask |= edgeBitRight; + if ((id[edgeIndexBottom] ?? 0) > 0) mask |= edgeBitBottom; + if ((id[edgeIndexLeft] ?? 0) > 0) mask |= edgeBitLeft; + + blobMap.set(mask, wangTile.tileid); + } + + return new WangSet({ tilesetIndex, blobMap, type: 'edge' }); +} diff --git a/packages/exojs-tiled/test/decodeLayerData.test.ts b/packages/exojs-tiled/test/decodeLayerData.test.ts new file mode 100644 index 00000000..46cff27a --- /dev/null +++ b/packages/exojs-tiled/test/decodeLayerData.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeTiledLayerData } from '../src/decodeLayerData'; +import { TiledFormatError } from '../src/validate'; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function gidsToBytes(gids: readonly number[]): Uint8Array { + const buffer = new ArrayBuffer(gids.length * 4); + const view = new DataView(buffer); + gids.forEach((g, i) => view.setUint32(i * 4, g, true)); // little-endian + return new Uint8Array(buffer); +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +async function compress(bytes: Uint8Array, format: 'gzip' | 'deflate'): Promise { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes as BufferSource); + controller.close(); + }, + }); + const reader = source.pipeThrough(new CompressionStream(format)).getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function makeRawMap(layer: Record): Record { + return { type: 'map', layers: [layer] }; +} + +const GIDS = [1, 2, 3, 0, 5, 8, 0, 1]; + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('decodeTiledLayerData', () => { + it('leaves CSV (plain array) data untouched', async () => { + const raw = makeRawMap({ type: 'tilelayer', data: [...GIDS] }); + await decodeTiledLayerData(raw, 'test.tmj'); + expect((raw.layers as Record[])[0].data).toEqual(GIDS); + }); + + it('decodes uncompressed base64 to a GID array and drops the markers', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + data: bytesToBase64(gidsToBytes(GIDS)), + }); + await decodeTiledLayerData(raw, 'test.tmj'); + const layer = (raw.layers as Record[])[0]; + expect(layer.data).toEqual(GIDS); + expect(layer.encoding).toBeUndefined(); + expect(layer.compression).toBeUndefined(); + }); + + it('decodes base64 + gzip', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + compression: 'gzip', + data: bytesToBase64(await compress(gidsToBytes(GIDS), 'gzip')), + }); + await decodeTiledLayerData(raw, 'test.tmj'); + expect((raw.layers as Record[])[0].data).toEqual(GIDS); + }); + + it('decodes base64 + zlib (deflate)', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + compression: 'zlib', + data: bytesToBase64(await compress(gidsToBytes(GIDS), 'deflate')), + }); + await decodeTiledLayerData(raw, 'test.tmj'); + expect((raw.layers as Record[])[0].data).toEqual(GIDS); + }); + + it('decodes base64 chunk data (infinite-map shape)', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + chunks: [{ x: 0, y: 0, width: 4, height: 2, data: bytesToBase64(gidsToBytes(GIDS)) }], + }); + await decodeTiledLayerData(raw, 'test.tmj'); + const chunk = ((raw.layers as Record[])[0].chunks as Record[])[0]; + expect(chunk.data).toEqual(GIDS); + }); + + it('recurses into group layers', async () => { + const raw = makeRawMap({ + type: 'group', + layers: [{ type: 'tilelayer', encoding: 'base64', data: bytesToBase64(gidsToBytes(GIDS)) }], + }); + await decodeTiledLayerData(raw, 'test.tmj'); + const inner = ((raw.layers as Record[])[0].layers as Record[])[0]; + expect(inner.data).toEqual(GIDS); + }); + + it('rejects zstd compression with a clear error', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + compression: 'zstd', + data: bytesToBase64(gidsToBytes(GIDS)), + }); + await expect(decodeTiledLayerData(raw, 'test.tmj')).rejects.toThrow(TiledFormatError); + await expect(decodeTiledLayerData(raw, 'test.tmj')).rejects.toThrow(/zstd/); + }); + + it('throws on a byte length that is not a multiple of 4', async () => { + const raw = makeRawMap({ + type: 'tilelayer', + encoding: 'base64', + data: bytesToBase64(new Uint8Array([1, 2, 3])), // 3 bytes + }); + await expect(decodeTiledLayerData(raw, 'test.tmj')).rejects.toThrow(TiledFormatError); + }); +}); diff --git a/packages/exojs-tiled/test/fixtures/orthogonal-rich.tmj b/packages/exojs-tiled/test/fixtures/orthogonal-rich.tmj index 2b847267..0e654eb8 100644 --- a/packages/exojs-tiled/test/fixtures/orthogonal-rich.tmj +++ b/packages/exojs-tiled/test/fixtures/orthogonal-rich.tmj @@ -9,6 +9,7 @@ "tilewidth": 16, "tileheight": 16, "infinite": false, + "class": "level", "backgroundcolor": "#223344", "properties": [ { "name": "mood", "type": "string", "value": "calm" } @@ -18,6 +19,8 @@ "id": 1, "name": "Ground", "type": "tilelayer", + "class": "terrainLayer", + "tintcolor": "#ff8800", "visible": true, "opacity": 0.5, "x": 0, @@ -42,7 +45,7 @@ "opacity": 1, "x": 0, "y": 0, - "draworder": "topdown", + "draworder": "index", "objects": [ { "id": 1, "name": "hero", "type": "spawn", @@ -53,6 +56,12 @@ "id": 2, "name": "chest", "type": "prop", "x": 32, "y": 0, "width": 16, "height": 16, "rotation": 0, "visible": true, "gid": 2 + }, + { + "id": 3, "name": "sign", "type": "", + "x": 0, "y": 0, "width": 64, "height": 16, + "rotation": 0, "visible": true, + "text": { "text": "Hello", "color": "#ff0000", "pixelsize": 12, "bold": true, "wrap": true } } ] }, @@ -96,6 +105,7 @@ { "firstgid": 1, "name": "tiles-a", + "class": "terrainSet", "image": "tiles-a.png", "imagewidth": 64, "imageheight": 32, @@ -105,6 +115,7 @@ "tilecount": 8, "spacing": 0, "margin": 0, + "tileoffset": { "x": 2, "y": -3 }, "properties": [ { "name": "author", "type": "string", "value": "exo" } ], @@ -115,6 +126,38 @@ "properties": [ { "name": "solid", "type": "bool", "value": true } ] + }, + { + "id": 1, + "animation": [ + { "tileid": 1, "duration": 120 }, + { "tileid": 2, "duration": 120 } + ] + }, + { + "id": 3, + "objectgroup": { + "id": 10, + "name": "collision", + "type": "objectgroup", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "objects": [ + { + "id": 1, + "name": "solid", + "type": "", + "x": 2, + "y": 2, + "width": 12, + "height": 12, + "rotation": 0, + "visible": true + } + ] + } } ] }, diff --git a/packages/exojs-tiled/test/toTileMap.test.ts b/packages/exojs-tiled/test/toTileMap.test.ts index f0b8fffe..531db366 100644 --- a/packages/exojs-tiled/test/toTileMap.test.ts +++ b/packages/exojs-tiled/test/toTileMap.test.ts @@ -130,10 +130,11 @@ describe('TiledMap source model — orthogonal-rich.tmj', () => { it('parses object-layer objects including a tile-object gid', async () => { const map = await loadTiledMap('orthogonal-rich.tmj', context); const objects = (map.layers[1] as TiledObjectLayer).objects; - expect(objects).toHaveLength(2); + expect(objects).toHaveLength(3); expect(objects[0].name).toBe('hero'); expect(objects[0].point).toBe(true); expect(objects[1].gid).toBe(2); + expect(objects[2].text?.text).toBe('Hello'); }); it('preserves a group layer with a nested tile layer', async () => { @@ -163,10 +164,10 @@ describe('TiledMap.toTileMap() — orthogonal-rich.tmj', () => { expect(context.fetchJson).not.toHaveBeenCalled(); }); - it('converts only tile layers (object/image omitted, group flattened)', async () => { + it('runtime.layers contains only tile layers (Spawns → objectLayers, Background → imageLayers, group flattened)', async () => { const map = await loadTiledMap('orthogonal-rich.tmj', context); const runtime = map.toTileMap(); - // Ground + DecorTiles (flattened out of the group); Spawns/Background dropped. + // Ground + DecorTiles (flattened out of the group); object/image layers are separate. expect(runtime.layers.map(l => l.name)).toEqual(['Ground', 'DecorTiles']); }); @@ -205,6 +206,52 @@ describe('TiledMap.toTileMap() — orthogonal-rich.tmj', () => { expect(tile!.localTileId).toBe(0); }); + it('carries map/layer/tileset metadata (class, tint, offset, background, renderorder, draworder)', async () => { + const map = await loadTiledMap('orthogonal-rich.tmj', context); + const runtime = map.toTileMap(); + + // Map level. + expect(runtime.class).toBe('level'); + expect(runtime.backgroundColor).toBe(0x223344); + expect(runtime.renderOrder).toBe('right-down'); + + // Tileset level (class + visual tile offset). + const tsA = runtime.getTileset('tiles-a')!; + expect(tsA.class).toBe('terrainSet'); + expect(tsA.offsetX).toBe(2); + expect(tsA.offsetY).toBe(-3); + + // Tile layer level (class + tint colour parsed to 0xRRGGBB). + const ground = runtime.getLayerByName('Ground')!; + expect(ground.class).toBe('terrainLayer'); + expect(ground.tintColor).toBe(0xff8800); + + // Object layer draw order. + const spawns = runtime.getObjectLayer('Spawns')!; + expect(spawns.drawOrder).toBe('index'); + }); + + it('carries per-tile properties and animation frames into the runtime tileset', async () => { + const map = await loadTiledMap('orthogonal-rich.tmj', context); + const runtime = map.toTileMap(); + const tsA = runtime.getTileset('tiles-a')!; + + // Per-tile property carried (previously dropped at conversion). + expect(tsA.getTileDefinition(0)?.properties?.solid).toBe(true); + + // Per-tile animation carried (previously dropped at conversion). + const anim = tsA.getTileDefinition(1)?.animation; + expect(anim).toHaveLength(2); + expect(anim?.[0]).toEqual({ localTileId: 1, duration: 120 }); + expect(anim?.[1]).toEqual({ localTileId: 2, duration: 120 }); + + // Per-tile collision shapes carried from the tile's objectgroup. + const collision = tsA.getTileDefinition(3)?.collision; + expect(collision).toHaveLength(1); + expect(collision?.[0].kind).toBe('rectangle'); + expect(collision?.[0]).toMatchObject({ x: 2, y: 2, width: 12, height: 12 }); + }); + it('is deterministic across repeated calls and does not take texture ownership', async () => { const map = await loadTiledMap('orthogonal-rich.tmj', context); const first = map.toTileMap(); @@ -313,7 +360,7 @@ describe('TiledMap.toTileMap() — object layers', () => { expect(runtime.objectLayers).toHaveLength(1); const layer = runtime.getObjectLayer('Spawns'); expect(layer).toBeDefined(); - expect(layer?.objects).toHaveLength(2); + expect(layer?.objects).toHaveLength(3); }); it('maps a point object and a gid object to the right kinds with resolved tiles', async () => { @@ -339,4 +386,96 @@ describe('TiledMap.toTileMap() — object layers', () => { expect(layer?.query({ type: 'spawn' })).toHaveLength(1); expect(layer?.query({ kind: 'tile' })).toHaveLength(1); }); + + it('converts a text object to kind "text" with mapped TextStyle fields', async () => { + const runtime = (await loadTiledMap('orthogonal-rich.tmj', context)).toTileMap(); + const layer = runtime.getObjectLayer('Spawns'); + + const sign = layer?.getObjectByName('sign'); + expect(sign?.kind).toBe('text'); + if (sign?.kind === 'text') { + expect(sign.text.text).toBe('Hello'); + expect(sign.text.color).toBe(0xff0000); + expect(sign.text.bold).toBe(true); + expect(sign.text.pixelSize).toBe(12); + expect(sign.text.wrap).toBe(true); + } + }); +}); + +describe('TiledMap.toTileMap() — image layers', () => { + const { context } = makeContext(richFixtures); + + it('converts an imagelayer into a data-only ImageLayer with texture pre-loaded', async () => { + const tiled = await loadTiledMap('orthogonal-rich.tmj', context); + const runtime = tiled.toTileMap(); + + expect(runtime.imageLayers).toHaveLength(1); + + const layer = runtime.getImageLayer('Background'); + expect(layer).toBeDefined(); + expect(layer?.image).toContain('bg.png'); + expect(layer?.opacity).toBe(1); + expect(layer?.repeatX).toBe(true); + expect(layer?.repeatY).toBe(false); + expect(layer?.texture).not.toBeNull(); + }); +}); + +// ── Parallax forwarding ────────────────────────────────────────────────────── + +describe('TiledMap.toTileMap() — parallax forwarding', () => { + const baseTileset = { + firstgid: 1, name: 'tiles', image: 'tiles-a.png', imagewidth: 64, imageheight: 32, + tilewidth: 16, tileheight: 16, columns: 4, tilecount: 8, + }; + + it('forwards parallaxX and parallaxY from Tiled layer data to runtime TileLayer', async () => { + const { context } = makeContext({ + 'parallax.tmj': { + type: 'map', version: '1.10', orientation: 'orthogonal', + width: 2, height: 1, tilewidth: 16, tileheight: 16, infinite: false, + layers: [ + { + id: 1, name: 'Background', type: 'tilelayer', + visible: true, opacity: 1, x: 0, y: 0, + width: 2, height: 1, data: [1, 1], + parallaxx: 0.5, parallaxy: 0.25, + }, + ], + tilesets: [baseTileset], + }, + }); + const tiled = await loadTiledMap('parallax.tmj', context); + const runtime = tiled.toTileMap(); + + const layer = runtime.getLayerByName('Background')!; + expect(layer).toBeDefined(); + expect(layer.parallaxX).toBe(0.5); + expect(layer.parallaxY).toBe(0.25); + }); + + it('defaults parallaxX and parallaxY to 1.0 when absent from Tiled data', async () => { + const { context } = makeContext({ + 'no-parallax.tmj': { + type: 'map', version: '1.10', orientation: 'orthogonal', + width: 2, height: 1, tilewidth: 16, tileheight: 16, infinite: false, + layers: [ + { + id: 1, name: 'Ground', type: 'tilelayer', + visible: true, opacity: 1, x: 0, y: 0, + width: 2, height: 1, data: [1, 1], + }, + ], + tilesets: [baseTileset], + }, + }); + const tiled = await loadTiledMap('no-parallax.tmj', context); + const runtime = tiled.toTileMap(); + + const layer = runtime.getLayerByName('Ground')!; + expect(layer).toBeDefined(); + expect(layer.parallaxX).toBe(1); + expect(layer.parallaxY).toBe(1); + }); }); diff --git a/packages/exojs-tiled/test/wangSets.test.ts b/packages/exojs-tiled/test/wangSets.test.ts new file mode 100644 index 00000000..6ecd5bcc --- /dev/null +++ b/packages/exojs-tiled/test/wangSets.test.ts @@ -0,0 +1,146 @@ +import { WangSet } from '@codexo/exojs-tilemap'; +import { describe, expect, it } from 'vitest'; + +import type { TiledWangSetData } from '../src/data'; +import { tiledWangSetToWangSet } from '../src/wangSets'; + +// ── WangSet blob bit layout (from WangSet.ts) ──────────────────────────────── +// Bit 0 (1): Top-left +// Bit 1 (2): Top +// Bit 2 (4): Top-right +// Bit 3 (8): Left +// Bit 4 (16): Right +// Bit 5 (32): Bottom-left +// Bit 6 (64): Bottom +// Bit 7 (128): Bottom-right +// +// Tiled wangid layout (indices 0-7): +// [top(0), topright(1), right(2), bottomright(3), bottom(4), bottomleft(5), left(6), topleft(7)] + +// Corner type: positions 1,3,5,7 carry color indices +// Tiled topleft = wangid[7] → WangSet bit 0 (1) +// Tiled topright = wangid[1] → WangSet bit 2 (4) +// Tiled botleft = wangid[5] → WangSet bit 5 (32) +// Tiled botright = wangid[3] → WangSet bit 7 (128) + +const TILESET_INDEX = 2; + +// Helper to build a minimal TiledWangSetData +function makeWangSet(type: string, wangtiles: { tileid: number; wangid: number[] }[]): TiledWangSetData { + return { + name: 'terrain', + type, + tile: -1, + colors: [{ name: 'grass', color: '#00ff00', tile: 0, probability: 1 }], + wangtiles: wangtiles.map(wt => ({ tileid: wt.tileid, wangid: wt.wangid })), + }; +} + +describe('tiledWangSetToWangSet — corner type', () => { + // Build a corner wangset with 3 tiles: + // tile 10: all corners set → blob mask TL|TR|BL|BR = 1|4|32|128 = 165 + // tile 11: no corners set → blob mask = 0 + // tile 12: TL + TR only → blob mask TL|TR = 1|4 = 5 + const cornerSet = makeWangSet('corner', [ + // top topright right botright bot botleft left topleft + { tileid: 10, wangid: [0, 1, 0, 1, 0, 1, 0, 1] }, // all corners + { tileid: 11, wangid: [0, 0, 0, 0, 0, 0, 0, 0] }, // no corners + { tileid: 12, wangid: [0, 1, 0, 0, 0, 0, 0, 1] }, // TL + TR only + ]); + + it('returns a WangSet instance', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX); + expect(result).toBeInstanceOf(WangSet); + }); + + it('sets tilesetIndex correctly', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + expect(result.tilesetIndex).toBe(TILESET_INDEX); + }); + + it('type is "blob"', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + expect(result.type).toBe('blob'); + }); + + it('maps all-corners mask (165) → tile 10', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + // TL(1) | TR(4) | BL(32) | BR(128) = 165 + expect(result.getTileId(165)).toBe(10); + }); + + it('maps no-corners mask (0) → tile 11', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + expect(result.getTileId(0)).toBe(11); + }); + + it('maps TL+TR mask (5) → tile 12', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + // TL(1) | TR(4) = 5 + expect(result.getTileId(5)).toBe(12); + }); + + it('returns undefined for a mask with no mapping', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + // mask 2 (top-only cardinal bit, not used by corner wangsets) → not mapped + expect(result.getTileId(2)).toBeUndefined(); + }); + + it('all mapped tile ids are members', () => { + const result = tiledWangSetToWangSet(cornerSet, TILESET_INDEX)!; + expect(result.isMember(10)).toBe(true); + expect(result.isMember(11)).toBe(true); + expect(result.isMember(12)).toBe(true); + }); +}); + +describe('tiledWangSetToWangSet — edge type', () => { + // Edge wangid positions: top=0, right=2, bottom=4, left=6 + // WangSet edge bits: top=1, right=2, bottom=4, left=8 + // tile 20: all edges → edge mask = 1|2|4|8 = 15 + // tile 21: top only → edge mask = 1 + // tile 22: left+right → edge mask = 2|8 = 10 + const edgeSet = makeWangSet('edge', [ + // top topright right botright bot botleft left topleft + { tileid: 20, wangid: [1, 0, 1, 0, 1, 0, 1, 0] }, // all 4 edges + { tileid: 21, wangid: [1, 0, 0, 0, 0, 0, 0, 0] }, // top only + { tileid: 22, wangid: [0, 0, 1, 0, 0, 0, 1, 0] }, // right + left + ]); + + it('returns a WangSet instance', () => { + expect(tiledWangSetToWangSet(edgeSet, 0)).toBeInstanceOf(WangSet); + }); + + it('type is "edge"', () => { + const result = tiledWangSetToWangSet(edgeSet, 0)!; + expect(result.type).toBe('edge'); + }); + + it('maps all-edges mask (15) → tile 20', () => { + const result = tiledWangSetToWangSet(edgeSet, 0)!; + expect(result.getTileId(15)).toBe(20); + }); + + it('maps top-only mask (1) → tile 21', () => { + const result = tiledWangSetToWangSet(edgeSet, 0)!; + expect(result.getTileId(1)).toBe(21); + }); + + it('maps right+left mask (10) → tile 22', () => { + const result = tiledWangSetToWangSet(edgeSet, 0)!; + // right(2) | left(8) = 10 + expect(result.getTileId(10)).toBe(22); + }); +}); + +describe('tiledWangSetToWangSet — unsupported types', () => { + it('returns null for type "mixed"', () => { + const mixedSet = makeWangSet('mixed', [{ tileid: 0, wangid: [1, 1, 1, 1, 1, 1, 1, 1] }]); + expect(tiledWangSetToWangSet(mixedSet, 0)).toBeNull(); + }); + + it('returns null for an unknown type string', () => { + const unknownSet = makeWangSet('superblob', []); + expect(tiledWangSetToWangSet(unknownSet, 0)).toBeNull(); + }); +}); diff --git a/packages/exojs-tilemap/LICENSE b/packages/exojs-tilemap/LICENSE new file mode 100644 index 00000000..dfb7cd04 --- /dev/null +++ b/packages/exojs-tilemap/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-tilemap/src/ImageLayer.ts b/packages/exojs-tilemap/src/ImageLayer.ts new file mode 100644 index 00000000..b1311195 --- /dev/null +++ b/packages/exojs-tilemap/src/ImageLayer.ts @@ -0,0 +1,95 @@ +import type { Texture } from '@codexo/exojs'; + +/** Construction options for an {@link ImageLayer}. */ +export interface ImageLayerOptions { + /** Layer id (unique within the map). */ + readonly id: number; + /** Layer name. */ + readonly name?: string; + /** Layer class string. */ + readonly class?: string; + /** Resolved image URL. */ + readonly image: string; + /** Loaded texture for the image, or `null` if unavailable. */ + readonly texture?: Texture | null; + /** Whether the layer is visible. Default `true`. */ + readonly visible?: boolean; + /** Layer opacity in `[0, 1]`. Default `1`. */ + readonly opacity?: number; + /** Layer pixel offset X. Default `0`. */ + readonly offsetX?: number; + /** Layer pixel offset Y. Default `0`. */ + readonly offsetY?: number; + /** Horizontal parallax factor. Default `1`. */ + readonly parallaxX?: number; + /** Vertical parallax factor. Default `1`. */ + readonly parallaxY?: number; + /** Tint colour as `0xRRGGBB`, or `null`. Default `null`. */ + readonly tintColor?: number | null; + /** Whether the image repeats horizontally. Default `false`. */ + readonly repeatX?: boolean; + /** Whether the image repeats vertically. Default `false`. */ + readonly repeatY?: boolean; +} + +/** + * A data-only image layer: a single image (texture + resolved URL) placed as a + * background or foreground layer. Image layers are not rendered by the tile + * renderer — they are exposed as data for a renderer or follow-up scene node to + * consume. + * + * Parallax, opacity, tint, offset, and repeat flags are carried from the + * source Tiled map. + * + * @advanced + */ +export class ImageLayer { + /** Layer-kind discriminant. */ + public readonly kind = 'image' as const; + + /** Layer id (unique within the map). */ + public readonly id: number; + /** Layer name. */ + public readonly name: string; + /** Layer class string. */ + public readonly class: string; + /** Resolved URL of the layer image. */ + public readonly image: string; + /** Loaded texture for the image, or `null` if unavailable. */ + public readonly texture: Texture | null; + /** Whether the layer is visible. */ + public readonly visible: boolean; + /** Layer opacity in `[0, 1]`. */ + public readonly opacity: number; + /** Layer pixel offset X. */ + public readonly offsetX: number; + /** Layer pixel offset Y. */ + public readonly offsetY: number; + /** Horizontal parallax factor. */ + public readonly parallaxX: number; + /** Vertical parallax factor. */ + public readonly parallaxY: number; + /** Tint colour as `0xRRGGBB`, or `null`. */ + public readonly tintColor: number | null; + /** Whether the image repeats horizontally. */ + public readonly repeatX: boolean; + /** Whether the image repeats vertically. */ + public readonly repeatY: boolean; + + public constructor(options: ImageLayerOptions) { + this.id = options.id; + this.name = options.name ?? ''; + this.class = options.class ?? ''; + this.image = options.image; + this.texture = options.texture ?? null; + this.visible = options.visible ?? true; + this.opacity = options.opacity ?? 1; + this.offsetX = options.offsetX ?? 0; + this.offsetY = options.offsetY ?? 0; + this.parallaxX = options.parallaxX ?? 1; + this.parallaxY = options.parallaxY ?? 1; + this.tintColor = options.tintColor ?? null; + this.repeatX = options.repeatX ?? false; + this.repeatY = options.repeatY ?? false; + } +} diff --git a/packages/exojs-tilemap/src/ObjectLayer.ts b/packages/exojs-tilemap/src/ObjectLayer.ts index 51197cb9..643a18ca 100644 --- a/packages/exojs-tilemap/src/ObjectLayer.ts +++ b/packages/exojs-tilemap/src/ObjectLayer.ts @@ -17,6 +17,7 @@ export const ObjectKind = { Polyline: 'polyline', Point: 'point', Tile: 'tile', + Text: 'text', } as const; /** Geometry discriminant for a {@link TileMapObject}. */ @@ -92,10 +93,42 @@ export interface TileObject

extends T readonly tile: ResolvedTile; } +/** Text styling options carried by a {@link TextObject}. */ +export interface TextStyle { + /** The text content. */ + readonly text: string; + /** Text colour as 0xRRGGBB, or `null` if default. */ + readonly color?: number | null; + /** Font family name. */ + readonly fontFamily?: string; + /** Font size in pixels. */ + readonly pixelSize?: number; + /** Bold text. */ + readonly bold?: boolean; + /** Italic text. */ + readonly italic?: boolean; + /** Underline text. */ + readonly underline?: boolean; + /** Strikeout text. */ + readonly strikeout?: boolean; + /** Wrap text within the object bounds. */ + readonly wrap?: boolean; + /** Horizontal alignment. */ + readonly halign?: 'left' | 'center' | 'right' | 'justify'; + /** Vertical alignment. */ + readonly valign?: 'top' | 'center' | 'bottom'; +} + +/** A text object carrying styled text content within `[x, y, width, height]`. */ +export interface TextObject

extends TileMapObjectBase

{ + readonly kind: typeof ObjectKind.Text; + readonly text: TextStyle; +} + /** * A format-independent map object. The geometry kind is the discriminant; - * narrow on `kind` to read shape-specific fields. Text objects and templates - * are intentionally not represented (data-only release). + * narrow on `kind` to read shape-specific fields. Template objects are not + * yet represented. * * The optional property-shape parameter `P` lets typed accessors * (see {@link ObjectLayer.byType}) narrow `properties` to a developer-declared @@ -109,7 +142,8 @@ export type TileMapObject

= | PointObject

| PolygonObject

| PolylineObject

- | TileObject

; + | TileObject

+ | TextObject

; /** * A developer-declared mapping from object `type`/class strings to the property @@ -176,6 +210,12 @@ export interface ObjectLayerOptions { readonly offsetX?: number; /** Layer pixel offset Y. Default `0`. */ readonly offsetY?: number; + /** + * Draw order for the objects (Tiled `draworder`): `'topdown'` (sorted by `y`) + * or `'index'` (source order). Informational; objects are stored as given. + * Default `'topdown'`. + */ + readonly drawOrder?: 'topdown' | 'index'; /** The objects in this layer. */ readonly objects?: readonly TileMapObject[]; /** Layer-level properties (copied and frozen). */ @@ -223,6 +263,8 @@ export class ObjectLayer { public readonly offsetX: number; /** Layer pixel offset Y. */ public readonly offsetY: number; + /** Draw order for the objects (Tiled `draworder`). Informational. */ + public readonly drawOrder: 'topdown' | 'index'; /** Immutable layer-level properties. */ public readonly properties: TileProperties; /** The objects in this layer (insertion order). */ @@ -236,6 +278,7 @@ export class ObjectLayer { this.opacity = options.opacity ?? 1; this.offsetX = options.offsetX ?? 0; this.offsetY = options.offsetY ?? 0; + this.drawOrder = options.drawOrder ?? 'topdown'; this.properties = options.properties ? Object.freeze({ ...options.properties }) : Object.freeze({}); this.objects = options.objects ? Object.freeze([...options.objects]) : Object.freeze([]); } diff --git a/packages/exojs-tilemap/src/TileAnimator.ts b/packages/exojs-tilemap/src/TileAnimator.ts new file mode 100644 index 00000000..4ab09d50 --- /dev/null +++ b/packages/exojs-tilemap/src/TileAnimator.ts @@ -0,0 +1,202 @@ +import type { TileLayer } from './TileLayer'; +import type { TileSet } from './TileSet'; +import type { TileAnimationFrame, TileTransform } from './types'; + +/** Internal per-cell animation record. */ +interface AnimatedCell { + readonly layer: TileLayer; + readonly tx: number; + readonly ty: number; + readonly tileset: TileSet; + readonly transform: TileTransform; + readonly frames: readonly TileAnimationFrame[]; + /** Cumulative end-time (ms) of each frame, so `cumulative.at(-1) === totalMs`. */ + readonly cumulative: readonly number[]; + readonly totalMs: number; + /** Index of the frame currently written into the layer (-1 = none yet). */ + currentFrame: number; +} + +/** + * Drives per-tile animations on one or more {@link TileLayer}s, RPG-Maker style. + * + * On construction it scans the given layer(s) once and registers every cell + * whose tile carries an `animation` (see {@link import('./types').TileDefinition}). + * Each {@link update} advances a shared clock and rewrites **only** the + * registered animated cells, and **only** when a cell crosses a frame boundary — + * static tiles are never touched. Because a tile rewrite goes through + * `layer.setTileAt`, only the chunks that actually contain animated cells rebuild + * their geometry, and only on the (infrequent) frames where a boundary is + * crossed. The large static body of the map never rebuilds. + * + * Tick it from your update loop, like `TweenSequencer`: + * + * ```ts + * const animator = new TileAnimator(map.layers); + * scene.systems.add({ update: (t) => animator.update(t.deltaSeconds) }); + * ``` + * + * The animator references — but never owns — the layers and their tilesets; + * {@link destroy} only drops its own cell registry. + * + * @advanced + */ +export class TileAnimator { + private readonly _layers: readonly TileLayer[]; + private _cells: AnimatedCell[] = []; + private _elapsedMs = 0; + + /** + * @param layers One layer, or an array of layers (e.g. `map.layers`), to scan + * for animated tiles. + */ + public constructor(layers: TileLayer | readonly TileLayer[]) { + this._layers = Array.isArray(layers) ? layers : [layers as TileLayer]; + this._scan(); + } + + /** Number of animated cells currently registered across all layers. */ + public get animatedCellCount(): number { + return this._cells.length; + } + + /** Total elapsed animation time in milliseconds since construction/reset. */ + public get elapsedMs(): number { + return this._elapsedMs; + } + + /** + * Advance all registered animations by `deltaSeconds` and write the current + * frame into any cell that crossed a frame boundary. Cells that did not change + * frame are not touched, so their chunks do not rebuild. + * + * @param deltaSeconds Elapsed wall-clock time since the last call, in seconds. + */ + public update(deltaSeconds: number): void { + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0 || this._cells.length === 0) { + return; + } + + this._elapsedMs += deltaSeconds * 1000; + + for (const cell of this._cells) { + const t = this._elapsedMs % cell.totalMs; + const frameIndex = frameIndexAt(cell.cumulative, t); + if (frameIndex === cell.currentFrame) { + continue; + } + cell.currentFrame = frameIndex; + const frame = cell.frames[frameIndex]; + if (frame === undefined) { + continue; + } + cell.layer.setTileAt(cell.tx, cell.ty, { + tileset: cell.tileset, + localTileId: frame.localTileId, + transform: cell.transform, + }); + } + } + + /** + * Restore every animated cell to its first frame and reset the clock. + * Useful before serialising or pausing. + */ + public reset(): void { + this._elapsedMs = 0; + for (const cell of this._cells) { + const frame = cell.frames[0]; + if (frame === undefined) { + continue; + } + cell.currentFrame = 0; + cell.layer.setTileAt(cell.tx, cell.ty, { + tileset: cell.tileset, + localTileId: frame.localTileId, + transform: cell.transform, + }); + } + } + + /** + * Re-scan the layers for animated cells. Call after structural edits that add + * or remove animated tiles. Resets all currently-tracked cells to frame 0 + * first so the rescan starts from a clean, deterministic state. + */ + public rescan(): void { + this.reset(); + this._scan(); + } + + /** Drop the cell registry. Does not modify the layers or tilesets. */ + public destroy(): void { + this._cells = []; + this._elapsedMs = 0; + } + + /** Scan all layers for cells whose tile carries a (multi-frame) animation. */ + private _scan(): void { + const cells: AnimatedCell[] = []; + + for (const layer of this._layers) { + for (let ty = 0; ty < layer.height; ty++) { + for (let tx = 0; tx < layer.width; tx++) { + const tile = layer.getTileAt(tx, ty); + if (!tile) { + continue; + } + + const def = tile.tileset.getTileDefinition(tile.localTileId); + const frames = def?.animation; + if (!frames || frames.length < 2) { + continue; + } + + // Skip animations referencing out-of-range frames so update() never + // throws from setTileAt; such data is malformed and silently ignored. + if (frames.some(f => f.localTileId < 0 || f.localTileId >= tile.tileset.tileCount)) { + continue; + } + + const cumulative: number[] = []; + let total = 0; + for (const frame of frames) { + total += Math.max(0, frame.duration); + cumulative.push(total); + } + if (total <= 0) { + continue; + } + + cells.push({ + layer, + tx, + ty, + tileset: tile.tileset, + transform: tile.transform, + frames, + cumulative, + totalMs: total, + currentFrame: -1, + }); + } + } + } + + this._cells = cells; + } +} + +/** + * Find the frame index whose cumulative window contains time `t` (0 ≤ t < total). + * `cumulative[i]` is the end time of frame `i`; the last entry equals the total. + */ +function frameIndexAt(cumulative: readonly number[], t: number): number { + for (let i = 0; i < cumulative.length; i++) { + const end = cumulative[i]; + if (end !== undefined && t < end) { + return i; + } + } + return cumulative.length - 1; +} diff --git a/packages/exojs-tilemap/src/TileLayer.ts b/packages/exojs-tilemap/src/TileLayer.ts index 8e338bab..d13bb7f1 100644 --- a/packages/exojs-tilemap/src/TileLayer.ts +++ b/packages/exojs-tilemap/src/TileLayer.ts @@ -43,6 +43,23 @@ export interface TileLayerOptions { readonly offsetX?: number; /** Layer pixel offset Y. Default 0. */ readonly offsetY?: number; + /** + * Parallax scroll factor on the X axis. `1.0` = full camera speed (normal), + * `0.5` = half speed (farther away), `0.0` = stationary. Default 1. + */ + readonly parallaxX?: number; + /** + * Parallax scroll factor on the Y axis. `1.0` = full camera speed (normal), + * `0.5` = half speed (farther away), `0.0` = stationary. Default 1. + */ + readonly parallaxY?: number; + /** Layer class/type string (Tiled `class`). Defaults to `''`. */ + readonly class?: string; + /** + * Multiplicative layer tint as a `0xRRGGBB` integer, or `null` for none + * (Tiled `tintcolor`). Applied to every chunk's render tint. Default `null`. + */ + readonly tintColor?: number | null; /** Layer properties (copied and frozen). */ readonly properties?: TileProperties; } @@ -101,6 +118,21 @@ export class TileLayer { public offsetX: number; /** Vertical pixel offset (mutable). */ public offsetY: number; + /** + * Parallax scroll factor on the X axis. + * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary. + */ + public readonly parallaxX: number; + /** + * Parallax scroll factor on the Y axis. + * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary. + */ + public readonly parallaxY: number; + + /** Layer class/type string (Tiled `class`; may be empty). */ + public readonly class: string; + /** Multiplicative layer tint as `0xRRGGBB`, or `null` for no tint. */ + public readonly tintColor: number | null; /** Immutable layer properties. */ public readonly properties: TileProperties; @@ -124,6 +156,7 @@ export class TileLayer { /** * @throws When dimensions, chunk size, or other options are invalid. */ + // eslint-disable-next-line complexity -- straight-line option validation + defaulting public constructor(options: TileLayerOptions) { validateNonNegativeInteger(options.id, 'layer.id'); if (!options.name || typeof options.name !== 'string') { @@ -154,6 +187,12 @@ export class TileLayer { throw new Error('TileLayer offset must be finite numbers.'); } + const parallaxX = options.parallaxX ?? 1; + const parallaxY = options.parallaxY ?? 1; + if (!Number.isFinite(parallaxX) || !Number.isFinite(parallaxY)) { + throw new Error('TileLayer parallax must be finite numbers.'); + } + this.id = options.id; this.name = options.name; this.width = options.width; @@ -167,6 +206,10 @@ export class TileLayer { this.opacity = opacity; this.offsetX = offsetX; this.offsetY = offsetY; + this.parallaxX = parallaxX; + this.parallaxY = parallaxY; + this.class = options.class ?? ''; + this.tintColor = options.tintColor ?? null; this.properties = options.properties ? Object.freeze({ ...options.properties }) : Object.freeze({}); diff --git a/packages/exojs-tilemap/src/TileLayerNode.ts b/packages/exojs-tilemap/src/TileLayerNode.ts index df0f7c06..a0f84041 100644 --- a/packages/exojs-tilemap/src/TileLayerNode.ts +++ b/packages/exojs-tilemap/src/TileLayerNode.ts @@ -45,13 +45,18 @@ export class TileLayerNode extends Container { private readonly _cullChunks: boolean; private readonly _chunkNodes: TileChunkNode[] = []; private _syncedOpacity = -1; + private _syncedTint: number | null | undefined = undefined; private _pixelSnapMode: PixelSnapMode = 'none'; + private readonly _baseOffsetX: number; + private readonly _baseOffsetY: number; public constructor(layer: TileLayer, options?: TileLayerNodeOptions) { super(); this._layer = layer; this._cullChunks = options?.cullable ?? true; + this._baseOffsetX = layer.offsetX; + this._baseOffsetY = layer.offsetY; this.setPosition(layer.offsetX, layer.offsetY); this._buildChunkNodes(); @@ -111,6 +116,7 @@ export class TileLayerNode extends Container { } this._syncedOpacity = -1; + this._syncedTint = undefined; this._buildChunkNodes(); return this; @@ -135,9 +141,25 @@ export class TileLayerNode extends Container { return; } - this._syncOpacity(); + this._syncTint(); - super._collectContent(builder); + const layer = this._layer; + + if (layer.parallaxX !== 1 || layer.parallaxY !== 1) { + const camCenter = builder.view.center; + const prevX = this.x; + const prevY = this.y; + + this.x = this._baseOffsetX + camCenter.x * (1 - layer.parallaxX); + this.y = this._baseOffsetY + camCenter.y * (1 - layer.parallaxY); + + super._collectContent(builder); + + this.x = prevX; + this.y = prevY; + } else { + super._collectContent(builder); + } } public override destroy(): void { @@ -179,21 +201,31 @@ export class TileLayerNode extends Container { this.addChild(node); } - this._syncOpacity(); + this._syncTint(); } - /** Propagate the layer's live opacity onto the chunk tints if it changed. */ - private _syncOpacity(): void { + /** + * Propagate the layer's live opacity and tint colour onto the chunk render + * tints if either changed. Opacity drives the tint alpha; `tintColor` + * (`0xRRGGBB`) multiplies the RGB (white = no tint). + */ + private _syncTint(): void { const opacity = this._layer.opacity; + const tintColor = this._layer.tintColor; - if (opacity === this._syncedOpacity) { + if (opacity === this._syncedOpacity && tintColor === this._syncedTint) { return; } + const r = tintColor === null ? 255 : (tintColor >> 16) & 0xff; + const g = tintColor === null ? 255 : (tintColor >> 8) & 0xff; + const b = tintColor === null ? 255 : tintColor & 0xff; + for (const child of this._chunkNodes) { - child.tint.a = opacity; + child.tint.set(r, g, b, opacity); } this._syncedOpacity = opacity; + this._syncedTint = tintColor; } } diff --git a/packages/exojs-tilemap/src/TileMap.ts b/packages/exojs-tilemap/src/TileMap.ts index 7d503bd9..1011d86d 100644 --- a/packages/exojs-tilemap/src/TileMap.ts +++ b/packages/exojs-tilemap/src/TileMap.ts @@ -1,3 +1,4 @@ +import { type ImageLayer } from './ImageLayer'; import type { ObjectLayer, ObjectSchema } from './ObjectLayer'; import { type TileLayer } from './TileLayer'; import type { TileMapViewOptions } from './TileMapView'; @@ -27,10 +28,22 @@ export interface TileMapOptions { readonly layers?: readonly TileLayer[]; /** Object layers (data-only; spawn points, triggers, collision regions). */ readonly objectLayers?: readonly ObjectLayer[]; + /** Image layers (data-only; background/foreground images from Tiled image layers). */ + readonly imageLayers?: readonly ImageLayer[]; /** Chunk width for layers (default 32). */ readonly chunkWidth?: number; /** Chunk height for layers (default 32). */ readonly chunkHeight?: number; + /** Map class/type string (Tiled `class`). Defaults to `''`. */ + readonly class?: string; + /** + * Map background colour as a `0xRRGGBB` integer, or `null` (Tiled + * `backgroundcolor`). Informational — the renderer does not auto-clear to it. + * Default `null`. + */ + readonly backgroundColor?: number | null; + /** Tile draw order (Tiled `renderorder`), informational. Default `'right-down'`. */ + readonly renderOrder?: string; /** Map-level properties (copied and frozen). */ readonly properties?: TileProperties; } @@ -73,6 +86,13 @@ export class TileMap { /** Default chunk height for layers. */ public readonly chunkHeight: number; + /** Map class/type string (Tiled `class`; may be empty). */ + public readonly class: string; + /** Map background colour as `0xRRGGBB`, or `null`. Informational. */ + public readonly backgroundColor: number | null; + /** Tile draw order (Tiled `renderorder`). Informational. */ + public readonly renderOrder: string; + /** Map-level properties (immutable). */ public readonly properties: TileProperties; @@ -80,6 +100,7 @@ export class TileMap { private readonly _layers: TileLayer[] = []; private readonly _layerById = new Map(); private readonly _objectLayers: ObjectLayer[] = []; + private readonly _imageLayers: ImageLayer[] = []; private _revision = 0; private _destroyed = false; @@ -105,6 +126,9 @@ export class TileMap { this.tileHeight = options.tileHeight; this.chunkWidth = chunkWidth; this.chunkHeight = chunkHeight; + this.class = options.class ?? ''; + this.backgroundColor = options.backgroundColor ?? null; + this.renderOrder = options.renderOrder ?? 'right-down'; this._tilesets = options.tilesets ? [...options.tilesets] : []; this.properties = options.properties @@ -120,6 +144,10 @@ export class TileMap { if (options.objectLayers) { this._objectLayers.push(...options.objectLayers); } + + if (options.imageLayers) { + this._imageLayers.push(...options.imageLayers); + } } // ── Tilesets ────────────────────────────────────────────────────────── @@ -243,6 +271,20 @@ export class TileMap { return this._objectLayers.find(layer => layer.name === name) as ObjectLayer | undefined; } + // ── Image layers (data-only) ────────────────────────────────────────── + + /** Immutable snapshot of image layers (insertion order). */ + public get imageLayers(): readonly ImageLayer[] { + return this._imageLayers; + } + + /** + * Get an image layer by name (first match in insertion order), or undefined. + */ + public getImageLayer(name: string): ImageLayer | undefined { + return this._imageLayers.find(layer => layer.name === name); + } + // ── Scene composition ───────────────────────────────────────────────── /** @@ -360,6 +402,7 @@ export class TileMap { this._layers.length = 0; this._layerById.clear(); this._objectLayers.length = 0; + this._imageLayers.length = 0; this._tilesets.length = 0; } } diff --git a/packages/exojs-tilemap/src/TileSet.ts b/packages/exojs-tilemap/src/TileSet.ts index 301ff891..51a42a1e 100644 --- a/packages/exojs-tilemap/src/TileSet.ts +++ b/packages/exojs-tilemap/src/TileSet.ts @@ -24,6 +24,12 @@ export interface TileSetOptions { readonly spacing?: number; /** Pixel margin around the atlas. Defaults to 0. */ readonly margin?: number; + /** Tileset class/type string (Tiled `class`). Defaults to `''`. */ + readonly class?: string; + /** Visual drawing offset in pixels applied to every tile of this set. Defaults to 0. */ + readonly offsetX?: number; + /** Visual drawing offset in pixels applied to every tile of this set. Defaults to 0. */ + readonly offsetY?: number; } /** @@ -62,6 +68,13 @@ export class TileSet { /** Pixel margin around the atlas. */ public readonly margin: number; + /** Tileset class/type string (Tiled `class`; may be empty). */ + public readonly class: string; + /** Visual drawing offset X in pixels, applied to every tile of this set. */ + public readonly offsetX: number; + /** Visual drawing offset Y in pixels, applied to every tile of this set. */ + public readonly offsetY: number; + private readonly _definitions: ReadonlyMap; /** @@ -119,6 +132,9 @@ export class TileSet { this.rows = rows; this.spacing = spacing; this.margin = margin; + this.class = options.class ?? ''; + this.offsetX = options.offsetX ?? 0; + this.offsetY = options.offsetY ?? 0; this._definitions = new Map(); @@ -141,9 +157,17 @@ export class TileSet { const props = definition.properties ? Object.freeze({ ...definition.properties }) : undefined; + const animation = definition.animation + ? Object.freeze(definition.animation.map(frame => Object.freeze({ ...frame }))) + : undefined; + const collision = definition.collision + ? Object.freeze([...definition.collision]) + : undefined; (this._definitions as Map).set(localTileId, { localTileId, ...(props !== undefined && { properties: props }), + ...(animation !== undefined && { animation }), + ...(collision !== undefined && { collision }), }); } @@ -159,7 +183,18 @@ export class TileSet { const props = def.properties ? Object.freeze({ ...def.properties }) : undefined; - map.set(def.localTileId, { localTileId: def.localTileId, ...(props !== undefined && { properties: props }) }); + const animation = def.animation + ? Object.freeze(def.animation.map(frame => Object.freeze({ ...frame }))) + : undefined; + const collision = def.collision + ? Object.freeze([...def.collision]) + : undefined; + map.set(def.localTileId, { + localTileId: def.localTileId, + ...(props !== undefined && { properties: props }), + ...(animation !== undefined && { animation }), + ...(collision !== undefined && { collision }), + }); } } diff --git a/packages/exojs-tilemap/src/WangSet.ts b/packages/exojs-tilemap/src/WangSet.ts new file mode 100644 index 00000000..4ed2302d --- /dev/null +++ b/packages/exojs-tilemap/src/WangSet.ts @@ -0,0 +1,117 @@ +/** + * Options for constructing a {@link WangSet}. + */ +export interface WangSetOptions { + /** Which TileSet contains the Wang tiles (index into the layer's tilesets array). */ + tilesetIndex: number; + /** + * Map from Wang bitmask to local tile ID within the tileset. + * + * For blob mode (8-neighbor), keys are 0–255; the 47 valid combinations + * of the blob encoding must be covered at minimum. + * For edge mode (4-neighbor), keys are 0–15. + * + * Accepts either a {@link ReadonlyMap} or a plain `Record`. + */ + blobMap: ReadonlyMap | Record; + /** Whether this is a blob (8-neighbor, default) or edge (4-neighbor) Wang set. */ + type?: 'blob' | 'edge'; + /** + * Extra local tile IDs that count as belonging to this Wang group, beyond + * the variant IDs already present as {@link blobMap} *values*. + * + * Membership is what {@link autoTile} / `refreshCell` use to decide whether a + * neighbouring cell is "part of the terrain". Because every value in + * `blobMap` is itself a member, group membership stays stable no matter which + * variant a cell currently shows — that is what makes incremental + * `refreshCell` correct. Add the *base* tile ID you paint with here if it is + * not already one of the blobMap variants. + */ + members?: Iterable; +} + +/** + * Describes a Wang autotile set: a mapping from a neighbor bitmask to a + * local tile ID within a specific tileset. + * + * Blob bitmask bit layout (powers of 2): + * - Bit 0 (1): Top-left + * - Bit 1 (2): Top + * - Bit 2 (4): Top-right + * - Bit 3 (8): Left + * - Bit 4 (16): Right + * - Bit 5 (32): Bottom-left + * - Bit 6 (64): Bottom + * - Bit 7 (128): Bottom-right + * + * Diagonal (corner) bits are only set when both adjacent cardinal directions + * are also set — reducing 256 raw combinations to 47 meaningful blob states. + * + * Edge bitmask bit layout: + * - Bit 0 (1): Top + * - Bit 1 (2): Right + * - Bit 2 (4): Bottom + * - Bit 3 (8): Left + */ +export class WangSet { + /** Index of the tileset that contains the Wang tiles. */ + public readonly tilesetIndex: number; + + /** The Wang mode: `'blob'` (8-neighbor) or `'edge'` (4-neighbor). */ + public readonly type: 'blob' | 'edge'; + + private readonly _map: ReadonlyMap; + private readonly _members: ReadonlySet; + + public constructor(options: WangSetOptions) { + this.tilesetIndex = options.tilesetIndex; + this.type = options.type ?? 'blob'; + + if (options.blobMap instanceof Map) { + this._map = options.blobMap; + } else { + const map = new Map(); + for (const [k, v] of Object.entries(options.blobMap as Record)) { + map.set(Number(k), v); + } + this._map = map; + } + + // Members default to every variant tile ID the set can produce, so that any + // autotiled variant is recognised as part of the group (variant-stable + // membership). Explicit `members` (e.g. the base paint ID) are added on top. + const members = new Set(this._map.values()); + if (options.members) { + for (const id of options.members) members.add(id); + } + this._members = members; + } + + /** + * Look up the local tile ID for a given neighbor bitmask. + * Returns `undefined` if the mask has no mapping in this set. + */ + public getTileId(mask: number): number | undefined { + return this._map.get(mask); + } + + /** + * Whether a local tile ID belongs to this Wang group. True for every variant + * the set produces plus any explicit {@link WangSetOptions.members}. Used as + * the default, variant-stable neighbour-membership test by {@link autoTile} + * and `refreshCell`. + */ + public isMember(localTileId: number): boolean { + return this._members.has(localTileId); + } + + /** Read-only view of the full bitmask → tile-ID mapping. */ + public get blobMap(): ReadonlyMap { + return this._map; + } + + /** Read-only view of the local tile IDs that count as group members. */ + public get members(): ReadonlySet { + return this._members; + } +} diff --git a/packages/exojs-tilemap/src/autoTile.ts b/packages/exojs-tilemap/src/autoTile.ts new file mode 100644 index 00000000..e7990cd9 --- /dev/null +++ b/packages/exojs-tilemap/src/autoTile.ts @@ -0,0 +1,265 @@ +import type { TileLayer } from './TileLayer'; +import { TILE_TRANSFORM_IDENTITY, unpackTile } from './types'; +import type { WangSet } from './WangSet'; + +/** + * Options for {@link autoTile} and {@link refreshCell}. + */ +export interface AutoTileOptions { + /** + * When provided, only cells for which `matchFn` returns `true` are treated + * as part of the Wang group. The function receives the cell's `localTileId`, + * `tilesetIndex`, and tile coordinates `(x, y)`. + * + * If omitted, group membership defaults to {@link WangSet.isMember} on the + * cell's `localTileId` (and a matching `tilesetIndex`). That default is + * *variant-stable* — every autotiled variant counts as a member — which is + * what makes {@link refreshCell} correct. If you supply a `matchFn` for use + * with `refreshCell`, it MUST likewise be variant-stable (independent of the + * currently-rendered variant), e.g. keyed on a separate logical-terrain grid + * or a tile property. + */ + matchFn?: (localTileId: number, tilesetIndex: number, x: number, y: number) => boolean; + /** + * When `true` (default), out-of-bounds neighbors are treated as though + * they belong to the Wang group, so border tiles fill correctly to the + * layer edge. Set to `false` to leave border tiles visually open. + */ + wrapBorder?: boolean; +} + +// ── Module-level mask helpers ───────────────────────────────────────────── + +/** + * Compute a 4-bit edge bitmask for the cell at `(tx, ty)`. + * Top=1, Right=2, Bottom=4, Left=8. + */ +function computeEdgeMask( + tx: number, + ty: number, + inGroup: (nx: number, ny: number) => boolean, +): number { + let mask = 0; + if (inGroup(tx, ty - 1)) mask |= 1; + if (inGroup(tx + 1, ty)) mask |= 2; + if (inGroup(tx, ty + 1)) mask |= 4; + if (inGroup(tx - 1, ty)) mask |= 8; + return mask; +} + +/** + * Compute an 8-bit blob bitmask for the cell at `(tx, ty)` using the + * corner-dependency rule: diagonal bits are only set when both adjacent + * cardinal directions are also set. + * + * Bit layout: + * ``` + * 1 | 2 | 4 + * 8 | -- | 16 + * 32 | 64 | 128 + * ``` + */ +function computeBlobMask( + tx: number, + ty: number, + inGroup: (nx: number, ny: number) => boolean, +): number { + const top = inGroup(tx, ty - 1); + const right = inGroup(tx + 1, ty); + const bottom = inGroup(tx, ty + 1); + const left = inGroup(tx - 1, ty); + let mask = 0; + if (top) mask |= 2; + if (right) mask |= 16; + if (bottom) mask |= 64; + if (left) mask |= 8; + // Corner bits: only when BOTH adjacent cardinals are set. + if (top && left && inGroup(tx - 1, ty - 1)) mask |= 1; + if (top && right && inGroup(tx + 1, ty - 1)) mask |= 4; + if (bottom && left && inGroup(tx - 1, ty + 1)) mask |= 32; + if (bottom && right && inGroup(tx + 1, ty + 1)) mask |= 128; + return mask; +} + +/** Compute the mask for a cell given the Wang mode and a membership predicate. */ +function computeMask( + wangSet: WangSet, + tx: number, + ty: number, + inGroup: (nx: number, ny: number) => boolean, +): number { + return wangSet.type === 'edge' + ? computeEdgeMask(tx, ty, inGroup) + : computeBlobMask(tx, ty, inGroup); +} + +/** + * Apply the variant computed for cell `(tx, ty)` to `layer`, preserving the + * cell's current orientation transform. No-op if the mask has no mapping or + * the target tileset is missing. + */ +function applyVariant( + layer: TileLayer, + wangSet: WangSet, + tx: number, + ty: number, + inGroup: (nx: number, ny: number) => boolean, +): void { + const newLocalTileId = wangSet.getTileId(computeMask(wangSet, tx, ty, inGroup)); + if (newLocalTileId === undefined) return; + + const tileset = layer.tilesets[wangSet.tilesetIndex]; + if (!tileset) return; + + // Preserve the existing orientation transform if the cell already holds one. + const existing = layer.getTileAt(tx, ty); + layer.setTileAt(tx, ty, { + localTileId: newLocalTileId, + tileset, + transform: existing ? existing.transform : TILE_TRANSFORM_IDENTITY, + }); +} + +// ── Public API ──────────────────────────────────────────────────────────── + +/** + * Apply Wang autotiling to `layer` using `wangSet`. + * + * Iterates every cell in the layer. For each cell that belongs to the Wang + * group, computes a neighbor bitmask and looks up the correct local tile ID + * from {@link WangSet.blobMap}. The layer is mutated in place; cells whose + * computed bitmask has no mapping in the blobMap are left unchanged. + * + * The function performs two passes (snapshot then write) so neighbor tests + * always reflect the pre-call state regardless of processing order. + * + * **Group membership.** With no `matchFn`, a cell belongs to the group when its + * `tilesetIndex` matches {@link WangSet.tilesetIndex} and its `localTileId` is a + * {@link WangSet.isMember member} of the set. Membership covers every variant + * the set can produce, so re-running `autoTile` (or {@link refreshCell}) on + * already-autotiled data is stable. Paint with a tile ID that is a member (a + * blobMap value, or one listed in {@link WangSetOptions.members}). + * + * **Blob mode bitmask (bit positions):** + * ``` + * 1 | 2 | 4 + * 8 | -- | 16 + * 32 | 64 | 128 + * ``` + * Diagonal bits (1, 4, 32, 128) are only set when both adjacent cardinals + * are also set (the "corner dependency" rule that reduces 256 raw + * combinations to 47 valid blob states). + * + * **Edge mode bitmask (bit positions):** Top=1, Right=2, Bottom=4, Left=8. + */ +export function autoTile(layer: TileLayer, wangSet: WangSet, options?: AutoTileOptions): void { + const matchFn = options?.matchFn; + const wrapBorder = options?.wrapBorder ?? true; + const w = layer.width; + const h = layer.height; + + // ── Pass 1: snapshot ───────────────────────────────────────────────── + // Capture each cell's (tilesetIndex, localTileId) before any writes so + // that neighbor membership tests always see pre-mutation state. + + interface CellInfo { + tilesetIndex: number; + localTileId: number; + } + const snapshot = new Map(); + + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const packed = layer.getRawTileAt(tx, ty); + if (packed === 0) continue; + const decoded = unpackTile(packed); + if (!decoded) continue; + snapshot.set(ty * w + tx, decoded); + } + } + + // ── Membership test (reads the snapshot) ───────────────────────────── + + const isInGroup = (nx: number, ny: number): boolean => { + if (nx < 0 || nx >= w || ny < 0 || ny >= h) return wrapBorder; + const cell = snapshot.get(ny * w + nx); + if (!cell) return false; + if (matchFn) return matchFn(cell.localTileId, cell.tilesetIndex, nx, ny); + return cell.tilesetIndex === wangSet.tilesetIndex && wangSet.isMember(cell.localTileId); + }; + + // ── Pass 2: compute masks and write ────────────────────────────────── + + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const cellInfo = snapshot.get(ty * w + tx); + if (!cellInfo) continue; + + // Skip cells that are not part of the group. + const isMember = matchFn + ? matchFn(cellInfo.localTileId, cellInfo.tilesetIndex, tx, ty) + : cellInfo.tilesetIndex === wangSet.tilesetIndex && wangSet.isMember(cellInfo.localTileId); + if (!isMember) continue; + + applyVariant(layer, wangSet, tx, ty, isInGroup); + } + } +} + +/** + * Incrementally re-autotile a single cell and its eight neighbours after an + * edit, instead of re-running {@link autoTile} over the whole layer. + * + * A cell's blob/edge mask depends only on its immediate neighbours, so painting + * or erasing cell `(x, y)` can change the variant of that cell and of the (up + * to) eight cells that have it as a neighbour — but nothing further out. This + * recomputes exactly that 3×3 neighbourhood, touching only the 1–4 chunks it + * spans (and rebuilding geometry only for chunks whose tiles actually change). + * For a paint operation this is O(1) work versus `autoTile`'s O(width·height). + * + * Membership is read live from the layer, so the membership test MUST be + * variant-stable: the default ({@link WangSet.isMember}) is, and any custom + * `matchFn` you pass must be too (see {@link AutoTileOptions.matchFn}). + * + * Typical editor use: write the painted tile with `layer.setTileAt(...)` using + * a member tile ID, then call `refreshCell(layer, x, y, wangSet)`. + * + * @param layer The layer to update in place. + * @param x Tile X of the edited cell. + * @param y Tile Y of the edited cell. + * @param wangSet The Wang set to resolve variants from. + * @param options Membership / border options (shared with {@link autoTile}). + */ +export function refreshCell( + layer: TileLayer, + x: number, + y: number, + wangSet: WangSet, + options?: AutoTileOptions, +): void { + const matchFn = options?.matchFn; + const wrapBorder = options?.wrapBorder ?? true; + const w = layer.width; + const h = layer.height; + + // Live membership test (reads the current layer; variant-stable by default). + const isInGroup = (nx: number, ny: number): boolean => { + if (nx < 0 || nx >= w || ny < 0 || ny >= h) return wrapBorder; + const tile = layer.getTileAt(nx, ny); + if (!tile) return false; + const tsi = layer.tilesets.indexOf(tile.tileset); + if (matchFn) return matchFn(tile.localTileId, tsi, nx, ny); + return tsi === wangSet.tilesetIndex && wangSet.isMember(tile.localTileId); + }; + + // Recompute the edited cell and its eight neighbours. + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const tx = x + dx; + const ty = y + dy; + if (!layer.inBounds(tx, ty)) continue; + if (!isInGroup(tx, ty)) continue; + applyVariant(layer, wangSet, tx, ty, isInGroup); + } + } +} diff --git a/packages/exojs-tilemap/src/chunkGeometry.ts b/packages/exojs-tilemap/src/chunkGeometry.ts index 979872c6..3b04d572 100644 --- a/packages/exojs-tilemap/src/chunkGeometry.ts +++ b/packages/exojs-tilemap/src/chunkGeometry.ts @@ -153,9 +153,10 @@ export function buildChunkPages( const v1 = (sy + rect.height) / textureHeight; // Bottom-left aligned destination (Tiled orthogonal). Uniform tiles - // (rect.height === tileHeight) collapse to a plain cell rect. - const x0 = lx * tileWidth; - const y0 = ly * tileHeight + tileHeight - rect.height; + // (rect.height === tileHeight) collapse to a plain cell rect. The + // tileset's visual draw offset (Tiled `tileoffset`) shifts every tile. + const x0 = lx * tileWidth + tileset.offsetX; + const y0 = ly * tileHeight + tileHeight - rect.height + tileset.offsetY; const x1 = x0 + rect.width; const y1 = y0 + rect.height; diff --git a/packages/exojs-tilemap/src/public.ts b/packages/exojs-tilemap/src/public.ts index d1c8e35d..0bdad520 100644 --- a/packages/exojs-tilemap/src/public.ts +++ b/packages/exojs-tilemap/src/public.ts @@ -3,6 +3,9 @@ export { tilemapExtension } from './tilemapExtension'; export type { Extension } from '@codexo/exojs/extensions'; +// Image layers: data-only background/foreground images from Tiled image layers. +export type { ImageLayerOptions } from './ImageLayer'; +export { ImageLayer } from './ImageLayer'; // Object layers: data-only spawn points / triggers / collision regions. export type { EllipseObject, @@ -14,6 +17,8 @@ export type { PolygonObject, PolylineObject, RectangleObject, + TextObject, + TextStyle, TileMapObject, TileMapObjectKind, TileObject, @@ -49,6 +54,7 @@ export type { ChunkCoord, PackedTile, ResolvedTile, + TileAnimationFrame, TileDefinition, TileProperties, TilePropertyValue, @@ -59,3 +65,10 @@ export { tileToChunkCoord, tileToLocalInChunk, } from './types'; +// Per-tile animation driver (RPG-Maker-style): advances only animated cells. +export { TileAnimator } from './TileAnimator'; +// Wang autotiling: automatic tile selection based on neighbor bitmasks. +export type { AutoTileOptions } from './autoTile'; +export { autoTile, refreshCell } from './autoTile'; +export type { WangSetOptions } from './WangSet'; +export { WangSet } from './WangSet'; diff --git a/packages/exojs-tilemap/src/types.ts b/packages/exojs-tilemap/src/types.ts index 54dbec3b..95480060 100644 --- a/packages/exojs-tilemap/src/types.ts +++ b/packages/exojs-tilemap/src/types.ts @@ -1,3 +1,4 @@ +import type { TileMapObject } from './ObjectLayer'; import type { TileSet } from './TileSet'; // ── Properties ──────────────────────────────────────────────────────────── @@ -174,11 +175,25 @@ export interface ResolvedTile { readonly transform: TileTransform; } +// ── Tile animation ──────────────────────────────────────────────────────── + +/** + * One frame of a tile animation: which local tile to show, and for how long. + * Mirrors Tiled's per-tile animation frame model. + * @advanced + */ +export interface TileAnimationFrame { + /** Local tile ID shown during this frame (within the tile's own tileset). */ + readonly localTileId: number; + /** Frame duration in milliseconds. */ + readonly duration: number; +} + // ── TileDefinition ──────────────────────────────────────────────────────── /** * Optional per-tile metadata in a {@link TileSet}. Sparse — only defined - * tiles carry a definition. May carry animation data in future slices. + * tiles carry a definition. * @advanced */ export interface TileDefinition { @@ -186,6 +201,18 @@ export interface TileDefinition { readonly localTileId: number; /** Tile properties (copied and frozen by the tileset). */ readonly properties?: TileProperties; + /** + * Animation frame sequence for this tile, if animated. The frames are + * driven at runtime by a {@link import('./TileAnimator').TileAnimator}; + * frame[0] is the tile's resting/base frame. + */ + readonly animation?: readonly TileAnimationFrame[]; + /** + * Per-tile collision shapes sourced from the Tiled `objectgroup` on the tile. + * Shapes are in tile-local pixel space (origin = top-left of the tile cell). + * Only present when the source map defines collision geometry for this tile. + */ + readonly collision?: readonly TileMapObject[]; } // ── Chunk coordinate helpers ────────────────────────────────────────────── diff --git a/packages/exojs-tilemap/test/TileAnimator.test.ts b/packages/exojs-tilemap/test/TileAnimator.test.ts new file mode 100644 index 00000000..70020ee0 --- /dev/null +++ b/packages/exojs-tilemap/test/TileAnimator.test.ts @@ -0,0 +1,195 @@ +import { TextureRegion } from '@codexo/exojs'; +import { type Texture } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { TileAnimator } from '../src/TileAnimator'; +import { TileLayer } from '../src/TileLayer'; +import { TileSet } from '../src/TileSet'; +import { TILE_TRANSFORM_IDENTITY } from '../src/types'; + +// ── Test helpers ────────────────────────────────────────────────────────── + +function fakeTexture(): Texture { + return { + destroyed: false, + destroy: () => {}, + height: 512, + label: 'test', + uid: 0, + width: 512, + } as unknown as Texture; +} + +function fakeRegion(): TextureRegion { + return new TextureRegion(fakeTexture(), { height: 512, width: 512, x: 0, y: 0 }); +} + +function makeTileset256(name = 'ts'): TileSet { + return new TileSet({ + columns: 16, + name, + tileCount: 256, + tileHeight: 32, + tileWidth: 32, + texture: fakeRegion(), + }); +} + +function makeLayer(ts: TileSet, w = 3, h = 3): TileLayer { + return new TileLayer({ + height: h, + id: 0, + name: 'layer', + tileHeight: 32, + tileWidth: 32, + tilesets: [ts], + width: w, + }); +} + +function setTile(layer: TileLayer, ts: TileSet, tx: number, ty: number, localTileId = 0): void { + layer.setTileAt(tx, ty, { localTileId, tileset: ts, transform: TILE_TRANSFORM_IDENTITY }); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +describe('TileAnimator', () => { + it('registers only cells whose tile carries a multi-frame animation', () => { + const ts = makeTileset256(); + // Tile 0 is a 2-frame animation: 0 → 1 → (loop). + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const layer = makeLayer(ts, 3, 3); + setTile(layer, ts, 0, 0, 0); // animated + setTile(layer, ts, 1, 1, 5); // static (no definition) + + const animator = new TileAnimator(layer); + expect(animator.animatedCellCount).toBe(1); + }); + + it('advances the animated cell across frame boundaries and loops', () => { + const ts = makeTileset256(); + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const layer = makeLayer(ts, 3, 3); + setTile(layer, ts, 0, 0, 0); + setTile(layer, ts, 1, 1, 5); // static control + + const animator = new TileAnimator(layer); + + // 50ms → frame 0 (window [0,100)). + animator.update(0.05); + expect(layer.getTileAt(0, 0)?.localTileId).toBe(0); + + // +60ms = 110ms → frame 1 (window [100,200)). + animator.update(0.06); + expect(layer.getTileAt(0, 0)?.localTileId).toBe(1); + + // +100ms = 210ms → 210 % 200 = 10 → back to frame 0. + animator.update(0.1); + expect(layer.getTileAt(0, 0)?.localTileId).toBe(0); + + // The static cell was never touched. + expect(layer.getTileAt(1, 1)?.localTileId).toBe(5); + }); + + it('does not rewrite a cell that stays within the same frame window', () => { + const ts = makeTileset256(); + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const layer = makeLayer(ts, 3, 3); + setTile(layer, ts, 0, 0, 0); + + const animator = new TileAnimator(layer); + + // First tick writes frame 0 (from the initial -1 state) → revision bumps. + animator.update(0.02); + const revAfterFirst = layer.revision; + + // Another tick still inside frame 0 → no write, revision unchanged. + animator.update(0.02); + expect(layer.revision).toBe(revAfterFirst); + }); + + it('reset restores frame 0 and zeroes the clock', () => { + const ts = makeTileset256(); + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const layer = makeLayer(ts, 3, 3); + setTile(layer, ts, 0, 0, 0); + + const animator = new TileAnimator(layer); + animator.update(0.15); // → frame 1 + expect(layer.getTileAt(0, 0)?.localTileId).toBe(1); + + animator.reset(); + expect(layer.getTileAt(0, 0)?.localTileId).toBe(0); + expect(animator.elapsedMs).toBe(0); + }); + + it('preserves the placed orientation transform across frames', () => { + const ts = makeTileset256(); + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const layer = makeLayer(ts, 3, 3); + layer.setTileAt(0, 0, { + localTileId: 0, + tileset: ts, + transform: { flipX: true, flipY: false, diagonal: false }, + }); + + const animator = new TileAnimator(layer); + animator.update(0.15); // → frame 1 + + const tile = layer.getTileAt(0, 0); + expect(tile?.localTileId).toBe(1); + expect(tile?.transform.flipX).toBe(true); + }); + + it('accepts multiple layers', () => { + const ts = makeTileset256(); + ts._setDefinition(0, { + animation: [ + { localTileId: 0, duration: 100 }, + { localTileId: 1, duration: 100 }, + ], + }); + + const a = makeLayer(ts, 2, 2); + const b = makeLayer(ts, 2, 2); + setTile(a, ts, 0, 0, 0); + setTile(b, ts, 1, 1, 0); + + const animator = new TileAnimator([a, b]); + expect(animator.animatedCellCount).toBe(2); + + animator.update(0.15); // → frame 1 on both + expect(a.getTileAt(0, 0)?.localTileId).toBe(1); + expect(b.getTileAt(1, 1)?.localTileId).toBe(1); + }); +}); diff --git a/packages/exojs-tilemap/test/autoTile.test.ts b/packages/exojs-tilemap/test/autoTile.test.ts new file mode 100644 index 00000000..77113e46 --- /dev/null +++ b/packages/exojs-tilemap/test/autoTile.test.ts @@ -0,0 +1,367 @@ +import { TextureRegion } from '@codexo/exojs'; +import { type Texture } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { autoTile, refreshCell } from '../src/autoTile'; +import { TileLayer } from '../src/TileLayer'; +import { TileSet } from '../src/TileSet'; +import { TILE_TRANSFORM_IDENTITY } from '../src/types'; +import { WangSet } from '../src/WangSet'; + +// ── Test helpers ────────────────────────────────────────────────────────── + +function fakeTexture(): Texture { + return { + destroyed: false, + destroy: () => {}, + height: 512, + label: 'test', + uid: 0, + width: 512, + } as unknown as Texture; +} + +function fakeRegion(): TextureRegion { + return new TextureRegion(fakeTexture(), { height: 512, width: 512, x: 0, y: 0 }); +} + +/** + * Create a TileSet with 256 tiles (16×16 grid in a 512×512 atlas). + * localTileIds 0–255 are all valid, which conveniently covers the full + * blob bitmask range (0–255) when using an identity blobMap. + */ +function makeTileset256(name = 'ts'): TileSet { + return new TileSet({ + columns: 16, + name, + tileCount: 256, + tileHeight: 32, + tileWidth: 32, + texture: fakeRegion(), + }); +} + +function makeLayer(ts: TileSet, w = 3, h = 3): TileLayer { + return new TileLayer({ + height: h, + id: 0, + name: 'layer', + tileHeight: 32, + tileWidth: 32, + tilesets: [ts], + width: w, + }); +} + +/** + * A blobMap that maps every bitmask to itself (identity). + * After autoTile, `layer.getTileAt(x,y).localTileId` equals the computed mask, + * making assertions straightforward. + */ +function identityBlobMap(): Map { + const m = new Map(); + for (let i = 0; i <= 255; i++) m.set(i, i); + return m; +} + +function setTile(layer: TileLayer, ts: TileSet, tx: number, ty: number, localTileId = 0): void { + layer.setTileAt(tx, ty, { localTileId, tileset: ts, transform: TILE_TRANSFORM_IDENTITY }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test 1: Blob mask — corner bits require adjacent cardinals to be set +// ═══════════════════════════════════════════════════════════════════════════ + +describe('autoTile — blob mode corner dependency', () => { + it('full 3×3 grid: center gets mask 255, corner gets partial mask without OOB neighbors', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + // Fill all 9 cells with tile 0. + for (let ty = 0; ty < 3; ty++) { + for (let tx = 0; tx < 3; tx++) { + setTile(layer, ts, tx, ty, 0); + } + } + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + autoTile(layer, wangSet, { wrapBorder: false }); + + // Center (1,1): all 8 in-bounds neighbors are tile 0 → all bits set → mask 255. + expect(layer.getTileAt(1, 1)?.localTileId).toBe(255); + + // Corner (0,0) with wrapBorder=false: + // top (0,-1): OOB → false T=0 + // left (-1,0): OOB → false L=0 + // right (1,0): tile 0 → true R=16 + // bottom (0,1): tile 0 → true B=64 + // TL: OOB but top=false → no bit 1=0 + // TR: OOB and top=false → no bit 4=0 + // BL: OOB and left=false → no bit 32=0 + // BR (1,1): tile0=true, AND bottom=true, right=true → yes bit 128=128 + // expected mask: 16 + 64 + 128 = 208 + expect(layer.getTileAt(0, 0)?.localTileId).toBe(208); + }); + + it('corner bit suppressed when an adjacent cardinal is absent (diagonal-only neighbor)', () => { + // Place tiles only at diagonally opposite corners of a 3×3 grid. + // Cell (0,0) has a diagonal neighbor at (1,1) but NO cardinal neighbors. + // The corner dependency rule must prevent bit 128 (BR) from being set. + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + setTile(layer, ts, 0, 0, 0); + setTile(layer, ts, 2, 2, 0); + // All other cells are empty. + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + autoTile(layer, wangSet, { wrapBorder: false }); + + // Cell (0,0): right=empty, bottom=empty → neither cardinal present. + // BR (1,1) is also empty, so even without the rule, bit 128 = 0. + // More importantly: no cardinal neighbors → mask = 0. + expect(layer.getTileAt(0, 0)?.localTileId).toBe(0); + + // Now test the critical case: 3×3 grid with a hole at (0,1). + // (0,1) is empty, so for cell (0,0): + // right (1,0): tile 0 → true R=16 + // bottom (0,1): EMPTY → false B=0 + // BR (1,1): tile 0, BUT bottom=false → BR bit suppressed → 0 + // mask = 16 only. + const layer2 = makeLayer(ts, 3, 3); + for (let ty2 = 0; ty2 < 3; ty2++) { + for (let tx2 = 0; tx2 < 3; tx2++) { + setTile(layer2, ts, tx2, ty2, 0); + } + } + layer2.clearTileAt(0, 1); // punch a hole below (0,0) + + autoTile(layer2, wangSet, { wrapBorder: false }); + + // (0,0): right=true, bottom=empty(false) → BR bit must NOT be set. + expect(layer2.getTileAt(0, 0)?.localTileId).toBe(16); // R only + }); + + it('wrapBorder=true treats OOB as in-group: every cell in a full grid gets mask 255', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + for (let ty = 0; ty < 3; ty++) { + for (let tx = 0; tx < 3; tx++) { + setTile(layer, ts, tx, ty, 0); + } + } + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + autoTile(layer, wangSet); // wrapBorder defaults to true + + // All cells: every OOB neighbor counts as in-group → all bits set → mask 255. + for (let ty = 0; ty < 3; ty++) { + for (let tx = 0; tx < 3; tx++) { + expect(layer.getTileAt(tx, ty)?.localTileId).toBe(255); + } + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Test 2: matchFn restricts which cells are autotiled +// ═══════════════════════════════════════════════════════════════════════════ + +describe('autoTile — matchFn scope restriction', () => { + it('cells not matched by matchFn are skipped and their tile ID is preserved', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + // Fill 3×3 with tile 0 (wang group), then plant a "foreign" tile 5 at (2,2). + for (let ty = 0; ty < 3; ty++) { + for (let tx = 0; tx < 3; tx++) { + setTile(layer, ts, tx, ty, 0); + } + } + setTile(layer, ts, 2, 2, 5); // not in the wang group + + // matchFn: only localTileId 0 is in the wang group. + const matchFn = (localTileId: number) => localTileId === 0; + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + autoTile(layer, wangSet, { matchFn, wrapBorder: false }); + + // The foreign tile at (2,2) must remain tile 5 — it was not autotiled. + expect(layer.getTileAt(2, 2)?.localTileId).toBe(5); + + // A tile-0 cell must have been updated (its localTileId changed to the mask). + // Cell (1,1): neighbors are mostly tile-0 (in group) except (2,2) is tile-5 (not matched). + // top (1,0): tile0 → matchFn true T=2 + // left (0,1): tile0 → true L=8 + // right (2,1): tile0 → true R=16 + // bottom (1,2): tile0 → true B=64 + // TL (0,0): tile0, top+left true → 1 + // TR (2,0): tile0, top+right true → 4 + // BL (0,2): tile0, bottom+left true → 32 + // BR (2,2): tile5, matchFn→false → bit suppressed → 0 + // mask = 1+2+4+8+16+32+64 = 127 + expect(layer.getTileAt(1, 1)?.localTileId).toBe(127); + }); + + it('matchFn returning false for all cells leaves the layer unchanged', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + setTile(layer, ts, 1, 1, 3); + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + // matchFn always returns false → nothing is autotiled. + autoTile(layer, wangSet, { matchFn: () => false }); + + expect(layer.getTileAt(1, 1)?.localTileId).toBe(3); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Test 3: Edge mode — 4 neighbors only +// ═══════════════════════════════════════════════════════════════════════════ + +describe('autoTile — edge mode (4-neighbor)', () => { + it('edge mode uses only Top/Right/Bottom/Left bits', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + // Fill all 9 cells. + for (let ty = 0; ty < 3; ty++) { + for (let tx = 0; tx < 3; tx++) { + setTile(layer, ts, tx, ty, 0); + } + } + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'edge' }); + autoTile(layer, wangSet, { wrapBorder: false }); + + // Center (1,1): all 4 cardinal neighbors in bounds and in group. + // Top=1, Right=2, Bottom=4, Left=8 → mask 15. + expect(layer.getTileAt(1, 1)?.localTileId).toBe(15); + + // Corner (0,0) with wrapBorder=false: + // top (0,-1): OOB → false T=0 + // left (-1,0): OOB → false L=0 + // right (1,0): tile 0 → true R=2 + // bottom (0,1): tile 0 → true B=4 + // mask = 2 + 4 = 6. + expect(layer.getTileAt(0, 0)?.localTileId).toBe(6); + + // Top-center (1,0) with wrapBorder=false: + // top (1,-1): OOB → false T=0 + // right (2,0): tile 0 R=2 + // bottom (1,1): tile 0 B=4 + // left (0,0): tile 0 L=8 + // mask = 2 + 4 + 8 = 14. + expect(layer.getTileAt(1, 0)?.localTileId).toBe(14); + }); + + it('edge mode does not consider diagonal neighbors', () => { + // Place tiles only at diagonal positions relative to origin. + // With edge mode, diagonals are never in the mask calculation. + const ts = makeTileset256(); + const layer = makeLayer(ts, 3, 3); + + setTile(layer, ts, 0, 0, 0); // origin + setTile(layer, ts, 1, 1, 0); // diagonal from (0,0) + // No cardinal neighbors of (0,0) are set. + + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'edge' }); + autoTile(layer, wangSet, { wrapBorder: false }); + + // (0,0): right=(1,0) empty, bottom=(0,1) empty → mask 0. + expect(layer.getTileAt(0, 0)?.localTileId).toBe(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Test 4: WangSet membership (variant-stable) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('WangSet — membership', () => { + it('every blobMap value is a member; extra members are additive', () => { + const wangSet = new WangSet({ + blobMap: new Map([ + [0, 10], + [255, 11], + ]), + members: [99], + tilesetIndex: 0, + }); + + // blobMap values are members… + expect(wangSet.isMember(10)).toBe(true); + expect(wangSet.isMember(11)).toBe(true); + // …explicit members too… + expect(wangSet.isMember(99)).toBe(true); + // …and unrelated ids are not. + expect(wangSet.isMember(0)).toBe(false); + expect(wangSet.members.has(10)).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Test 5: refreshCell — incremental autotiling around an edit +// ═══════════════════════════════════════════════════════════════════════════ + +describe('refreshCell — incremental update', () => { + it('produces the same result as a full autoTile for a localised edit', () => { + const ts = makeTileset256(); + const full = makeLayer(ts, 5, 5); + const incr = makeLayer(ts, 5, 5); + + // Identity members so every variant 0..255 is recognised as group member. + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + + // Both layers: fill a 3×3 block of water at the centre, leave the rim empty. + for (const layer of [full, incr]) { + for (let ty = 1; ty <= 3; ty++) { + for (let tx = 1; tx <= 3; tx++) { + setTile(layer, ts, tx, ty, 0); + } + } + } + + // Full path: autotile the whole block. + autoTile(full, wangSet, { wrapBorder: false }); + + // Incremental path: autotile once to establish variants, then "paint" a new + // water cell at (2,4) and refresh only its neighbourhood. + autoTile(incr, wangSet, { wrapBorder: false }); + setTile(incr, ts, 2, 4, 0); // paint with a member id + refreshCell(incr, 2, 4, wangSet, { wrapBorder: false }); + + // Now apply the same paint to the full layer and re-run a complete autoTile; + // the incremental result must match the full recompute everywhere. + setTile(full, ts, 2, 4, 0); + autoTile(full, wangSet, { wrapBorder: false }); + + for (let ty = 0; ty < 5; ty++) { + for (let tx = 0; tx < 5; tx++) { + expect(incr.getTileAt(tx, ty)?.localTileId).toBe(full.getTileAt(tx, ty)?.localTileId); + } + } + }); + + it('only touches the 3×3 neighbourhood (does not bump the whole layer)', () => { + const ts = makeTileset256(); + const layer = makeLayer(ts, 5, 5); + const wangSet = new WangSet({ blobMap: identityBlobMap(), tilesetIndex: 0, type: 'blob' }); + + // A lone cell far from the edit must be untouched by refreshCell. + setTile(layer, ts, 0, 0, 0); + autoTile(layer, wangSet, { wrapBorder: false }); + const farBefore = layer.getTileAt(0, 0)?.localTileId; + const revBefore = layer.revision; + + // Paint + refresh in the opposite corner. + setTile(layer, ts, 4, 4, 0); + refreshCell(layer, 4, 4, wangSet, { wrapBorder: false }); + + // The far cell is unchanged, and revision advanced by only a small amount + // (the refreshed cell, not a whole-layer rewrite). + expect(layer.getTileAt(0, 0)?.localTileId).toBe(farBefore); + expect(layer.revision).toBeGreaterThan(revBefore); + }); +}); diff --git a/packages/exojs-tilemap/test/nodes.test.ts b/packages/exojs-tilemap/test/nodes.test.ts index 8e214398..e224be49 100644 --- a/packages/exojs-tilemap/test/nodes.test.ts +++ b/packages/exojs-tilemap/test/nodes.test.ts @@ -108,6 +108,41 @@ describe('TileLayerNode', () => { expect(node.y).toBe(-32); }); + it('parallax layer: initial position is the base offset (not parallax-shifted)', () => { + const tileset = makeTileset(); + const layer = new TileLayer({ + id: 1, name: 'bg', width: 4, height: 4, tileWidth: 32, tileHeight: 32, + tilesets: [tileset], offsetX: 10, offsetY: 20, parallaxX: 0.5, parallaxY: 0.5, + }); + const node = new TileLayerNode(layer); + + // Construction must NOT apply a parallax shift — the shift is render-time only. + expect(node.x).toBe(10); + expect(node.y).toBe(20); + }); + + it('parallax layer: position is restored to base offset after _collectContent', () => { + const tileset = makeTileset(); + const layer = new TileLayer({ + id: 1, name: 'bg', width: 4, height: 4, tileWidth: 32, tileHeight: 32, + tilesets: [tileset], offsetX: 10, offsetY: 20, parallaxX: 0.5, parallaxY: 0.5, + }); + const node = new TileLayerNode(layer); + + // Simulate the render-plan builder with a view whose center is at (100, 200). + const mockBuilder = { + view: { center: { x: 100, y: 200 } }, + }; + + // Invoke the protected _collectContent via cast; it must not throw and + // must restore the position even though children don't exist (empty layer). + (node as unknown as { _collectContent(b: unknown): void })._collectContent(mockBuilder); + + // After the call the node position must be restored to the base offset. + expect(node.x).toBe(10); + expect(node.y).toBe(20); + }); + it('reports local bounds as the layer pixel rect (even when empty)', () => { const tileset = makeTileset(); const layer = makeLayer(tileset, { width: 5, height: 3 }); diff --git a/packages/exojs-tilemap/test/tilemap.test.ts b/packages/exojs-tilemap/test/tilemap.test.ts index 4f0392c9..fcd0dbfb 100644 --- a/packages/exojs-tilemap/test/tilemap.test.ts +++ b/packages/exojs-tilemap/test/tilemap.test.ts @@ -896,6 +896,39 @@ describe('TileLayer', () => { try { layer.setTileAt(0, 0, { tileset: ts2, localTileId: 0, transform: TILE_TRANSFORM_IDENTITY }); } catch { /* expected */ } expect(layer.revision).toBe(rev); }); + + // ── Parallax ────────────────────────────────────────────────────────── + + it('parallaxX and parallaxY default to 1.0', () => { + const layer = new TileLayer({ + id: 0, name: 'l', width: 8, height: 8, + tileWidth: 16, tileHeight: 16, tilesets: [ts], + }); + expect(layer.parallaxX).toBe(1); + expect(layer.parallaxY).toBe(1); + }); + + it('parallaxX and parallaxY can be set via options', () => { + const layer = new TileLayer({ + id: 0, name: 'l', width: 8, height: 8, + tileWidth: 16, tileHeight: 16, tilesets: [ts], + parallaxX: 0.5, + parallaxY: 0.25, + }); + expect(layer.parallaxX).toBe(0.5); + expect(layer.parallaxY).toBe(0.25); + }); + + it('parallaxX = 0.0 is valid (stationary layer)', () => { + const layer = new TileLayer({ + id: 0, name: 'l', width: 8, height: 8, + tileWidth: 16, tileHeight: 16, tilesets: [ts], + parallaxX: 0, + parallaxY: 0, + }); + expect(layer.parallaxX).toBe(0); + expect(layer.parallaxY).toBe(0); + }); }); // ═══════════════════════════════════════════════════════════════════════ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e4afcea..253cc045 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@rollup/plugin-typescript': specifier: ^12.3.0 version: 12.3.0(rollup@4.62.0)(tslib@2.8.1)(typescript@6.0.3) + '@size-limit/preset-small-lib': + specifier: ^11.2.0 + version: 11.2.0(size-limit@11.2.0) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/css-font-loading-module': specifier: 0.0.14 version: 0.0.14 @@ -104,6 +113,9 @@ importers: rollup-plugin-string: specifier: ^3.0.0 version: 3.0.0 + size-limit: + specifier: ^11.2.0 + version: 11.2.0 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -132,6 +144,15 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/exojs-aseprite: + devDependencies: + '@codexo/exojs': + specifier: workspace:* + version: link:../.. + '@codexo/exojs-config': + specifier: workspace:* + version: link:../exojs-config + packages/exojs-audio-fx: devDependencies: '@codexo/exojs': @@ -156,6 +177,19 @@ importers: specifier: ^3.0.0 version: 3.0.0 + packages/exojs-ldtk: + dependencies: + '@codexo/exojs-tilemap': + specifier: workspace:* + version: link:../exojs-tilemap + devDependencies: + '@codexo/exojs': + specifier: workspace:* + version: link:../.. + '@codexo/exojs-config': + specifier: workspace:* + version: link:../exojs-config + packages/exojs-particles: devDependencies: '@codexo/exojs': @@ -174,6 +208,25 @@ importers: specifier: workspace:* version: link:../exojs-config + packages/exojs-react: + dependencies: + react: + specifier: '>=18.0.0' + version: 19.2.7 + react-dom: + specifier: '>=18.0.0' + version: 19.2.7(react@19.2.7) + devDependencies: + '@codexo/exojs': + specifier: workspace:* + version: link:../.. + '@codexo/exojs-config': + specifier: workspace:* + version: link:../exojs-config + '@types/react': + specifier: ^18.0.0 + version: 18.3.31 + packages/exojs-tiled: dependencies: '@codexo/exojs-tilemap': @@ -411,6 +464,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -502,6 +559,12 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -514,6 +577,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -526,6 +595,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -538,6 +613,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -550,6 +631,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -562,6 +649,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -574,6 +667,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -586,6 +685,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -598,6 +703,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -610,6 +721,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -622,6 +739,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -634,6 +757,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -646,6 +775,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -658,6 +793,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -670,6 +811,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -682,6 +829,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -694,6 +847,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -706,6 +865,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} @@ -718,6 +883,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -730,6 +901,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} @@ -742,6 +919,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -754,6 +937,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} @@ -766,6 +955,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -778,6 +973,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -790,6 +991,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -802,6 +1009,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -1484,12 +1697,51 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@size-limit/esbuild@11.2.0': + resolution: {integrity: sha512-vSg9H0WxGQPRzDnBzeDyD9XT0Zdq0L+AI3+77/JhxznbSCMJMMr8ndaWVQRhOsixl97N0oD4pRFw2+R1Lcvi6A==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + size-limit: 11.2.0 + + '@size-limit/file@11.2.0': + resolution: {integrity: sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + size-limit: 11.2.0 + + '@size-limit/preset-small-lib@11.2.0': + resolution: {integrity: sha512-RFbbIVfv8/QDgTPyXzjo5NKO6CYyK5Uq5xtNLHLbw5RgSKrgo8WpiB/fNivZuNd/5Wk0s91PtaJ9ThNcnFuI3g==} + peerDependencies: + size-limit: 11.2.0 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1547,11 +1799,17 @@ packages: '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} @@ -1831,6 +2089,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1842,6 +2104,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1927,6 +2192,10 @@ packages: resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} engines: {node: '>=18.20'} + bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -2168,6 +2437,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2233,6 +2505,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -2906,6 +3183,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} @@ -2938,6 +3219,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3181,6 +3466,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.15: + resolution: {integrity: sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==} + engines: {node: ^18 || >=20} + hasBin: true + + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3392,6 +3685,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3433,6 +3730,9 @@ packages: peerDependencies: react: ^19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -3666,6 +3966,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + size-limit@11.2.0: + resolution: {integrity: sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -4629,6 +4934,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4726,156 +5033,234 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.7': optional: true '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -5462,10 +5847,49 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@size-limit/esbuild@11.2.0(size-limit@11.2.0)': + dependencies: + esbuild: 0.25.12 + nanoid: 5.1.15 + size-limit: 11.2.0 + + '@size-limit/file@11.2.0(size-limit@11.2.0)': + dependencies: + size-limit: 11.2.0 + + '@size-limit/preset-small-lib@11.2.0(size-limit@11.2.0)': + dependencies: + '@size-limit/esbuild': 11.2.0(size-limit@11.2.0) + '@size-limit/file': 11.2.0(size-limit@11.2.0) + size-limit: 11.2.0 + '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -5537,10 +5961,17 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/prop-types@15.7.15': {} + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/react@19.2.17': dependencies: csstype: 3.2.3 @@ -5923,6 +6354,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} anymatch@3.1.3: @@ -5932,6 +6365,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -6100,6 +6537,8 @@ snapshots: builtin-modules@5.2.0: {} + bytes-iec@3.1.1: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001799: {} @@ -6311,6 +6750,8 @@ snapshots: diff@8.0.4: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -6376,6 +6817,35 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -7243,6 +7713,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lilconfig@3.1.3: {} + linkify-it@5.0.1: dependencies: uc.micro: 2.1.0 @@ -7273,6 +7745,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7774,6 +8248,12 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.15: {} + + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + natural-compare@1.4.0: {} neotraverse@0.6.18: {} @@ -7998,6 +8478,12 @@ snapshots: prettier@3.8.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} property-information@7.2.0: {} @@ -8041,6 +8527,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.18.0: {} react@19.2.7: {} @@ -8386,6 +8874,16 @@ snapshots: sisteransi@1.0.5: {} + size-limit@11.2.0: + dependencies: + bytes-iec: 3.1.1 + chokidar: 4.0.3 + jiti: 2.7.0 + lilconfig: 3.1.3 + nanospinner: 1.2.2 + picocolors: 1.1.1 + tinyglobby: 0.2.17 + smart-buffer@4.2.0: {} smob@1.6.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20a6ee99..8d3037d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,10 @@ packages: - packages/exojs-particles - packages/exojs-tilemap - packages/exojs-tiled + - packages/exojs-aseprite + - packages/exojs-ldtk - packages/exojs-physics + - packages/exojs-react - packages/exojs-audio-fx - packages/create-exo-app diff --git a/rollup.config.ts b/rollup.config.ts index f91d157f..c32bdbf9 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -6,7 +6,7 @@ import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; -import type { RollupOptions } from 'rollup'; +import type { Plugin, RollupOptions } from 'rollup'; import { string } from 'rollup-plugin-string'; const rootDir = resolvePath(dirname(fileURLToPath(import.meta.url))); @@ -21,6 +21,24 @@ const defines = createBuildDefinesFromRepo({ mode: buildMode, packageDir: rootDi // standard conditions keep normal dependency resolution intact. const sourceConditions = ['@codexo/source', 'browser', 'module', 'import', 'default']; +// Full-bundle source conditions: includes per-package source conditions for the +// extension packages that use # subpath imports internally (e.g. exojs-particles). +const fullSourceConditions = ['@codexo/source', '@codexo/exojs-particles-source', 'browser', 'module', 'import', 'default']; + +// Resolves @codexo/exojs- → packages/exojs-/src/index.ts so the +// full IIFE bundle can be built entirely from TypeScript source without requiring +// the extension packages to be pre-built. +const extensionSourcePlugin = (): Plugin => ({ + name: 'extension-source', + resolveId(id: string) { + const match = /^@codexo\/exojs-([^/]+)$/.exec(id); + if (match) { + return resolvePath(rootDir, 'packages', `exojs-${match[1]}`, 'src', 'index.ts'); + } + return null; + }, +}); + const glslPlugin = string({ include: ['**/*.vert', '**/*.frag'], }); @@ -57,6 +75,47 @@ const bundled: RollupOptions = { ], }; +// Unminified IIFE global bundle for CDN script-tag usage (both dev and production). +const iife: RollupOptions = { + input: 'src/index.ts', + output: { + file: 'dist/exo.iife.js', + format: 'iife', + name: 'Exo', + sourcemap: true, + }, + plugins: [ + constantReplacementPlugin, + resolve({ mainFields: ['browser', 'module', 'main'], exportConditions: sourceConditions }), + glslPlugin, + typescript({ + compilerOptions: { incremental: false }, + outputToFilesystem: false, + }), + ], +}; + +// Minified IIFE global bundle for CDN production use (production only). +const iifeMin: RollupOptions = { + input: 'src/index.ts', + output: { + file: 'dist/exo.iife.min.js', + format: 'iife', + name: 'Exo', + sourcemap: true, + }, + plugins: [ + constantReplacementPlugin, + resolve({ mainFields: ['browser', 'module', 'main'], exportConditions: sourceConditions }), + glslPlugin, + typescript({ + compilerOptions: { incremental: false }, + outputToFilesystem: false, + }), + terser({ compress: { pure_funcs: ['assert', 'assertDefined', 'invariant', 'warnOnce'] } }), + ], +}; + const debugBundled: RollupOptions = { input: 'src/debug/index.ts', // All `#` imports are core dependencies — mark them external so the debug @@ -106,4 +165,61 @@ const modules: RollupOptions = { ], }; -export default [bundled, debugBundled, modules]; +// Unminified full IIFE bundle (core + all extension packages) for CDN script-tag usage. +const iifeFull: RollupOptions = { + input: 'scripts/exo-full.entry.ts', + output: { + file: 'dist/exo.full.iife.js', + format: 'iife', + name: 'Exo', + sourcemap: true, + }, + plugins: [ + constantReplacementPlugin, + extensionSourcePlugin(), + resolve({ mainFields: ['browser', 'module', 'main'], exportConditions: fullSourceConditions }), + glslPlugin, + typescript({ + compilerOptions: { incremental: false }, + outputToFilesystem: false, + // The full bundle pulls extension-package source (resolved via + // extensionSourcePlugin); the TS transform must cover them, not just src/. + include: ['src/**/*.ts', 'packages/*/src/**/*.ts', 'scripts/exo-full.entry.ts'], + }), + ], +}; + +// Minified full IIFE bundle (production only). +const iifeFullMin: RollupOptions = { + input: 'scripts/exo-full.entry.ts', + output: { + file: 'dist/exo.full.iife.min.js', + format: 'iife', + name: 'Exo', + sourcemap: true, + }, + plugins: [ + constantReplacementPlugin, + extensionSourcePlugin(), + resolve({ mainFields: ['browser', 'module', 'main'], exportConditions: fullSourceConditions }), + glslPlugin, + typescript({ + compilerOptions: { incremental: false }, + outputToFilesystem: false, + include: ['src/**/*.ts', 'packages/*/src/**/*.ts', 'scripts/exo-full.entry.ts'], + }), + terser({ compress: { pure_funcs: ['assert', 'assertDefined', 'invariant', 'warnOnce'] } }), + ], +}; + +const productionOnlyConfigs = buildMode === 'production' ? [iifeMin] : []; + +// The all-in-one full bundle (core + every extension package) is opt-in via +// EXOJS_FULL_BUNDLE=1. It bundles extension-package SOURCE, which +// @rollup/plugin-typescript cannot transform across the multiple rootDirs +// (src/ + packages/*/src) in one pass — building it needs an esbuild/swc +// transpile step (or a build-from-dist approach) that is not yet wired up. +// Keeping it out of the default build keeps `pnpm build` / release green. +const fullBundleConfigs = process.env.EXOJS_FULL_BUNDLE === '1' ? (buildMode === 'production' ? [iifeFull, iifeFullMin] : [iifeFull]) : []; + +export default [bundled, debugBundled, modules, iife, ...productionOnlyConfigs, ...fullBundleConfigs]; diff --git a/scripts/ci/select-lanes.mjs b/scripts/ci/select-lanes.mjs index 9fab9861..f2d8a342 100644 --- a/scripts/ci/select-lanes.mjs +++ b/scripts/ci/select-lanes.mjs @@ -52,9 +52,22 @@ import { pathToFileURL } from 'node:url'; * NOT listed: `create-exo-app` (a standalone scaffolding CLI with no engine / * browser impact) and `site` (the examples app — covered by the `site` area). * - * Adding a new runtime extension package == add its directory name here. + * Adding a new runtime extension package == add its directory name here. Keep in + * sync with `LOCKSTEP_PACKAGES` in scripts/release/lockstep-packages.ts (this + * file is dependency-free ESM and runs before any install, so it cannot import + * that TS module). */ -const RUNTIME_PACKAGES = ['exojs-config', 'exojs-particles', 'exojs-tilemap', 'exojs-tiled', 'exojs-physics', 'exojs-audio-fx']; +const RUNTIME_PACKAGES = [ + 'exojs-config', + 'exojs-particles', + 'exojs-tilemap', + 'exojs-tiled', + 'exojs-physics', + 'exojs-audio-fx', + 'exojs-aseprite', + 'exojs-ldtk', + 'exojs-react', +]; /** * Documentation-only files inside a package. A change limited to these must NOT diff --git a/scripts/create-package.ts b/scripts/create-package.ts new file mode 100644 index 00000000..ace083ab --- /dev/null +++ b/scripts/create-package.ts @@ -0,0 +1,603 @@ +/** + * create:package — scaffold a new lockstep extension package. + * + * `pnpm create:package [flags]` generates `packages/exojs-/` from + * the conventions the existing extension packages share (aseprite/tiled for the + * register style, physics/audio-fx for the library style), then auto-wires it + * into the single sources of truth so adding a package is one command instead of + * a ~10-file hand-edit. + * + * Auto-wired (string-edit, idempotent): + * - scripts/release/lockstep-packages.ts (LOCKSTEP_PACKAGES entry — the SoT + * every release script derives from: cut/manifest/prepare/run/verify-*) + * - scripts/ci/select-lanes.mjs (RUNTIME_PACKAGES entry) + * - pnpm-workspace.yaml (explicit member list, not a glob) + * + * NOT auto-wired (enumerated YAML / a different runtime / a manual bootstrap) — + * printed as a concrete, copy-pasteable checklist at the end: + * - .github/workflows/_ci-checks.yml + release.yml `--filter` lines + * - vitest.config.ts createJsdomTestProject entry (+ aliasConfig if imported) + * - root package.json typecheck:packages / test / test:coverage lists + * - the npm placeholder publish + Trusted-Publisher (OIDC) bootstrap from + * scripts/release/RELEASING.md (do this BEFORE the package's first release) + * + * Usage: + * pnpm create:package # library (default) + * pnpm create:package --register # ships /register + * pnpm create:package --dep tilemap # runtime workspace dep + * pnpm create:package --description "…" # manifest description + * pnpm create:package --no-offline-smoke # exclude from offline smoke + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// ── Argument parsing ───────────────────────────────────────────────────────── + +interface Options { + name: string; + register: boolean; + deps: string[]; + description?: string; + inOfflineSmoke: boolean; +} + +const USAGE = `Usage: pnpm create:package [--register | --library] [--dep ]... [--description ""] [--no-offline-smoke] + + bare package name without the "exojs-"/scope prefix (kebab-case), e.g. "spine" + --register ship a /register side-effect entry (extension style: aseprite, tiled) + --library DEFAULT — sideEffects:false, no /register (library style: physics, audio-fx) + --dep add a runtime workspace dependency on @codexo/exojs- (repeatable / comma list) + --description "..." package.json description + --no-offline-smoke exclude from the offline external-consumer smoke (react is the precedent)`; + +function fail(message: string): never { + process.stderr.write(`create:package: ${message}\n\n${USAGE}\n`); + process.exit(1); +} + +function parseArgs(argv: readonly string[]): Options { + let name: string | undefined; + let register = false; + let library = false; + const deps: string[] = []; + let description: string | undefined; + let inOfflineSmoke = true; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const eq = arg.indexOf('='); + const flag = arg.startsWith('--') && eq !== -1 ? arg.slice(0, eq) : arg; + const inlineValue = arg.startsWith('--') && eq !== -1 ? arg.slice(eq + 1) : undefined; + const takeValue = (): string => { + if (inlineValue !== undefined) return inlineValue; + const next = argv[++i]; + if (next === undefined) fail(`${flag} expects a value`); + return next; + }; + + switch (flag) { + case '--register': + register = true; + break; + case '--library': + library = true; + break; + case '--dep': + for (const d of takeValue().split(',')) { + const trimmed = d.trim(); + if (trimmed) deps.push(trimmed); + } + break; + case '--description': + description = takeValue(); + break; + case '--no-offline-smoke': + inOfflineSmoke = false; + break; + case '--help': + case '-h': + process.stdout.write(`${USAGE}\n`); + process.exit(0); + break; + default: + if (arg.startsWith('-')) fail(`unknown flag: ${arg}`); + if (name !== undefined) fail(`unexpected extra argument: ${arg}`); + name = arg; + } + } + + if (name === undefined) fail('a package is required'); + if (register && library) fail('--register and --library are mutually exclusive'); + + // Tolerate a leading scope/prefix the user may have typed; the canonical name + // is the bare kebab segment. + name = name.replace(/^@codexo\//, '').replace(/^exojs-/, ''); + + return { name, register, deps, ...(description !== undefined ? { description } : {}), inOfflineSmoke }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +const toCamel = (kebab: string): string => kebab.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +const toPascal = (kebab: string): string => { + const camel = toCamel(kebab); + return camel.charAt(0).toUpperCase() + camel.slice(1); +}; +const toTitle = (kebab: string): string => + kebab + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + +const writeFile = (relPath: string, contents: string): void => { + writeFileSync(resolve(pkgDir, relPath), contents, 'utf8'); +}; + +/** Insert `lines` (each gets a trailing newline) immediately before `anchor`. */ +const insertBefore = (content: string, anchor: string, lines: readonly string[]): string => { + const idx = content.indexOf(anchor); + if (idx === -1) throw new Error(`anchor not found: ${anchor}`); + return content.slice(0, idx) + lines.map(l => `${l}\n`).join('') + content.slice(idx); +}; + +// ── Parse + validate ───────────────────────────────────────────────────────── + +const opts = parseArgs(process.argv.slice(2)); +const { name, register, deps, inOfflineSmoke } = opts; + +if (!KEBAB_RE.test(name)) fail(`name "${name}" is not kebab-case (lowercase letters/digits, single dashes)`); + +const pkgName = `@codexo/exojs-${name}`; +const pkgDirRel = `packages/exojs-${name}`; +const pkgDir = resolve(rootDir, pkgDirRel); + +if (existsSync(pkgDir)) fail(`directory already exists: ${pkgDirRel}`); + +const lockstepPath = resolve(rootDir, 'scripts/release/lockstep-packages.ts'); +const lockstepSrc = readFileSync(lockstepPath, 'utf8'); +if (lockstepSrc.includes(`'${pkgName}'`)) fail(`${pkgName} is already in LOCKSTEP_PACKAGES`); + +for (const dep of deps) { + if (!KEBAB_RE.test(dep)) fail(`--dep "${dep}" is not kebab-case`); + if (!existsSync(resolve(rootDir, `packages/exojs-${dep}`))) { + fail(`--dep "${dep}" → packages/exojs-${dep} does not exist`); + } +} + +const camel = toCamel(name); +const description = opts.description ?? `${toTitle(name)} ${register ? 'extension' : 'library'} for ExoJS.`; + +// Version is shared across all lockstep packages — read it from Core rather than +// hard-coding, so the scaffold always matches the current in-tree version. +const coreVersion = (JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf8')) as { version: string }).version; +const [major, minor] = coreVersion.split('.'); +const peerRange = `${major}.${minor}.x`; + +// ── Generate package files ─────────────────────────────────────────────────── + +mkdirSync(resolve(pkgDir, 'src'), { recursive: true }); +mkdirSync(resolve(pkgDir, 'test'), { recursive: true }); + +const generated: string[] = []; +const emit = (relPath: string, contents: string): void => { + writeFile(relPath, contents); + generated.push(`${pkgDirRel}/${relPath}`); +}; + +// package.json — key order mirrors the reference packages exactly. +const manifest: Record = { + name: pkgName, + version: coreVersion, + description, + repository: { + type: 'git', + url: 'git+https://github.com/Exoridus/ExoJS.git', + directory: pkgDirRel, + }, + type: 'module', + sideEffects: register ? ['./dist/esm/register.js'] : 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', + }, + ...(register + ? { + './register': { + types: './dist/esm/register.d.ts', + import: './dist/esm/register.js', + default: './dist/esm/register.js', + }, + } + : {}), + './package.json': './package.json', + }, + files: ['dist/esm/', 'README.md', 'LICENSE'], + scripts: { + build: 'tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:production', + 'build:dev': 'tsx ../../node_modules/rollup/dist/bin/rollup -c --environment EXOJS_ENV:development', + typecheck: 'tsc --noEmit', + lint: 'eslint "src/**/*.ts" "test/**/*.ts"', + test: `vitest run --root ../.. --project=exojs-${name}`, + }, + peerDependencies: { '@codexo/exojs': peerRange }, + ...(deps.length ? { dependencies: Object.fromEntries(deps.map(d => [`@codexo/exojs-${d}`, 'workspace:*'])) } : {}), + devDependencies: { + '@codexo/exojs': 'workspace:*', + '@codexo/exojs-config': 'workspace:*', + }, + license: 'MIT', + publishConfig: { access: 'public' }, +}; +emit('package.json', `${JSON.stringify(manifest, null, 2)}\n`); + +// tsconfig.json — Core paths to source (+ each runtime dep), like aseprite/tiled. +// Hand-built (not JSON.stringify) to keep the references' inline-array style. +const pathEntries: [string, string][] = [ + ['@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'], + ...deps.map((dep): [string, string] => [`@codexo/exojs-${dep}`, `../exojs-${dep}/src/index.ts`]), +]; +const pathLines = pathEntries.map(([k, v]) => ` "${k}": ["${v}"]`).join(',\n'); + +// A runtime dep that uses package-internal `#` imports exposes its OWN source +// condition (e.g. particles → '@codexo/exojs-particles-source'). Without it in +// customConditions, tsc resolves the dep's `#` imports to its stale dist and the +// dep's src↔dist class identities diverge ("two declarations of a private +// property"). Pull each dep's source condition from its imports map so the +// cross-package source typecheck stays clean. Deps with no `#` imports +// (tilemap, etc.) contribute nothing. +const SOURCE_CONDITION_RE = /-source$/; +const customConditions = ['@codexo/source']; +for (const dep of deps) { + const depPkg = JSON.parse(readFileSync(resolve(rootDir, `packages/exojs-${dep}/package.json`), 'utf8')) as { + imports?: Record; + }; + const star = depPkg.imports?.['#*']; + if (star && typeof star === 'object') { + for (const cond of Object.keys(star)) { + if (SOURCE_CONDITION_RE.test(cond) && !customConditions.includes(cond)) customConditions.push(cond); + } + } +} +const conditionsLine = `[${customConditions.map(c => `"${c}"`).join(', ')}]`; + +emit( + 'tsconfig.json', + `{ + "extends": "@codexo/exojs-config/typescript/extension.json", + "compilerOptions": { + "customConditions": ${conditionsLine}, + "paths": { +${pathLines} + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules", "test"] +} +`, +); + +// rollup.config.ts — register builds index + register (factory default); library +// builds index only. No internal `#` imports, so sourceCondition is null. +const rollupConfig = register + ? `import { createExtensionConfig } from '@codexo/exojs-config/rollup'; + +// ${pkgName} ships a /register side-effect entry alongside the side-effect-free +// root. No package-internal \`#\` imports (all same-directory \`./\`), so no source +// condition / node-resolve is needed; Core's \`#\` resolves to its dist. +export default createExtensionConfig({ + root: import.meta.dirname, + sourceCondition: null, +}); +` + : `import { createExtensionConfig } from '@codexo/exojs-config/rollup'; + +// ${pkgName} is a library package (no /register): a single side-effect-free +// entry. No package-internal \`#\` imports (all same-directory \`./\`), so no source +// condition / node-resolve is needed; Core's \`#\` resolves to its dist. +export default createExtensionConfig({ + root: import.meta.dirname, + sourceCondition: null, + inputs: ['src/index.ts'], +}); +`; +emit('rollup.config.ts', rollupConfig); + +// src/index.ts +const indexHeader = register + ? `// ${pkgName} — side-effect-free root entry. +// Importing this entry does NOT register the extension globally. +// Use ${pkgName}/register for global registration.` + : `// ${pkgName} — side-effect-free root entry.`; +emit('src/index.ts', `${indexHeader}\n\nexport * from './public';\n`); + +// src/public.ts — a small placeholder so TypeDoc/typecheck has a public symbol. +const depTypeExports = deps + .map( + dep => ` +/** + * Type handle proving the @codexo/exojs-${dep} runtime dependency resolves. + * Replace it with this package's real use of the dependency. + */ +export type ${toPascal(dep)}Module = typeof import('@codexo/exojs-${dep}');`, + ) + .join('\n'); + +const publicTs = register + ? `// Side-effect-free public API for ${pkgName}. +// No registration is performed on import. + +import type { Extension } from '@codexo/exojs/extensions'; + +/** + * Default immutable extension descriptor for ${pkgName}. + * + * Register it explicitly via \`ApplicationOptions.extensions\`, or import + * \`${pkgName}/register\` for global auto-registration. Replace the empty + * descriptor below with this package's real renderer/asset/serializer bindings. + */ +export const ${camel}Extension: Extension = Object.freeze({ + id: '${pkgName}', +}); +${depTypeExports} +` + : `// Side-effect-free public API for ${pkgName}. + +/** + * Placeholder so TypeDoc and the typecheck gate have a stable public symbol. + * Replace it with this package's real API. + */ +export const ${camel}PackageName = '${pkgName}' as const; +${depTypeExports} +`; +emit('src/public.ts', publicTs); + +// src/register.ts — only for register mode (the single side-effectful entry). +if (register) { + emit( + 'src/register.ts', + `// ${pkgName}/register — explicit registration entry. +// Importing this entry registers the default ${camel}Extension descriptor in the +// global ExtensionRegistry. Subsequently constructed Applications that use global +// defaults will receive this extension. This is the only side-effectful entry. + +import { ExtensionRegistry } from '@codexo/exojs/extensions'; + +import { ${camel}Extension } from './public'; + +ExtensionRegistry.register(${camel}Extension); + +export * from './public'; +`, + ); +} + +// test/.test.ts — a trivial smoke test (mirrors the package tests' +// relative `../src/index` import convention). +const testSymbol = register ? `${camel}Extension` : `${camel}PackageName`; +emit( + `test/${name}.test.ts`, + `import { describe, expect, it } from 'vitest'; + +import { ${testSymbol} } from '../src/index'; + +describe('${pkgName}', () => { + it('exposes its public API', () => { + expect(${testSymbol}).toBeDefined(); + }); +}); +`, +); + +// README.md +const compatTable = `## Core compatibility + +| \`${pkgName}\` | \`@codexo/exojs\` | +|---|---| +| ${major}.${minor}.x | ${major}.${minor}.x |`; + +const installDeps = ['@codexo/exojs', pkgName, ...deps.map(d => `@codexo/exojs-${d}`)].join(' '); + +const readme = register + ? `# ${pkgName} + +${description} + +> Official ExoJS extension. \`@codexo/exojs\` is a peer dependency. + +## Installation + +\`\`\`sh +npm install ${installDeps} +\`\`\` + +## Usage + +Freshly scaffolded — replace the placeholder \`${camel}Extension\` descriptor in +\`src/public.ts\` (and this section) with the real API. + +\`\`\`ts +import { Application } from '@codexo/exojs'; +import { ${camel}Extension } from '${pkgName}'; + +const app = new Application({ extensions: [${camel}Extension] }); +\`\`\` + +## \`/register\` convenience entry + +\`\`\`ts +// Side effect: registers ${camel}Extension in the global ExtensionRegistry. +import '${pkgName}/register'; +\`\`\` + +Importing the package root (\`${pkgName}\`) does **not** register anything — that is +the only side-effectful entry. + +${compatTable} + +## License + +MIT © Codexo +` + : `# ${pkgName} + +${description} + +> A peer-dependency library on top of \`@codexo/exojs\` (no \`/register\` entry — +> construct its API directly). + +## Installation + +\`\`\`sh +npm install ${installDeps} +\`\`\` + +## Usage + +Freshly scaffolded — replace the placeholder \`${camel}PackageName\` export in +\`src/public.ts\` (and this section) with the real API. + +\`\`\`ts +import { ${camel}PackageName } from '${pkgName}'; +\`\`\` + +${compatTable} + +## License + +MIT © Codexo +`; +emit('README.md', readme); + +// LICENSE — copy the MIT text from a sibling package verbatim. +emit('LICENSE', readFileSync(resolve(rootDir, 'packages/exojs-aseprite/LICENSE'), 'utf8')); + +// ── Auto-wire the single sources of truth (idempotent) ─────────────────────── + +const wired: string[] = []; + +// 1. scripts/release/lockstep-packages.ts — append a LockstepPackage entry. +{ + const entry = ` { name: '${pkgName}', dir: '${pkgDirRel}', isExtension: true, hasRegister: ${register}, inOfflineSmoke: ${inOfflineSmoke} },`; + const updated = insertBefore(lockstepSrc, '] as const satisfies readonly LockstepPackage[];', [entry]); + writeFileSync(lockstepPath, updated, 'utf8'); + wired.push('scripts/release/lockstep-packages.ts (LOCKSTEP_PACKAGES)'); +} + +// 2. scripts/ci/select-lanes.mjs — append the directory to RUNTIME_PACKAGES. +{ + const lanesPath = resolve(rootDir, 'scripts/ci/select-lanes.mjs'); + const src = readFileSync(lanesPath, 'utf8'); + const dirName = `exojs-${name}`; + if (src.includes(`'${dirName}'`)) { + wired.push('scripts/ci/select-lanes.mjs (already present)'); + } else { + const arrayStart = src.indexOf('const RUNTIME_PACKAGES = ['); + if (arrayStart === -1) throw new Error('RUNTIME_PACKAGES array not found in select-lanes.mjs'); + const closeIdx = src.indexOf('];', arrayStart); + if (closeIdx === -1) throw new Error('RUNTIME_PACKAGES closing not found in select-lanes.mjs'); + const updated = `${src.slice(0, closeIdx)} '${dirName}',\n${src.slice(closeIdx)}`; + writeFileSync(lanesPath, updated, 'utf8'); + wired.push('scripts/ci/select-lanes.mjs (RUNTIME_PACKAGES)'); + } +} + +// 3. pnpm-workspace.yaml — explicit member list (NOT a glob), so a new package is +// invisible to pnpm until listed here. +{ + const wsPath = resolve(rootDir, 'pnpm-workspace.yaml'); + const src = readFileSync(wsPath, 'utf8'); + const memberLine = ` - ${pkgDirRel}`; + if (src.includes(`${memberLine}\n`)) { + wired.push('pnpm-workspace.yaml (already present)'); + } else { + const updated = insertBefore(src, ' - packages/create-exo-app', [memberLine]); + writeFileSync(wsPath, updated, 'utf8'); + wired.push('pnpm-workspace.yaml (packages)'); + } +} + +// ── Report ─────────────────────────────────────────────────────────────────── + +const out = process.stdout; +out.write(`\nScaffolded ${pkgName} (${register ? 'register' : 'library'}${deps.length ? `, deps: ${deps.join(', ')}` : ''}) @ v${coreVersion}\n\n`); + +out.write('Generated files:\n'); +for (const f of generated) out.write(` + ${f}\n`); + +out.write('\nAuto-wired (single sources of truth):\n'); +for (const w of wired) out.write(` ✓ ${w}\n`); + +const filterFlag = `--filter "${pkgName}"`; +out.write(` +Next: run \`pnpm install\` to link the new workspace package, then \`pnpm ${filterFlag} typecheck\`. + +──────────────────────────────────────────────────────────────────────────────── +MANUAL CHECKLIST — not auto-edited (enumerated YAML / different runtime / npm bootstrap) +──────────────────────────────────────────────────────────────────────────────── + +1) vitest.config.ts — add a jsdom test project (after the other extension projects): + + createJsdomTestProject({ + name: 'exojs-${name}', + alias: aliasConfig, + include: ['${pkgDirRel}/test/**/*.test.ts'], + }), + + If other in-repo tests import this package's source, also add to \`aliasConfig\`: + + { find: '${pkgName}', replacement: fileURLToPath(new URL('./${pkgDirRel}/src/index.ts', import.meta.url)) }, + +2) Root package.json scripts — append ${filterFlag} / --project=exojs-${name}: + - "typecheck:packages" → add ${filterFlag} + - "test", "test:coverage" → add --project=exojs-${name} (to run its tests by default) + - "verify:publint" → add ${filterFlag} (only if you want publint to gate it) + +3) .github/workflows/_ci-checks.yml — add ${filterFlag} to these three steps: + - "Typecheck extension packages" + - "Build extension packages" + - "Extension package dry runs" + +4) .github/workflows/release.yml — add this build line to the PREPARE job + (verify:release-matrix ENFORCES it, so a forgotten line fails CI): + + pnpm --filter ${pkgName} build +${ + deps.length + ? ` + Runtime deps build first, so place it AFTER: ${deps.map(d => `pnpm --filter @codexo/exojs-${d} build`).join(', ')}.` + : '' +}${ + deps.filter(d => d !== 'tilemap').length + ? ` +5) packages/exojs-config/rollup/index.js — add a \`corePaths\` declaration entry for + each non-tilemap runtime dep so the build's .d.ts emit resolves it: +${deps + .filter(d => d !== 'tilemap') + .map(d => ` '@codexo/exojs-${d}': ['../exojs-${d}/dist/esm/index.d.ts'],`) + .join('\n')} +` + : '' +} +${deps.filter(d => d !== 'tilemap').length ? '6' : '5'}) npm / OIDC bootstrap BEFORE the first release (see scripts/release/RELEASING.md + "Adding a NEW package to the lockstep set"): + - Publish a one-off placeholder manually (e.g. ${coreVersion}-next.0 via \`npm login\` + \`npm publish\`). + Trusted Publishing (OIDC) cannot publish a package that does not yet exist on npm. + - Create its Trusted Publisher config on npmjs.com: repo Exoridus/ExoJS, + workflow release.yml, no environment, enable the "publish" action. + - The generated package.json already carries the required \`repository.directory\` + field (npm publish --provenance / verify:release-matrix require it). +──────────────────────────────────────────────────────────────────────────────── +`); diff --git a/scripts/exo-full.entry.ts b/scripts/exo-full.entry.ts new file mode 100644 index 00000000..86de449e --- /dev/null +++ b/scripts/exo-full.entry.ts @@ -0,0 +1,51 @@ +// Full-bundle entry — bundles core + all extension packages into a single IIFE. +// Global name: Exo (i.e. window.Exo after a @@ -265,7 +290,12 @@ const sidebarParts = GUIDE_PARTS.map(part => ({ display: none; } - .chapter-prose :global(h2) { + /* Typographic rules are scoped to .chapter-content (the rendered MDX body), + not .chapter-prose (the whole article). The article also wraps layout + chrome — GuideMeta, Related API, the pager — which carry their own styles; + scoping to the body keeps the prose h2 (#-prefix, 56px lead), list, and + link styles from bleeding into those boxes. */ + .chapter-content :global(h2) { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; @@ -275,7 +305,7 @@ const sidebarParts = GUIDE_PARTS.map(part => ({ position: relative; } - .chapter-prose :global(h2)::before { + .chapter-content :global(h2)::before { content: '#'; font-family: var(--f-mono); color: var(--fg-faint); @@ -284,40 +314,40 @@ const sidebarParts = GUIDE_PARTS.map(part => ({ margin-right: 8px; } - .chapter-prose :global(h3) { + .chapter-content :global(h3) { font-size: 16px; font-weight: 600; margin: var(--s-7) 0 var(--s-3); } - .chapter-prose :global(p) { + .chapter-content :global(p) { margin: var(--s-3) 0; line-height: 1.65; color: var(--fg); text-wrap: pretty; } - .chapter-prose :global(li) { + .chapter-content :global(li) { margin: 4px 0; line-height: 1.65; } - .chapter-prose :global(ul, ol) { + .chapter-content :global(ul, ol) { margin: var(--s-3) 0; padding-left: var(--s-5); line-height: 1.65; } - .chapter-prose :global(p.muted) { + .chapter-content :global(p.muted) { color: var(--fg-muted); } - .chapter-prose :global(pre.astro-code) { + .chapter-content :global(pre.astro-code) { margin: var(--s-4) 0; border-color: var(--line-soft); } - .chapter-prose :global(hr) { + .chapter-content :global(hr) { border: none; border-top: 1px solid var(--line-soft); margin: var(--s-9) 0; @@ -325,16 +355,16 @@ const sidebarParts = GUIDE_PARTS.map(part => ({ /* Underline prose links only — component links (TryIt, NextStep, pager, example/source embeds) keep their own styling. */ - .chapter-prose :global(p a), - .chapter-prose :global(li a) { + .chapter-content :global(p a), + .chapter-content :global(li a) { color: var(--fg); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent), transparent 55%); text-underline-offset: 0.2em; } - .chapter-prose :global(p a:hover), - .chapter-prose :global(li a:hover) { + .chapter-content :global(p a:hover), + .chapter-content :global(li a:hover) { color: var(--accent); text-decoration-color: var(--accent); } diff --git a/site/src/pages/en/index.astro b/site/src/pages/en/index.astro index d6d82047..0fa8a9d5 100644 --- a/site/src/pages/en/index.astro +++ b/site/src/pages/en/index.astro @@ -1,6 +1,7 @@ --- import AppShell from '../../layouts/AppShell.astro'; import { Code } from 'astro:components'; +import ExampleThumb from '../../components/ExampleThumb.astro'; import { GuideExamplePreview } from '../../components/GuideExamplePreview'; import { appInfo } from '../../lib/app-info'; import { getAllExamples, getExamplesForChapter } from '../../lib/examples-catalog'; @@ -19,48 +20,38 @@ const heroExample = getExamplesForChapter('particles').find(entry => entry.slug const heroExampleTitle = heroExample?.title ?? 'Bonfire'; const heroExampleSource = getExampleExecutionSource('particles', 'bonfire'); -const heroSnippet = `import { - Application, Keyboard, Scene, - Sprite, Texture, -} from '@codexo/exojs'; +const heroSnippet = `import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs'; -class PlayerScene extends Scene { - private hero!: Sprite; +class HeroScene extends Scene { + private ship!: Sprite; async load(loader) { - await loader.load(Texture, { hero: 'images/hero.png' }); + this.ship = new Sprite(await loader.load(Texture, 'ship.png')); } - init(loader) { - const { width, height } = this.app!.canvas; + init() { + const { width, height } = this.app.canvas; + this.ship.setAnchor(0.5).setPosition(width / 2, height / 2); - this.hero = new Sprite(loader.get(Texture, 'hero')); - this.hero.setAnchor(0.5); - this.hero.setPosition(width / 2, height / 2); - - this.inputs.onActive(Keyboard.Left, () => - this.hero.move(-3, 0), - ); - this.inputs.onActive(Keyboard.Right, () => - this.hero.move(3, 0), - ); + // Pulse and spin — two tweens running in parallel. + this.app.tweens.create(this.ship.scale) + .to({ x: 1.4, y: 1.4 }, 0.9).yoyo(true).repeat(-1).start(); + this.app.tweens.create(this.ship) + .to({ rotation: Math.PI }, 1.8).yoyo(true).repeat(-1).start(); } draw(context) { context.backend.clear(); - context.render(this.hero); + context.render(this.ship); } } const app = new Application({ - canvas: { - width: 960, - height: 540, - pixelRatio: window.devicePixelRatio, - }, + canvas: { width: 960, height: 540 }, + clearColor: Color.black, }); -await app.start(new PlayerScene());`; +app.start(new HeroScene());`; const quickstartSnippet = `import { Application, Scene } from '@codexo/exojs'; @@ -79,42 +70,48 @@ const examples = [ description: 'Per-frame particle emitter with falloff and color ramp.', chapter: 'Particles', tags: ['particles', 'color'], - href: `${import.meta.env.BASE_URL}en/guide/effects/particles/#bonfire`, + anim: 'particles' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=particles/bonfire`, }, { title: 'Chromatic Aberration', description: 'Stack two filters on a layer for analog feel.', chapter: 'Filters', tags: ['filter', 'blend'], - href: `${import.meta.env.BASE_URL}en/guide/effects/filters/#chromatic-aberration`, + anim: 'chroma' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=filters/chromatic-aberration`, }, { title: 'Beat Sync Pulse', description: 'Drive scene scale from FFT bands.', chapter: 'Audio FX', tags: ['audio', 'fft'], - href: `${import.meta.env.BASE_URL}en/guide/audio/beat-detection/#beat-sync-pulse`, + anim: 'fft' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=beat-detection/beat-sync-pulse`, }, { title: 'Sprite Sheet', description: 'Animate from a strip with frame timing.', chapter: 'Sprites & Textures', tags: ['sprite', 'anim'], - href: `${import.meta.env.BASE_URL}en/guide/rendering/sprites/#spritesheet-frames`, + anim: 'frames' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=sprites-textures/spritesheet-frames`, }, { title: 'Easing Curves', description: 'Tween any numeric property with built-in easings.', chapter: 'Tweens & Animation', tags: ['tween', 'easing'], - href: `${import.meta.env.BASE_URL}en/guide/rendering/animation/#easing-curves`, + anim: 'easing' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=tweens-animation/easing-curves`, }, { title: 'Camera And View', description: 'Pan and zoom with View; world vs screen coords.', chapter: 'Application & Scenes', tags: ['scene', 'view'], - href: `${import.meta.env.BASE_URL}en/guide/runtime/coordinates-and-views/#camera-and-view`, + anim: 'camera' as const, + href: `${import.meta.env.BASE_URL}en/playground/?example=application-scenes/camera-and-view`, }, ]; --- @@ -123,7 +120,7 @@ const examples = [

-
+
v{appInfo.version} {releaseStage} / TypeScript / {appInfo.license} @@ -143,7 +140,7 @@ const examples = [
game.ts - sceneinput + scenetweens
@@ -151,6 +148,16 @@ const examples = [
+
Live — rendering in your browser

{heroExampleTitle}

@@ -206,9 +213,7 @@ const examples = [
{examples.map((example, index) => ( -
- {example.title} -
+
{example.tags.map(tag => ( @@ -277,6 +282,14 @@ const examples = [ mask-image: radial-gradient(120% 80% at 70% 0%, #000 35%, transparent 75%); pointer-events: none; z-index: -1; + animation: hero-grid-drift 26s linear infinite; + } + + /* Slowly drift the grid by exactly one cell so the loop is seamless. */ + @keyframes hero-grid-drift { + to { + background-position: 63px 63px; + } } .hero::after { @@ -289,9 +302,23 @@ const examples = [ background: radial-gradient(closest-side, var(--accent-soft), transparent 70%); pointer-events: none; z-index: -1; + animation: hero-glow-breathe 9s ease-in-out infinite; + } + + @keyframes hero-glow-breathe { + 0%, + 100% { + opacity: 0.8; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.09); + } } .hero-inner { + position: relative; max-width: 1180px; margin: 0 auto; display: grid; @@ -300,6 +327,45 @@ const examples = [ align-items: center; } + .hero-copy { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + /* Companion mascot — a waving greeter presenting the live example, perched in + the top-right of the live-hero section. Decorative (aria-hidden). */ + .hero-companion { + position: absolute; + top: -58px; + right: var(--s-4); + z-index: 3; + width: clamp(120px, 13vw, 172px); + height: auto; + pointer-events: none; + filter: drop-shadow(0 14px 26px rgba(0, 0, 0, 0.4)); + animation: companion-float 4.6s ease-in-out infinite; + } + + @keyframes companion-float { + 0%, + 100% { + transform: translateY(0) rotate(-0.6deg); + } + 50% { + transform: translateY(-10px) rotate(0.6deg); + } + } + + @media (prefers-reduced-motion: reduce) { + .hero-companion, + .hero::before, + .hero::after, + .cap-icon { + animation: none; + } + } + .hero-eyebrow { display: inline-flex; align-items: center; @@ -520,6 +586,7 @@ const examples = [ } .live-hero { + position: relative; max-width: 1180px; margin: var(--s-10) auto 0; padding: 0 var(--s-7); @@ -607,6 +674,28 @@ const examples = [ font-size: 13px; font-family: var(--f-mono); font-weight: 700; + animation: cap-pulse 4.8s ease-in-out infinite; + } + + /* Staggered glow travelling across the four feature icons. */ + .cap:nth-child(2) .cap-icon { + animation-delay: 1.2s; + } + .cap:nth-child(3) .cap-icon { + animation-delay: 2.4s; + } + .cap:nth-child(4) .cap-icon { + animation-delay: 3.6s; + } + + @keyframes cap-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 transparent; + } + 50% { + box-shadow: 0 0 16px 1px var(--accent-soft); + } } .cap h4 { @@ -680,48 +769,6 @@ const examples = [ transform: translateY(-2px); } - .ex-thumb { - aspect-ratio: 16/10; - background: var(--bg-code); - position: relative; - overflow: hidden; - display: grid; - place-items: center; - } - - .ex-thumb::before { - content: ''; - position: absolute; - inset: 0; - background-image: - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: 28px 28px; - opacity: 0.55; - } - - .ex-thumb::after { - content: ''; - position: absolute; - width: 64%; - height: 64%; - border-radius: 16px; - background: radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--accent), transparent 35%), transparent 70%); - opacity: 0.35; - } - - .ex-thumb__label { - position: relative; - z-index: 1; - font-family: var(--f-mono); - font-size: 12px; - color: oklch(86% 0.01 245); - padding: 4px 10px; - border-radius: var(--r-pill); - border: 1px solid oklch(40% 0.015 245); - background: oklch(16% 0.012 245 / 0.75); - } - .ex-meta { padding: var(--s-4) var(--s-5); display: flex; @@ -887,6 +934,13 @@ const examples = [ padding: var(--s-8) var(--s-4) var(--s-7); } + /* Shrink the live-hero greeter so it never crowds the heading on phones. */ + .hero-companion { + width: 92px; + top: -32px; + right: var(--s-3); + } + .hero-inner { gap: var(--s-6); } diff --git a/site/src/styles/global.scss b/site/src/styles/global.scss index 5f5e8c00..94abce53 100644 --- a/site/src/styles/global.scss +++ b/site/src/styles/global.scss @@ -81,18 +81,16 @@ kbd { line-height: 1.68; } +// Code blocks always sit on the dark `--color-code-bg` surface (dark in both +// themes, see the background rule above), so the syntax colours must always be +// the dark-theme set — `--shiki-light` is the light-on-light variant and would +// render dark text on the dark panel (unreadable in the light site theme). +// Dual-theme ``/markdown output still defines `--shiki-dark`; we just pin +// to it regardless of the site's data-theme. :where(pre.astro-code, pre.astro-code span) { color: var(--shiki-dark) !important; } -html[data-theme='light'] :where(pre.astro-code, pre.astro-code span) { - color: var(--shiki-light) !important; -} - -html[data-theme='dark'] :where(pre.astro-code, pre.astro-code span) { - color: var(--shiki-dark) !important; -} - p :where(code), li :where(code), h1 :where(code), diff --git a/src/animation/TweenManager.ts b/src/animation/TweenManager.ts index 5422e8d1..9b3770f9 100644 --- a/src/animation/TweenManager.ts +++ b/src/animation/TweenManager.ts @@ -2,6 +2,12 @@ import type { System } from '#core/System'; import type { Time } from '#core/Time'; import { Tween } from './Tween'; +import { TweenSequencer } from './TweenSequencer'; + +/** Any object that can be driven each frame by a delta in seconds. @internal */ +interface Ticker { + update(deltaSeconds: number): void; +} /** * Owns and advances a collection of {@link Tween} instances, driving them @@ -9,6 +15,9 @@ import { Tween } from './Tween'; * automatically; manually constructed tweens can be opted in via * {@link TweenManager.add}. * + * Custom updatables (such as {@link TweenSequencer}) can be registered via + * {@link TweenManager.addTicker} so they share the same frame tick. + * * Update iteration uses a snapshot so callbacks may freely add or remove * tweens during the same frame without corrupting the loop. Completed and * stopped tweens are evicted automatically. @@ -18,6 +27,7 @@ export class TweenManager implements System { /** App-systems tick band — tweens after audio. @internal */ public readonly order = 400; private _tweens: Tween[] = []; + private _tickers: Ticker[] = []; private _destroyed = false; /** @@ -65,6 +75,25 @@ export class TweenManager implements System { return first; } + /** + * Create a new {@link TweenSequencer} bound to this manager and return it. + * The sequencer registers itself automatically when {@link TweenSequencer.start} + * is called, so no manual wiring is needed. + * + * @example + * ```ts + * scene.tweens.createSequencer() + * .then(fadeIn) + * .wait(0.5) + * .then([moveLeft, scaleUp]) + * .onComplete(() => console.log('done')) + * .start(); + * ``` + */ + public createSequencer(): TweenSequencer { + return new TweenSequencer(this); + } + /** * Explicitly add a stand-alone Tween (created via `new Tween(target)`) * to this manager so it participates in the update loop. @@ -91,9 +120,38 @@ export class TweenManager implements System { } /** - * Advance all active tweens by the frame `delta` (read as seconds). Ticked - * once per frame via {@link Application.systems}. Uses a snapshot of the list - * so that callbacks that add or remove tweens do not corrupt mid-iteration. + * Register a custom updatable so it is driven each frame alongside tweens. + * Idempotent — registering the same ticker twice is a no-op. + * + * Used internally by {@link TweenSequencer}. + */ + public addTicker(ticker: Ticker): this { + if (!this._tickers.includes(ticker)) { + this._tickers.push(ticker); + } + + return this; + } + + /** + * Remove a previously registered ticker. Called automatically by + * {@link TweenSequencer} when it completes or is stopped. + */ + public removeTicker(ticker: Ticker): this { + const index = this._tickers.indexOf(ticker); + + if (index !== -1) { + this._tickers.splice(index, 1); + } + + return this; + } + + /** + * Advance all active tweens by the frame `delta` (read as seconds), then + * advance all registered tickers. Ticked once per frame via + * {@link Application.systems}. Uses snapshots so callbacks that add or + * remove tweens/tickers do not corrupt mid-iteration. */ public update(delta: Time): void { if (this._destroyed) return; @@ -103,19 +161,26 @@ export class TweenManager implements System { for (const tween of snapshot) { tween.update(delta.seconds); } + + const tickerSnapshot = [...this._tickers]; + + for (const ticker of tickerSnapshot) { + ticker.update(delta.seconds); + } } /** - * Remove all tweens immediately. No callbacks (onComplete etc.) fire. + * Remove all tweens and tickers immediately. No callbacks fire. * The tweens' states are left as-is; they are simply evicted from the list. */ public clear(): this { this._tweens = []; + this._tickers = []; return this; } - /** Tear down the manager. Clears tweens and makes subsequent updates no-ops. */ + /** Tear down the manager. Clears tweens and tickers and makes subsequent updates no-ops. */ public destroy(): void { this.clear(); this._destroyed = true; diff --git a/src/animation/TweenSequencer.ts b/src/animation/TweenSequencer.ts new file mode 100644 index 00000000..41149ac8 --- /dev/null +++ b/src/animation/TweenSequencer.ts @@ -0,0 +1,350 @@ +import type { Tween } from './Tween'; +import type { TweenManager } from './TweenManager'; +import { TweenState } from './types'; + +interface TweenStage { + readonly type: 'tweens'; + readonly tweens: readonly Tween[]; +} + +interface DelayStage { + readonly type: 'delay'; + readonly seconds: number; +} + +type Stage = TweenStage | DelayStage; + +/** + * Lifecycle states of a {@link TweenSequencer}. Mirrors {@link TweenState} + * semantics: starts `Idle`, becomes `Active` on {@link TweenSequencer.start}, + * and ends in `Complete` (all stages exhausted) or `Stopped` (cancelled via + * {@link TweenSequencer.stop}). `Paused` is reachable from `Active` only. + */ +export enum TweenSequencerState { + Idle = 'idle', + Active = 'active', + Paused = 'paused', + Complete = 'complete', + Stopped = 'stopped', +} + +/** + * Composes multiple {@link Tween} instances into a multi-stage animation. + * + * Each stage added via {@link TweenSequencer.then} plays after the previous + * one finishes. Within a single stage, multiple tweens run simultaneously + * (parallel); the stage advances when **all** of them complete. + * + * Delay stages inserted via {@link TweenSequencer.wait} create a timed pause + * between stages without needing a dummy tween. + * + * The sequencer integrates with {@link TweenManager} via + * {@link TweenManager.addTicker} so it is driven automatically each frame. + * It can also be used stand-alone by calling {@link TweenSequencer.update} + * manually — in that mode the sequencer also advances its child tweens. + * + * @example + * ```ts + * app.tweens.createSequencer() + * .then(fadeIn) + * .wait(0.5) + * .then([moveLeft, scaleUp]) + * .then(fadeOut) + * .onComplete(() => console.log('done!')) + * .start(); + * ``` + * @stable + */ +export class TweenSequencer { + private readonly _stages: Stage[] = []; + private _state: TweenSequencerState = TweenSequencerState.Idle; + private readonly _manager: TweenManager | null; + + /** Index into `_stages` for the current pass (0-based). */ + private _currentStageIndex = 0; + + /** 1 = forward through stages, -1 = reversed (yoyo pass). */ + private _direction: 1 | -1 = 1; + + /** Remaining repeat cycles. -1 = infinite. */ + private _repeatCount = 0; + /** The value configured by {@link TweenSequencer.repeat}. Preserved for restart. */ + private _repeatTotal = 0; + private _yoyo = false; + + /** Accumulated seconds within the current delay stage. */ + private _delayElapsed = 0; + + private _onStartCb: (() => void) | null = null; + private _onCompleteCb: (() => void) | null = null; + private _startFired = false; + + public constructor(manager?: TweenManager) { + this._manager = manager ?? null; + } + + // ── State ──────────────────────────────────────────────────────────────── + + /** Current lifecycle state of the sequencer. */ + public get state(): TweenSequencerState { + return this._state; + } + + /** + * Playback progress in 0..1, advancing in discrete steps as each stage + * completes. Equals `1` when the entire sequence has finished. + */ + public get progress(): number { + const n = this._stages.length; + if (n === 0) return 1; + return Math.min(this._currentStageIndex / n, 1); + } + + // ── Builder ────────────────────────────────────────────────────────────── + + /** + * Append a stage to the sequence. + * + * - **Single tween**: the stage plays that tween, then advances. + * - **Array of tweens**: all start simultaneously; the stage advances when + * every tween in the array has completed. + */ + public then(tween: Tween | Tween[]): this { + const tweens = Array.isArray(tween) ? tween : [tween]; + this._stages.push({ type: 'tweens', tweens }); + return this; + } + + /** Insert a fixed pause of `seconds` between stages. */ + public wait(seconds: number): this { + this._stages.push({ type: 'delay', seconds }); + return this; + } + + /** + * Number of additional repeat cycles. -1 = infinite. Default 0 (plays once). + * + * `repeat(2)` plays the full sequence three times total (initial pass + 2 + * repeats). + */ + public repeat(count: number): this { + this._repeatTotal = count; + return this; + } + + /** + * Reverse stage order on each repeat cycle. Only meaningful when combined + * with {@link TweenSequencer.repeat}. + */ + public yoyo(enabled = true): this { + this._yoyo = enabled; + return this; + } + + /** + * Register a callback fired once on the first {@link TweenSequencer.update} + * call after {@link TweenSequencer.start}. + */ + public onStart(cb: () => void): this { + this._onStartCb = cb; + return this; + } + + /** + * Register a callback fired when the sequence finishes naturally (all stages + * and repeat cycles exhausted). Does NOT fire when stopped via + * {@link TweenSequencer.stop}. + */ + public onComplete(cb: () => void): this { + this._onCompleteCb = cb; + return this; + } + + // ── Control ────────────────────────────────────────────────────────────── + + /** + * Start (or restart) the sequence from stage 0. Resets all internal state + * and re-registers with the manager if one is attached. + */ + public start(): this { + this._state = TweenSequencerState.Active; + this._currentStageIndex = 0; + this._direction = 1; + this._repeatCount = this._repeatTotal; + this._startFired = false; + this._manager?.addTicker(this); + this._startCurrentStage(); + return this; + } + + /** + * Pause the sequence. Tweens in the current stage are also paused. The + * elapsed timer in delay stages is frozen. + */ + public pause(): this { + if (this._state === TweenSequencerState.Active) { + this._state = TweenSequencerState.Paused; + this._pauseCurrentStageTweens(); + } + return this; + } + + /** Resume a paused sequence (and its current-stage tweens) from where they left off. */ + public resume(): this { + if (this._state === TweenSequencerState.Paused) { + this._state = TweenSequencerState.Active; + this._resumeCurrentStageTweens(); + } + return this; + } + + /** + * Stop the sequence without finishing. Active tweens are stopped. + * {@link TweenSequencer.onComplete} does NOT fire. The sequencer is removed + * from its manager if one is assigned. + */ + public stop(): this { + if (this._state === TweenSequencerState.Active || this._state === TweenSequencerState.Paused) { + this._state = TweenSequencerState.Stopped; + this._stopCurrentStageTweens(); + this._manager?.removeTicker(this); + } + return this; + } + + // ── Ticker ─────────────────────────────────────────────────────────────── + + /** + * Advance the sequencer by `deltaSeconds`. Called automatically by the + * attached {@link TweenManager} each frame. When used stand-alone (no + * manager), call this manually and child tweens will also be advanced. + */ + public update(deltaSeconds: number): void { + if (this._state !== TweenSequencerState.Active) return; + + if (!this._startFired) { + this._startFired = true; + this._onStartCb?.(); + } + + if (this._stages.length === 0) { + this._finish(); + return; + } + + const stageIndex = this._getActualStageIndex(); + const stage = this._stages[stageIndex]; + if (stage === undefined) return; + + if (stage.type === 'delay') { + this._delayElapsed += deltaSeconds; + if (this._delayElapsed >= stage.seconds) { + this._advanceStage(); + } + } else { + // In stand-alone mode (no manager), the sequencer ticks tweens itself. + if (this._manager === null) { + for (const tween of stage.tweens) { + if (tween.state === TweenState.Active) { + tween.update(deltaSeconds); + } + } + } + + // Advance as soon as every tween in this stage has settled. + const allDone = stage.tweens.every(t => t.state === TweenState.Complete || t.state === TweenState.Stopped); + + if (allDone) { + this._advanceStage(); + } + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + /** + * Map the logical `_currentStageIndex` to the real index in `_stages`, + * accounting for yoyo reversal. + */ + private _getActualStageIndex(): number { + if (this._direction === 1) return this._currentStageIndex; + return this._stages.length - 1 - this._currentStageIndex; + } + + private _startCurrentStage(): void { + const stageIndex = this._getActualStageIndex(); + const stage = this._stages[stageIndex]; + if (stage === undefined) return; + + this._delayElapsed = 0; + + if (stage.type === 'tweens') { + for (const tween of stage.tweens) { + if (this._manager !== null) { + // Register with manager so the manager ticks it each frame. + this._manager.add(tween); + } + tween.start(); + } + } + // Delay stages need only the elapsed counter reset (done above). + } + + private _advanceStage(): void { + this._currentStageIndex++; + + if (this._currentStageIndex >= this._stages.length) { + // All stages in this pass are done. + const hasMoreRepeats = this._repeatCount === -1 || this._repeatCount > 0; + + if (hasMoreRepeats) { + if (this._repeatCount !== -1) { + this._repeatCount--; + } + + if (this._yoyo) { + this._direction = this._direction === 1 ? -1 : 1; + } + + this._currentStageIndex = 0; + this._startCurrentStage(); + } else { + this._finish(); + } + } else { + this._startCurrentStage(); + } + } + + private _finish(): void { + this._state = TweenSequencerState.Complete; + this._manager?.removeTicker(this); + this._onCompleteCb?.(); + } + + private _getCurrentStageTweens(): readonly Tween[] { + if (this._stages.length === 0) return []; + const stageIndex = this._getActualStageIndex(); + const stage = this._stages[stageIndex]; + if (stage?.type === 'tweens') return stage.tweens; + return []; + } + + private _pauseCurrentStageTweens(): void { + for (const tween of this._getCurrentStageTweens()) { + tween.pause(); + } + } + + private _resumeCurrentStageTweens(): void { + for (const tween of this._getCurrentStageTweens()) { + tween.resume(); + } + } + + private _stopCurrentStageTweens(): void { + for (const tween of this._getCurrentStageTweens()) { + tween.stop(); + } + } +} diff --git a/src/animation/index.ts b/src/animation/index.ts index d86aca66..a218f212 100644 --- a/src/animation/index.ts +++ b/src/animation/index.ts @@ -2,5 +2,6 @@ export type { EasingFunction } from './Easing'; export { Ease } from './Easing'; export { Tween } from './Tween'; export { TweenManager } from './TweenManager'; +export { TweenSequencer, TweenSequencerState } from './TweenSequencer'; export type { TweenLifecycleCallback, TweenUpdateCallback } from './types'; export { TweenState } from './types'; diff --git a/src/core/Application.ts b/src/core/Application.ts index 233f1abe..0750e8cb 100644 --- a/src/core/Application.ts +++ b/src/core/Application.ts @@ -25,6 +25,7 @@ import { Loader, type LoaderOptions } from '#resources/Loader'; import { Capabilities } from './capabilities'; import { Clock } from './Clock'; import { Color } from './Color'; +import { FixedTimestep } from './FixedTimestep'; import { computeLetterboxLayout } from './letterbox'; import type { Scene } from './Scene'; import { SceneManager } from './SceneManager'; @@ -111,6 +112,11 @@ export interface ApplicationOptions { input?: InputApplicationOptions; /** Seed for the per-Application {@link Application.random} RNG. Omit for a non-deterministic seed. */ seed?: number; + /** + * Fixed-timestep size in **seconds** for {@link Scene.fixedUpdate} / {@link Application.onFixedFrame}. + * Default `1 / 60`. Must be positive. + */ + fixedTimeStep?: number; /** * Extension selection. * `undefined` → Core + globally registered extensions. @@ -137,6 +143,10 @@ export interface AutoBackendConfig { export type BackendConfig = AutoBackendConfig | WebGl2BackendConfig | WebGpuBackendConfig; const maxDeltaMs = 100; +/** Default fixed-timestep size in milliseconds (60 Hz). */ +const defaultFixedStepMs = 1000 / 60; +/** Max fixed steps run in one frame — the spiral-of-death guard. */ +const maxFixedSteps = 5; const createDefaultCanvas = (): HTMLCanvasElement => document.createElement('canvas'); @@ -242,16 +252,23 @@ export class Application { public readonly serializers = new SerializationRegistry(defaultSerializationRegistry); public readonly onResize = new Signal<[number, number, Application]>(); public readonly onFrame = new Signal<[Time]>(); + /** Dispatched once per fixed-timestep step (zero or more times per frame), ahead of {@link onFrame}. */ + public readonly onFixedFrame = new Signal<[Time]>(); public readonly onCanvasFocusChange = new Signal<[focused: boolean]>(); public readonly onVisibilityChange = new Signal<[visible: boolean]>(); public readonly onBackendLost = new Signal(); public readonly onBackendRestored = new Signal(); + /** Dispatched when an unhandled error occurs in scene lifecycle. */ + public readonly onError = new Signal<[error: Error]>(); public pauseOnHidden = false; private readonly _updateHandler: () => void; private readonly _startupClock: Clock = new Clock(); private readonly _activeClock: Clock = new Clock(); private readonly _frameClock: Clock = new Clock(); + private readonly _fixed: FixedTimestep; + private readonly _fixedTime: Time; + private _frameAlpha = 0; private _status: ApplicationStatus = ApplicationStatus.Stopped; private _pixelRatio: number = defaultCanvasSettings.pixelRatio; @@ -268,6 +285,7 @@ export class Application { private _cursor = 'default'; private readonly _visibilityChangeHandler = this._onDocumentVisibilityChange.bind(this); private _resizeObserver: ResizeObserver | null = null; + private _sizingMode: CanvasSizingMode = 'fixed'; private readonly _audio: AudioManager = new AudioManager(); public constructor(appSettings: ApplicationOptions = {}) { @@ -296,7 +314,8 @@ export class Application { } this._mountCanvas(canvasOptions.mount); - this._applySizingMode(canvasOptions.sizingMode ?? 'fixed'); + this._sizingMode = canvasOptions.sizingMode ?? 'fixed'; + this._applySizingMode(this._sizingMode); this.options = { clearColor: appSettings.clearColor ?? Color.cornflowerBlue, @@ -355,6 +374,11 @@ export class Application { this.random = new Random(this.options.seed); this._updateHandler = this.update.bind(this); + const fixedStepMs = this.options.fixedTimeStep !== undefined ? this.options.fixedTimeStep * 1000 : defaultFixedStepMs; + + this._fixed = new FixedTimestep(fixedStepMs, maxFixedSteps); + this._fixedTime = new Time(fixedStepMs); + // Register the core managers as ordered app systems (reserved order bands); // they tick from one loop, ahead of any user systems (order 600+). this.systems.add(this.input); @@ -399,6 +423,21 @@ export class Application { return this._frameCount; } + /** + * Interpolation factor `[0, 1)` — the leftover sub-step fraction after this + * frame's fixed steps. Lerp rendered state between its previous and current + * fixed-step values by this to smooth motion when the fixed rate is below the + * frame rate. + */ + public get frameAlpha(): number { + return this._frameAlpha; + } + + /** Fixed-timestep size in seconds (see {@link ApplicationOptions.fixedTimeStep}). */ + public get fixedTimeStep(): number { + return this._fixed.stepMs / 1000; + } + /** * Low-level render backend. Prefer the high-level * {@link Application.rendering} render context for normal rendering. @@ -449,6 +488,39 @@ export class Application { this.setCursor(cursor); } + /** + * The active {@link CanvasSizingMode}. Assigning a new mode re-applies the + * sizing strategy live: the previous mode's {@link ResizeObserver} (if any) + * is disconnected and the new mode's CSS / observer is installed. Assigning + * the current value is a no-op. + */ + public get sizingMode(): CanvasSizingMode { + return this._sizingMode; + } + + public set sizingMode(mode: CanvasSizingMode) { + if (mode === this._sizingMode) { + return; + } + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + this._sizingMode = mode; + this._applySizingMode(mode); + } + + /** + * The colour the canvas is cleared to each frame, as a live {@link Color}. + * Assigning copies into the backend's clear colour (effective next frame); + * you may also mutate it in place via `app.clearColor.set(...)`. + */ + public get clearColor(): Color { + return this._backend.clearColor; + } + + public set clearColor(color: Color) { + this._backend.clearColor.copy(color); + } + public get audio(): AudioManager { return this._audio; } @@ -529,6 +601,7 @@ export class Application { await this.scene.setScene(scene); this._frameRequest = requestAnimationFrame(this._updateHandler); this._frameClock.restart(); + this._fixed.reset(); this._activeClock.start(); this._status = ApplicationStatus.Running; } catch (error) { @@ -568,6 +641,7 @@ export class Application { if (this._status === ApplicationStatus.Running) { if (this.pauseOnHidden && !this._documentVisible) { this._frameClock.restart(); + this._fixed.reset(); this._frameRequest = requestAnimationFrame(this._updateHandler); return this; @@ -583,6 +657,17 @@ export class Application { this.systems._tick(frameDelta); + // Fixed-timestep steps (0..N) for deterministic logic/physics, after input + // so they see this frame's input and before the variable update/draw. + const fixedSteps = this._fixed.advance(clampedDeltaMs); + + for (let step = 0; step < fixedSteps; step++) { + this.scene.fixedUpdate(this._fixedTime); + this.onFixedFrame.dispatch(this._fixedTime); + } + + this._frameAlpha = this._fixed.alpha; + this.scene.update(frameDelta); this.onFrame.dispatch(frameDelta); this.backend.flush(); @@ -606,6 +691,7 @@ export class Application { cancelAnimationFrame(this._frameRequest); void this.scene.setScene(null).catch((error: unknown) => { console.error('Application.stop() failed to unload the active scene.', error); + this.onError?.dispatch(error instanceof Error ? error : new Error(String(error))); }); this._activeClock.stop(); this._frameClock.stop(); @@ -795,10 +881,12 @@ export class Application { this._frameClock.destroy(); this.onResize.destroy(); this.onFrame.destroy(); + this.onFixedFrame.destroy(); this.onCanvasFocusChange.destroy(); this.onVisibilityChange.destroy(); this.onBackendLost.destroy(); this.onBackendRestored.destroy(); + this.onError.destroy(); } private _onDocumentVisibilityChange(): void { diff --git a/src/core/Codec.ts b/src/core/Codec.ts new file mode 100644 index 00000000..af134571 --- /dev/null +++ b/src/core/Codec.ts @@ -0,0 +1,84 @@ +/** + * Binary codec helpers: base64 decoding and stream decompression. + * + * These are zero-dependency wrappers over platform primitives (`atob` and the + * native `DecompressionStream`), useful for save games, embedded binary blobs, + * network payloads, and inline-encoded asset data (e.g. base64/gzip tile-layer + * data in Tiled maps). + * + * Decompression is async because `DecompressionStream` is a streaming API. + * Only the formats the platform implements natively are supported — notably + * **not** `zstd`, which has no native browser decoder. + */ + +/** Compression formats supported by the native `DecompressionStream`. */ +export type DecompressFormat = 'gzip' | 'deflate' | 'deflate-raw'; + +/** + * Decode a standard base64 string into raw bytes. Whitespace (newlines, spaces) + * is ignored, so multi-line base64 blocks decode correctly. + * + * @throws If the input is not valid base64. + */ +function decodeBase64(input: string): Uint8Array { + const clean = input.replaceAll(/\s+/g, ''); + const binary = atob(clean); + const length = binary.length; + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Decompress a byte buffer using the native `DecompressionStream`. + * + * - `'gzip'` — gzip container (RFC 1952). + * - `'deflate'` — zlib container (RFC 1950), Tiled's `zlib` compression. + * - `'deflate-raw'` — raw DEFLATE with no header (RFC 1951). + * + * @throws If the platform lacks `DecompressionStream`, or the data is corrupt. + */ +async function decompress(bytes: Uint8Array, format: DecompressFormat): Promise { + if (typeof DecompressionStream === 'undefined') { + throw new Error('Codec.decompress requires the native DecompressionStream API.'); + } + + // Feed the input through the decompressor as a single chunk, then drain the + // output by reading the stream directly. Avoids Blob/Response so the helper + // works uniformly across browsers, Node, and jsdom test environments. + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes as BufferSource); + controller.close(); + }, + }); + const reader = source.pipeThrough(new DecompressionStream(format)).getReader(); + + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** + * Binary codec facade, grouped as a namespace so the public API carries no + * loose codec functions. + */ +export const Codec = { + decodeBase64, + decompress, +} as const; diff --git a/src/core/FixedTimestep.ts b/src/core/FixedTimestep.ts new file mode 100644 index 00000000..fad4d080 --- /dev/null +++ b/src/core/FixedTimestep.ts @@ -0,0 +1,68 @@ +/** + * Fixed-timestep accumulator (Gaffer's "Fix Your Timestep"). Converts the + * variable per-frame delta into a whole number of fixed-size steps, carrying the + * sub-step remainder across frames as an interpolation {@link FixedTimestep.alpha}. + * + * Pure timing logic — no scene or signal coupling — so the loop can drive it and + * tests can exercise the step/alpha maths directly. The {@link maxSteps} cap is + * the spiral-of-death guard: when a frame is so long it would need more than + * `maxSteps` catch-up steps, the surplus whole-step backlog is dropped rather + * than accumulated (which would make the next frame even longer). + * + * @internal + */ +export class FixedTimestep { + private _accumulatorMs = 0; + + public constructor( + private readonly _stepMs: number, + private readonly _maxSteps: number, + ) { + if (!(_stepMs > 0) || !Number.isFinite(_stepMs)) { + throw new RangeError(`FixedTimestep: step must be a positive finite number of ms, received ${_stepMs}.`); + } + } + + /** The fixed step size in milliseconds. */ + public get stepMs(): number { + return this._stepMs; + } + + /** Leftover fraction `[0, 1)` of a step — the render interpolation factor. */ + public get alpha(): number { + return this._accumulatorMs / this._stepMs; + } + + /** Add a frame's elapsed time and return how many fixed steps to run now (capped at `maxSteps`). */ + public advance(frameDeltaMs: number): number { + this._accumulatorMs += frameDeltaMs; + + // Tolerance so an accumulator that lands a rounding-error below a whole + // multiple of the step (e.g. 3×step) still yields that many steps. + const epsilon = this._stepMs * 1e-9; + let steps = 0; + + while (this._accumulatorMs >= this._stepMs - epsilon && steps < this._maxSteps) { + this._accumulatorMs -= this._stepMs; + steps++; + } + + // Capped: drop the whole-step backlog, keep only the sub-step remainder so + // alpha stays in [0, 1) and the next frame does not replay the lost time. + if (this._accumulatorMs > this._stepMs) { + this._accumulatorMs %= this._stepMs; + } + + // Clamp the tiny negative the epsilon subtraction can leave. + if (this._accumulatorMs < 0) { + this._accumulatorMs = 0; + } + + return steps; + } + + /** Clear the accumulator (e.g. on start/resume so a paused gap is not caught up). */ + public reset(): void { + this._accumulatorMs = 0; + } +} diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 4a71809c..09af6704 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -10,6 +10,7 @@ import type { Application } from './Application'; import { DisposalScope } from './DisposalScope'; import { deserializeInto, migrate, serializeTree } from './serialization/serialize'; import { SERIALIZATION_VERSION, type SerializedScene } from './serialization/types'; +import { Signal } from './Signal'; import { SystemRegistry } from './SystemRegistry'; import type { Time } from './Time'; import type { Destroyable } from './types'; @@ -116,6 +117,11 @@ export class Scene { */ public paused = false; + /** Dispatched after the scene finishes loading (after load() and init() complete). */ + public readonly onLoad = new Signal(); + /** Dispatched when the scene is about to be unloaded. */ + public readonly onUnload = new Signal(); + private _inputs: SceneInputs | null = null; private _tweens: SceneTweens | null = null; private _systems: SystemRegistry | null = null; @@ -333,6 +339,18 @@ export class Scene { // override in subclass } + /** + * Fixed-timestep logic hook. Called zero or more times per frame with a + * constant `delta` ({@link Application.fixedTimeStep}) before {@link Scene.update}, + * so physics and deterministic gameplay advance at a frame-rate-independent + * rate. Put `physicsWorld.step(delta)` and movement here; leave camera, UI and + * purely visual work in {@link Scene.update}. Default is a no-op. Override in + * subclass. + */ + public fixedUpdate(_delta: Time): void { + // override in subclass + } + /** * Explicit per-frame rendering entry point. Override to choose * what gets rendered. @@ -368,6 +386,8 @@ export class Scene { this._inputs = null; this._tweens = null; this._systems = null; + this.onLoad.destroy(); + this.onUnload.destroy(); this._root.destroy(); this._app = null; } diff --git a/src/core/SceneManager.ts b/src/core/SceneManager.ts index 2038c5d5..c0d46b29 100644 --- a/src/core/SceneManager.ts +++ b/src/core/SceneManager.ts @@ -186,6 +186,22 @@ export class SceneManager { return this; } + /** + * Drive one fixed-timestep step on the active scene (unless paused). Called + * zero or more times per frame by the {@link Application} loop, ahead of + * {@link SceneManager.update}. No drawing or transition advance happens here — + * those are per-frame, not per fixed step. + */ + public fixedUpdate(delta: Time): this { + const scene = this._activeScene; + + if (scene !== null && !scene.paused) { + scene.fixedUpdate(delta); + } + + return this; + } + public destroy(): void { if (this._transition) { const transition = this._transition; @@ -227,6 +243,8 @@ export class SceneManager { if (ui !== null) { this._app.interaction.attachUIRoot(ui); } + + scene.onLoad.dispatch(); } catch (error) { let cleanupError: unknown = null; @@ -258,6 +276,7 @@ export class SceneManager { } private async _disposeScene(scene: Scene): Promise { + scene.onUnload.dispatch(); this.onStopScene.dispatch(scene); await scene.unload(this._app.loader); diff --git a/src/core/SceneNode.ts b/src/core/SceneNode.ts index 3b18952c..0e3b88bc 100644 --- a/src/core/SceneNode.ts +++ b/src/core/SceneNode.ts @@ -15,9 +15,10 @@ import { Interval } from '#math/Interval'; import type { Line } from '#math/Line'; import { Matrix } from '#math/Matrix'; import { ObservableVector, type ObservableVectorOwner } from '#math/ObservableVector'; +import { Polygon } from '#math/Polygon'; import { Rectangle } from '#math/Rectangle'; import { degreesToRadians, trimRotation } from '#math/utils'; -import type { Vector } from '#math/Vector'; +import { Vector } from '#math/Vector'; import type { Container } from '#rendering/Container'; import type { RenderNode } from '#rendering/RenderNode'; import type { View } from '#rendering/View'; @@ -56,6 +57,9 @@ enum SceneNodeVectorChannel { Anchor, } +/** Shared scratch for the four oriented corners — written then copied into a node's polygon (single-threaded). */ +const orientedCorners = [new Vector(), new Vector(), new Vector(), new Vector()]; + /** * Transform-bearing leaf in the scene-graph hierarchy. Carries position, * rotation, scale, skew, origin, and a 2-component {@link Vector} `anchor` @@ -69,10 +73,10 @@ enum SceneNodeVectorChannel { * for this node and every {@link Container} ancestor up the parent chain. * The caches rebuild lazily on the next read. * - * The fast-path `isAlignedBox` getter reports `true` when the rotation is a - * multiple of 90° and both skew components are zero; in that case the - * (cheaper) AABB-based collision test is used instead of the rotated/skewed - * quad SAT path. + * Collision and hit-testing take the cheaper AABB path when the node's *world* + * box is axis-aligned (its own and any inherited rotation compose to a multiple + * of 90° with no skew) and the oriented-quad SAT path otherwise. The public + * `isAlignedBox` getter reports that predicate for this node's own rotation only. * * `_invalidate*` methods are exported as `public` for friend-class access * from {@link Container} and {@link InteractionManager}; treat them as @@ -109,6 +113,9 @@ export class SceneNode implements Collidable, ObservableVectorOwner { private _parentNode: Container | null = null; private _zIndex = 0; private _cullable = true; + private _cullArea: Rectangle | null = null; + /** Lazily-built oriented bounding box (the local bounds under the global transform) for rotated-node SAT. */ + private _orientedBounds: Polygon | null = null; /** * Optional human-readable identity for this node. Defaults to `null`. @@ -208,6 +215,10 @@ export class SceneNode implements Collidable, ObservableVectorOwner { } } + /** + * When `false`, this node is never culled by the viewport check and is + * always considered in-view. Defaults to `true`. + */ public get cullable(): boolean { return this._cullable; } @@ -216,6 +227,19 @@ export class SceneNode implements Collidable, ObservableVectorOwner { this._cullable = cullable; } + /** + * Custom rectangle used for viewport cull intersection test. + * When set, replaces the default node bounds in cull checks. + * Set to `null` to restore default bounds-based culling. + */ + public get cullArea(): Rectangle | null { + return this._cullArea; + } + + public set cullArea(rect: Rectangle | null) { + this._cullArea = rect; + } + /** * Horizontal skew angle in degrees. Shears the node along the X axis * (positive values lean the top edge right). Combines correctly with @@ -395,15 +419,48 @@ export class SceneNode implements Collidable, ObservableVectorOwner { } public getNormals(): Vector[] { - return this.getBounds().getNormals(); + return this._isWorldAligned() ? this.getBounds().getNormals() : this._orientedBoundsPolygon().getNormals(); } public project(axis: Vector, result: Interval = new Interval()): Interval { - return this.getBounds().project(axis, result); + return this._isWorldAligned() ? this.getBounds().project(axis, result) : this._orientedBoundsPolygon().project(axis, result); + } + + /** + * The node's oriented bounding box: the four local-bounds corners under the + * global transform, as a {@link Polygon}. Used by the SAT collision path so a + * rotated node tests its true oriented axes instead of the loose AABB. Built + * lazily and refreshed in place; only ever reached for non-axis-aligned nodes. + */ + private _orientedBoundsPolygon(): Polygon { + const bounds = this.getLocalBounds(); + const matrix = this.getGlobalTransform(); + + orientedCorners[0]!.set(bounds.left, bounds.top).transform(matrix); + orientedCorners[1]!.set(bounds.right, bounds.top).transform(matrix); + orientedCorners[2]!.set(bounds.right, bounds.bottom).transform(matrix); + orientedCorners[3]!.set(bounds.left, bounds.bottom).transform(matrix); + + return (this._orientedBounds ??= new Polygon()).setPoints(orientedCorners); + } + + /** + * Whether the node's *world* box is axis-aligned — its own and every inherited + * rotation/skew compose to a transform that maps axes to axes (a multiple of + * 90°, no shear). When true the AABB equals the oriented box, so the cheaper + * AABB collision/hit-test path is exact; otherwise the oriented-quad SAT path + * is used. Unlike {@link isAlignedBox} (this node's own rotation only), this + * accounts for a rotated ancestor. + */ + private _isWorldAligned(): boolean { + const matrix = this.getGlobalTransform(); + const epsilon = 1e-9; + + return (Math.abs(matrix.b) < epsilon && Math.abs(matrix.c) < epsilon) || (Math.abs(matrix.a) < epsilon && Math.abs(matrix.d) < epsilon); } public intersectsWith(target: Collidable): boolean { - if (this.isAlignedBox) { + if (this._isWorldAligned()) { return this.getBounds().intersectsWith(target); } @@ -428,7 +485,7 @@ export class SceneNode implements Collidable, ObservableVectorOwner { } public collidesWith(target: Collidable): CollisionResponse | null { - if (this.isAlignedBox) { + if (this._isWorldAligned()) { return this.getBounds().collidesWith(target); } @@ -449,7 +506,7 @@ export class SceneNode implements Collidable, ObservableVectorOwner { /** * Hit-test the world-space point `(x, y)` against this node. * - * For axis-aligned nodes ({@link isAlignedBox} — rotation a multiple of 90° + * For world-axis-aligned nodes (own and inherited rotation a multiple of 90° * and no skew) the AABB equals the oriented box, so the cheap * {@link getBounds} test is exact. For rotated or skewed nodes the point is * mapped back into local space with the inverse of the global transform and @@ -460,7 +517,7 @@ export class SceneNode implements Collidable, ObservableVectorOwner { * empty AABB corners of a rotated node. */ public contains(x: number, y: number): boolean { - if (this.isAlignedBox) { + if (this._isWorldAligned()) { return this.getBounds().contains(x, y); } @@ -487,7 +544,9 @@ export class SceneNode implements Collidable, ObservableVectorOwner { return true; } - return view.getBounds().intersectsWith(this.getBounds()); + const bounds = this._cullArea ?? this.getBounds(); + + return view.getBounds().intersectsWith(bounds); } public destroy(): void { @@ -501,6 +560,7 @@ export class SceneNode implements Collidable, ObservableVectorOwner { this._localBounds.destroy(); this._bounds.destroy(); this._anchor.destroy(); + this._orientedBounds?.destroy(); } /** diff --git a/src/core/Stage.ts b/src/core/Stage.ts index f8079bc0..9575e3a2 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -1,5 +1,7 @@ import type { RenderNode } from '#rendering/RenderNode'; +import type { Application } from './Application'; + /** * Friend-class hooks a scene node uses to notify its owning interaction service * of lifecycle and bounds changes. Implemented by `InteractionManager`. Kept on @@ -41,4 +43,10 @@ export interface FocusHooks { export interface Stage { readonly interaction: InteractionHooks; readonly focus: FocusHooks; + /** + * The owning {@link Application}. Present in all production stages created by + * {@link InteractionManager}; may be absent in lightweight test stubs (hence + * optional). Widgets that need input access should use `this._stage?.app`. + */ + readonly app?: Application; } diff --git a/src/core/SystemRegistry.ts b/src/core/SystemRegistry.ts index f93db08a..ffaa0004 100644 --- a/src/core/SystemRegistry.ts +++ b/src/core/SystemRegistry.ts @@ -1,3 +1,4 @@ +import { Signal } from './Signal'; import type { System } from './System'; import type { Time } from './Time'; import type { Destroyable } from './types'; @@ -19,6 +20,11 @@ export class SystemRegistry implements Destroyable { private _ticking = false; private _sorted = true; + /** Dispatched when a system is added to this registry. */ + public readonly onAdd = new Signal<[system: System]>(); + /** Dispatched when a system is removed from this registry. */ + public readonly onRemove = new Signal<[system: System]>(); + /** Add `system`; returns it for fluent capture. Ticks from the next frame. */ public add(system: T): T { if (this._ticking) { @@ -84,6 +90,8 @@ export class SystemRegistry implements Destroyable { this._systems.length = 0; this._set.clear(); this._pending.length = 0; + this.onAdd.destroy(); + this.onRemove.destroy(); } private _insert(system: System): void { @@ -91,6 +99,7 @@ export class SystemRegistry implements Destroyable { this._set.add(system); this._systems.push(system); this._sorted = false; + this.onAdd.dispatch(system); } } @@ -105,6 +114,7 @@ export class SystemRegistry implements Destroyable { this._systems.splice(index, 1); } + this.onRemove.dispatch(system); return true; } diff --git a/src/core/index.ts b/src/core/index.ts index e56df247..7b5c8d06 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,8 +15,12 @@ export type { BuildInfo } from './BuildInfo'; export { buildInfo } from './BuildInfo'; export { Capabilities } from './capabilities'; export { Clock } from './Clock'; +export type { DecompressFormat } from './Codec'; +export { Codec } from './Codec'; export { Color } from './Color'; export { DisposalScope } from './DisposalScope'; +export type { LogChannel, LogEntry } from './logging'; +export { Logger, logger, LogSeverity } from './logging'; export { Perf } from './Perf'; export { Scene } from './Scene'; export type { FadeSceneTransition, SceneTransition, SetSceneOptions } from './SceneManager'; diff --git a/src/core/logging.ts b/src/core/logging.ts new file mode 100644 index 00000000..8563f8ff --- /dev/null +++ b/src/core/logging.ts @@ -0,0 +1,88 @@ +export enum LogSeverity { + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, +} + +export type LogChannel = 'core' | 'rendering' | 'audio' | 'input' | 'assets' | 'physics' | 'ui' | 'animation' | 'scene' | (string & {}); + +export interface LogEntry { + readonly severity: LogSeverity; + readonly channel: LogChannel; + readonly message: string; + readonly data?: Record; + readonly error?: Error; +} + +type LogHandler = (entry: LogEntry) => void; + +export class Logger { + private readonly _handlers: LogHandler[] = []; + private readonly _warnedKeys = new Set(); + + public log(severity: LogSeverity, channel: LogChannel, message: string, options?: { data?: Record; error?: Error }): void { + if (!__DEV__ && severity < LogSeverity.Error) return; + const entry: LogEntry = { + severity, + channel, + message, + ...(options?.data !== undefined && { data: options.data }), + ...(options?.error !== undefined && { error: options.error }), + }; + for (const handler of this._handlers) { + handler(entry); + } + } + + public debug(channel: LogChannel, message: string, data?: Record): void { + this.log(LogSeverity.Debug, channel, message, data ? { data } : undefined); + } + + public info(channel: LogChannel, message: string, data?: Record): void { + this.log(LogSeverity.Info, channel, message, data ? { data } : undefined); + } + + public warn(channel: LogChannel, message: string, data?: Record): void { + this.log(LogSeverity.Warning, channel, message, data ? { data } : undefined); + } + + public error(channel: LogChannel, message: string, error?: Error): void { + this.log(LogSeverity.Error, channel, message, error ? { error } : undefined); + } + + public warnOnce(key: string, channel: LogChannel, message: string): void { + if (this._warnedKeys.has(key)) return; + this._warnedKeys.add(key); + this.warn(channel, message); + } + + public addHandler(handler: LogHandler): () => void { + this._handlers.push(handler); + return () => { + const idx = this._handlers.indexOf(handler); + if (idx >= 0) this._handlers.splice(idx, 1); + }; + } + + /** @internal */ + public _resetWarnedKeys(): void { + this._warnedKeys.clear(); + } +} + +export const logger = new Logger(); + +if (__DEV__) { + logger.addHandler(entry => { + const prefix = `[ExoJS:${entry.channel}]`; + const method = entry.severity >= LogSeverity.Error ? 'error' : entry.severity >= LogSeverity.Warning ? 'warn' : 'log'; + if (entry.error) { + console[method](prefix, entry.message, entry.error); + } else if (entry.data) { + console[method](prefix, entry.message, entry.data); + } else { + console[method](prefix, entry.message); + } + }); +} diff --git a/src/core/serialization/commonFields.ts b/src/core/serialization/commonFields.ts index ee5a5bb4..f324ecc2 100644 --- a/src/core/serialization/commonFields.ts +++ b/src/core/serialization/commonFields.ts @@ -30,6 +30,7 @@ export function writeCommonFields(node: SceneNode, out: SerializedNode): void { if (!node.visible) out.visible = false; if (node.zIndex !== 0) out.zIndex = node.zIndex; if (!node.cullable) out.cullable = false; + if (node.cullArea !== null) out.cullArea = [node.cullArea.x, node.cullArea.y, node.cullArea.width, node.cullArea.height]; if (node.name !== null) out.name = node.name; if (node instanceof RenderNode) { @@ -88,6 +89,13 @@ export function applyCommonFields(node: SceneNode, data: SerializedNode): void { if (data.visible === false) node.visible = false; if (typeof data.zIndex === 'number') node.zIndex = data.zIndex; if (data.cullable === false) node.cullable = false; + + const cullArea = data.cullArea; + + if (Array.isArray(cullArea) && cullArea.length === 4) { + node.cullArea = new Rectangle(Number(cullArea[0]), Number(cullArea[1]), Number(cullArea[2]), Number(cullArea[3])); + } + if (typeof data.name === 'string') node.name = data.name; if (node instanceof RenderNode) { diff --git a/src/input/InteractionManager.ts b/src/input/InteractionManager.ts index e3cad035..c1d0549d 100644 --- a/src/input/InteractionManager.ts +++ b/src/input/InteractionManager.ts @@ -139,8 +139,8 @@ export class InteractionManager implements InteractionHooks, System { public constructor(app: Application) { this._app = app; - this._stage = { interaction: this, focus: app.focus }; - this._uiStage = { interaction: this._uiInteraction, focus: app.focus }; + this._stage = { interaction: this, focus: app.focus, app }; + this._uiStage = { interaction: this._uiInteraction, focus: app.focus, app }; this._onPointerDownHandler = this._handlePointerDown.bind(this); this._onPointerMoveHandler = this._handlePointerMove.bind(this); diff --git a/src/rendering/RenderBackend.ts b/src/rendering/RenderBackend.ts index 1ea5d1de..41db3e18 100644 --- a/src/rendering/RenderBackend.ts +++ b/src/rendering/RenderBackend.ts @@ -35,6 +35,12 @@ export interface RenderBackend { readonly view: View; readonly renderTarget: RenderTarget; readonly stats: RenderStats; + /** + * The colour the canvas root target is cleared to each frame. Mutable in + * place (`backend.clearColor.copy(...)`) — the new value takes effect on the + * next frame. Both backends initialise it from `app.options.clearColor`. + */ + readonly clearColor: Color; initialize(): Promise; resetStats(): this; @@ -85,6 +91,15 @@ export interface RenderBackend { */ composeWithAlphaMask(content: RenderTexture, mask: Texture | RenderTexture, x: number, y: number, width: number, height: number, blendMode: BlendModes): this; + /** + * Composite `source` over the active render target under an advanced + * (backdrop-aware) blend mode. Captures the target's `[x, y, width, height]` + * region, runs the W3C blend formula in a shader, and draws the result back + * with normal premultiplied source-over. Used internally by the render-effect + * executor for modes where {@link isAdvancedBlendMode} is `true`. + */ + composeWithBackdropBlend(source: RenderTexture, x: number, y: number, width: number, height: number, mode: BlendModes): this; + draw(drawable: Drawable): this; /** diff --git a/src/rendering/RenderNode.ts b/src/rendering/RenderNode.ts index e99272ae..bc4a01a1 100644 --- a/src/rendering/RenderNode.ts +++ b/src/rendering/RenderNode.ts @@ -13,7 +13,7 @@ import type { Texture } from '#rendering/texture/Texture'; import { BackendTargetPass } from './BackendTargetPass'; import type { RenderBackend } from './RenderBackend'; -import { BlendModes } from './types'; +import { BlendModes, isAdvancedBlendMode } from './types'; import { View } from './View'; interface DestroyableFilter { @@ -419,7 +419,7 @@ export abstract class RenderNode extends SceneNode { /** @internal */ public _renderPlanHasBarrierEffects(): boolean { - return this._filters.length > 0 || this._mask !== null || this._cacheAsBitmap || this.clip; + return this._filters.length > 0 || this._mask !== null || this._cacheAsBitmap || this.clip || isAdvancedBlendMode(this._renderPlanGetBlendMode()); } /** @internal */ diff --git a/src/rendering/RenderingContext.ts b/src/rendering/RenderingContext.ts index 7e9b8a1b..2c66b99c 100644 --- a/src/rendering/RenderingContext.ts +++ b/src/rendering/RenderingContext.ts @@ -261,11 +261,10 @@ export class RenderingContext implements System { const view = options.view ?? this._camera; const mesh = (this._immediateMesh ??= new ImmediateMesh()); - // Set the view first: this flushes whatever renderer a prior render() / - // drawGeometry left pending, so the shared transform buffer is free for this - // draw's synthetic slot and the pooled mesh is safe to reconfigure. The - // immediate flush below then keeps a later drawGeometry from observing this - // pooled mesh through a still-deferred draw. + // Set the view first: setView now only flushes when the view actually changes + // (not unconditionally). Correctness here rests on (a) the trailing flush() + // below — so a later drawGeometry cannot observe this pooled mesh through a + // still-deferred draw — and (b) any renderer switch flushing its pending batch. this._backend.setView(view); mesh.configure(geometry, transform, material, options.tint ?? null); this._backend.draw(mesh); @@ -302,9 +301,11 @@ export class RenderingContext implements System { const view = options.view ?? this._camera; const mesh = (this._batchMesh ??= new ImmediateMesh()); - // Set the view first (flushing any renderer left pending), configure the - // pooled geometry/look source, then submit a single instanced draw over the - // batch's per-instance transforms/tints and flush it immediately. + // Set the view first (setView only flushes when the view actually changes; + // correctness rests on the trailing flush() below and on any renderer switch + // flushing its pending batch), configure the pooled geometry/look source, + // then submit a single instanced draw over the batch's per-instance + // transforms/tints and flush it immediately. this._backend.setView(view); mesh.configureBatchSource(batch.geometry, batch.material); this._backend.drawInstanced(mesh, batch._instanceTransforms, batch._instanceTints, batch.count); diff --git a/src/rendering/TransformBuffer.ts b/src/rendering/TransformBuffer.ts index 8482f134..f63b0181 100644 --- a/src/rendering/TransformBuffer.ts +++ b/src/rendering/TransformBuffer.ts @@ -38,6 +38,12 @@ export class TransformBuffer { private _skippedWriteCount = 0; private _uploadCount = 0; private _uploadedRecordCount = 0; + // Dirty row range [_dirtyMin, _dirtyMax] written since the last upload — the + // exact rows a delta upload must push. Empty when `_dirtyMax < _dirtyMin`. + // Tracked by slot (not a high-water mark) so a reused slot (nested-plan + // rewind, filter composite) is correctly re-uploaded. + private _dirtyMin = 0; + private _dirtyMax = -1; public get count(): number { return this._count; @@ -94,6 +100,11 @@ export class TransformBuffer { return this._version; } + /** Running content hash of the rows written since begin(). @internal */ + public get frameHash(): number { + return this._frameHash; + } + public begin(expectedCount = 0): this { if (expectedCount > 0) { this._ensureCapacity(expectedCount); @@ -105,6 +116,8 @@ export class TransformBuffer { this._skippedWriteCount = 0; this._uploadCount = 0; this._uploadedRecordCount = 0; + this._dirtyMin = 0; + this._dirtyMax = -1; return this; } @@ -117,6 +130,47 @@ export class TransformBuffer { return slot; } + /** + * Rewind the write cursor to `count`, freeing the rows above it for reuse, and + * (optionally) restore the running content hash to its pre-rewind value so the + * freed rows' writes don't linger in the hash and trigger spurious re-uploads. + * Used by nested draw plans (filters / cacheAsBitmap) to isolate their slots. + * @internal + */ + public rewindTo(count: number, frameHash?: number): this { + if (count >= 0 && count < this._count) { + this._count = count; + + if (frameHash !== undefined) { + this._frameHash = frameHash >>> 0; + } + } + + return this; + } + + /** + * Consume the dirty row range written since the last upload, clamped to + * `[0, maxCount)`, and clear it. Returns the contiguous `[firstRow, firstRow + + * rowCount)` a delta upload should push (`rowCount === 0` when nothing is + * dirty). The backend calls this at its upload boundary. + * @internal + */ + public consumeDirtyRange(maxCount: number): { firstRow: number; rowCount: number } { + if (this._dirtyMax < this._dirtyMin) { + return { firstRow: 0, rowCount: 0 }; + } + + const firstRow = Math.max(0, this._dirtyMin); + const lastRow = Math.min(this._dirtyMax, maxCount - 1); + const rowCount = lastRow >= firstRow ? lastRow - firstRow + 1 : 0; + + this._dirtyMin = 0; + this._dirtyMax = -1; + + return { firstRow, rowCount }; + } + public write(slot: number, transform: Matrix, tint: Color): this { if (!Number.isInteger(slot) || slot < 0) { throw new Error(`TransformBuffer slot must be a non-negative integer (got ${slot}).`); @@ -144,6 +198,16 @@ export class TransformBuffer { this._count = slot + 1; } + // Track the exact written-slot range so a delta upload pushes precisely the + // changed rows — including a slot reused below the high-water mark. + if (this._dirtyMax < this._dirtyMin) { + this._dirtyMin = slot; + this._dirtyMax = slot; + } else { + if (slot < this._dirtyMin) this._dirtyMin = slot; + if (slot > this._dirtyMax) this._dirtyMax = slot; + } + this._frameHash = this._mix(this._frameHash, slot); for (let i = 0; i < floatsPerSlot; i++) { diff --git a/src/rendering/material/MeshMaterial.ts b/src/rendering/material/MeshMaterial.ts index 160eaa44..7bb965ee 100644 --- a/src/rendering/material/MeshMaterial.ts +++ b/src/rendering/material/MeshMaterial.ts @@ -1,5 +1,9 @@ -import type { MaterialOptions } from './Material'; +import type { SamplerOptions } from '#rendering/texture/Sampler'; +import type { BlendModes } from '#rendering/types'; + +import type { MaterialOptions, UniformValue } from './Material'; import { Material } from './Material'; +import { ShaderSource } from './ShaderSource'; /** * Material specialization for {@link Mesh} drawables. @@ -17,4 +21,52 @@ export class MeshMaterial extends Material { public constructor(options: MaterialOptions) { super(options); } + + /** + * Build a `MeshMaterial` from an existing {@link ShaderSource}. + * Equivalent to `new MeshMaterial({ shader, ...options })`. + */ + public static from(source: ShaderSource, options?: Omit): MeshMaterial; + /** + * Build a `MeshMaterial` from raw GLSL vertex and fragment source strings. + * Wraps them in a new {@link ShaderSource}; pass `options.wgsl` to also + * cover the WebGPU backend. + */ + public static from( + glslVertex: string, + glslFragment: string, + options?: { + readonly wgsl?: string; + readonly uniforms?: Record; + readonly blendMode?: BlendModes; + readonly sampler?: SamplerOptions | null; + }, + ): MeshMaterial; + public static from( + sourceOrGlslVertex: ShaderSource | string, + optionsOrGlslFragment?: Omit | string, + glslOptions?: { + readonly wgsl?: string; + readonly uniforms?: Record; + readonly blendMode?: BlendModes; + readonly sampler?: SamplerOptions | null; + }, + ): MeshMaterial { + if (sourceOrGlslVertex instanceof ShaderSource) { + const opts = optionsOrGlslFragment as Omit | undefined; + return new MeshMaterial({ shader: sourceOrGlslVertex, ...(opts !== undefined ? opts : {}) }); + } + + const shader = new ShaderSource({ + glsl: { vertex: sourceOrGlslVertex, fragment: optionsOrGlslFragment as string }, + ...(glslOptions?.wgsl !== undefined ? { wgsl: glslOptions.wgsl } : {}), + }); + + return new MeshMaterial({ + shader, + ...(glslOptions?.uniforms !== undefined ? { uniforms: glslOptions.uniforms } : {}), + ...(glslOptions?.blendMode !== undefined ? { blendMode: glslOptions.blendMode } : {}), + ...(glslOptions?.sampler !== undefined ? { sampler: glslOptions.sampler } : {}), + }); + } } diff --git a/src/rendering/material/SpriteMaterial.ts b/src/rendering/material/SpriteMaterial.ts index 40053e85..51bfede0 100644 --- a/src/rendering/material/SpriteMaterial.ts +++ b/src/rendering/material/SpriteMaterial.ts @@ -1,5 +1,9 @@ -import type { MaterialOptions } from './Material'; +import type { SamplerOptions } from '#rendering/texture/Sampler'; +import type { BlendModes } from '#rendering/types'; + +import type { MaterialOptions, UniformValue } from './Material'; import { Material } from './Material'; +import { ShaderSource } from './ShaderSource'; /** * Material specialization for {@link Sprite} drawables. @@ -16,4 +20,52 @@ export class SpriteMaterial extends Material { public constructor(options: MaterialOptions) { super(options); } + + /** + * Build a `SpriteMaterial` from an existing {@link ShaderSource}. + * Equivalent to `new SpriteMaterial({ shader, ...options })`. + */ + public static from(source: ShaderSource, options?: Omit): SpriteMaterial; + /** + * Build a `SpriteMaterial` from raw GLSL vertex and fragment source strings. + * Wraps them in a new {@link ShaderSource}; pass `options.wgsl` to also + * cover the WebGPU backend. + */ + public static from( + glslVertex: string, + glslFragment: string, + options?: { + readonly wgsl?: string; + readonly uniforms?: Record; + readonly blendMode?: BlendModes; + readonly sampler?: SamplerOptions | null; + }, + ): SpriteMaterial; + public static from( + sourceOrGlslVertex: ShaderSource | string, + optionsOrGlslFragment?: Omit | string, + glslOptions?: { + readonly wgsl?: string; + readonly uniforms?: Record; + readonly blendMode?: BlendModes; + readonly sampler?: SamplerOptions | null; + }, + ): SpriteMaterial { + if (sourceOrGlslVertex instanceof ShaderSource) { + const opts = optionsOrGlslFragment as Omit | undefined; + return new SpriteMaterial({ shader: sourceOrGlslVertex, ...(opts !== undefined ? opts : {}) }); + } + + const shader = new ShaderSource({ + glsl: { vertex: sourceOrGlslVertex, fragment: optionsOrGlslFragment as string }, + ...(glslOptions?.wgsl !== undefined ? { wgsl: glslOptions.wgsl } : {}), + }); + + return new SpriteMaterial({ + shader, + ...(glslOptions?.uniforms !== undefined ? { uniforms: glslOptions.uniforms } : {}), + ...(glslOptions?.blendMode !== undefined ? { blendMode: glslOptions.blendMode } : {}), + ...(glslOptions?.sampler !== undefined ? { sampler: glslOptions.sampler } : {}), + }); + } } diff --git a/src/rendering/plan/RenderEffectExecutor.ts b/src/rendering/plan/RenderEffectExecutor.ts index 47f3f99e..9978e018 100644 --- a/src/rendering/plan/RenderEffectExecutor.ts +++ b/src/rendering/plan/RenderEffectExecutor.ts @@ -15,7 +15,7 @@ export class RenderEffectExecutor { const needsBitmapCache = effect.cacheAsBitmap; const { left, top, width, height } = barrier; - if (!hasFilters && !needsBitmapCache) { + if (!hasFilters && !needsBitmapCache && !effect.needsBackdropBlend) { this._withClip(node, backend, barrier, () => { if (barrier.childPlan !== null) { playScope(barrier.childPlan); @@ -89,7 +89,11 @@ export class RenderEffectExecutor { } this._withClip(node, backend, barrier, () => { - node._renderPlanDrawTexture(backend, finalTexture, left, top, width, height, effect.blendMode); + if (effect.needsBackdropBlend) { + backend.composeWithBackdropBlend(finalTexture, left, top, width, height, effect.blendMode); + } else { + node._renderPlanDrawTexture(backend, finalTexture, left, top, width, height, effect.blendMode); + } }); } finally { if (pooledTexture !== null) { diff --git a/src/rendering/plan/RenderInstruction.ts b/src/rendering/plan/RenderInstruction.ts index 27132776..91161945 100644 --- a/src/rendering/plan/RenderInstruction.ts +++ b/src/rendering/plan/RenderInstruction.ts @@ -9,8 +9,11 @@ import type { GroupScope } from './RenderScope'; * names the concept the plan player consumes and that the batching layer * reorders, independent of how the draw happens to be stored in the scope * tree. Future {@link TransformBuffer} slotting keys on each instruction's - * stable {@link DrawCommand.nodeIndex} (within the `[0, plan.nodeCount)` - * slot space). + * stable {@link DrawCommand.nodeIndex}. Each index is frame-global — + * `[frameBase, frameBase + plan.nodeCount)` — because the transform buffer + * is frame-scoped and the builder bases node indices at the current buffer + * slot count (`frameBase`) so every plan in the frame occupies distinct + * slots and can batch cross-call. * * Batch units (maximal runs of consecutive instructions in a {@link GroupScope} * sharing GPU pipeline/bind state) are not materialized: the plan player walks diff --git a/src/rendering/plan/RenderPlanBuilder.ts b/src/rendering/plan/RenderPlanBuilder.ts index d403bf5a..58053f25 100644 --- a/src/rendering/plan/RenderPlanBuilder.ts +++ b/src/rendering/plan/RenderPlanBuilder.ts @@ -3,6 +3,7 @@ import type { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; import type { RenderBackend } from '#rendering/RenderBackend'; import type { RenderNode } from '#rendering/RenderNode'; +import { isAdvancedBlendMode } from '#rendering/types'; import type { View } from '#rendering/View'; import { type DrawCommand, RenderEntryKind } from './RenderCommand'; @@ -92,7 +93,11 @@ export class RenderPlanBuilder { this._barrierEntryPoolCursor = 0; this._scopeStack.length = 0; this._hasPending = false; - this._nodeIndex = 0; + // Base this plan's node indices after whatever earlier render() calls already + // wrote into the frame-scoped transform buffer, so every draw across all + // render() calls in the frame references a distinct slot and can batch. + const frameBase = (backend as { transformBufferCount?: number }).transformBufferCount ?? 0; + this._nodeIndex = frameBase; const rootScope = this._acquireGroupScope(false); @@ -109,7 +114,7 @@ export class RenderPlanBuilder { }); } - this._plan.nodeCount = this._nodeIndex; + this._plan.nodeCount = this._nodeIndex - frameBase; return this._plan; } @@ -130,7 +135,7 @@ export class RenderPlanBuilder { if (node._renderPlanHasBarrierEffects()) { const effect = this._createEffectDescriptor(node); const hasAlphaMask = effect.maskSource !== null && !(effect.maskSource instanceof Rectangle); - const needsBounds = effect.cacheAsBitmap || effect.filters.length > 0 || hasAlphaMask; + const needsBounds = effect.cacheAsBitmap || effect.filters.length > 0 || hasAlphaMask || (effect.needsBackdropBlend ?? false); let left = 0; let top = 0; let width = 0; @@ -401,13 +406,16 @@ export class RenderPlanBuilder { } } + const blendMode = node._renderPlanGetBlendMode(); + return { filters: node._renderPlanGetFilters(), clip, clipShape, maskSource: mask, cacheAsBitmap: node.cacheAsBitmap, - blendMode: node._renderPlanGetBlendMode(), + blendMode, + needsBackdropBlend: isAdvancedBlendMode(blendMode), }; } } diff --git a/src/rendering/plan/RenderScope.ts b/src/rendering/plan/RenderScope.ts index 0de9e236..775e671a 100644 --- a/src/rendering/plan/RenderScope.ts +++ b/src/rendering/plan/RenderScope.ts @@ -27,6 +27,13 @@ export interface EffectDescriptor { readonly maskSource: MaskSource; readonly cacheAsBitmap: boolean; readonly blendMode: BlendModes; + /** + * When `true`, the node uses a backdrop-aware blend mode (modes 5–17). The + * render-effect executor renders the content off-screen and composites it back + * via {@link RenderBackend.composeWithBackdropBlend} instead of the regular + * draw-texture path. + */ + readonly needsBackdropBlend?: boolean; } /** diff --git a/src/rendering/public.ts b/src/rendering/public.ts index aafac1f6..ecec9343 100644 --- a/src/rendering/public.ts +++ b/src/rendering/public.ts @@ -29,7 +29,7 @@ export { RenderPipeline } from './RenderPipeline'; export type { RenderStats } from './RenderStats'; export { createRenderStats, resetRenderStats } from './RenderStats'; export { RenderTarget } from './RenderTarget'; -export { BlendModes, BufferTypes, BufferUsage, RenderingPrimitives, ScaleModes, ShaderPrimitives, WrapModes } from './types'; +export { BlendModes, BufferTypes, BufferUsage, isAdvancedBlendMode, RenderingPrimitives, ScaleModes, ShaderPrimitives, WrapModes } from './types'; export type { ViewFollowOptions, ViewFollowTarget, ViewShakeOptions } from './View'; export { View, ViewFlags } from './View'; export type { BlurFilterOptions } from '#rendering/filters/BlurFilter'; diff --git a/src/rendering/types.ts b/src/rendering/types.ts index 9ff5aab3..b9cf8abf 100644 --- a/src/rendering/types.ts +++ b/src/rendering/types.ts @@ -1,6 +1,11 @@ /** * Compositing blend modes applied when drawing a {@link Drawable} over the current render target. - * Values map to backend-specific blend-equation presets. + * + * Modes 0–4 are implemented as fixed-function GPU blend equations (no texture + * capture required). Modes 5–17 use a backdrop-aware compositor: the content is + * first rendered off-screen, then composited over the captured backdrop via a + * W3C-compliant blend shader. Use {@link isAdvancedBlendMode} to test whether a + * mode requires the compositor path. */ export enum BlendModes { Normal = 0, @@ -8,21 +13,41 @@ export enum BlendModes { Subtract = 2, Multiply = 3, Screen = 4, - /** - * `min(src, dst)` per channel. - * - * KNOWN LIMITATION: implemented with the fixed-function `MIN` blend equation, - * which ignores the blend factors and therefore cannot account for source - * coverage. With premultiplied-alpha drawables, transparent texels (rgb = 0) - * resolve to `min(0, dst) = 0`, so transparent regions of a sprite turn black - * instead of showing the background. Reliable only for fully opaque sources; - * a coverage-correct version needs a shader-side blend. Tracked for a future - * render pass. {@link Lighten} is unaffected (`max(0, dst) = dst`). - */ + /** `min(src, dst)` per channel — coverage-correct via backdrop-aware shader. */ Darken = 5, + /** `max(src, dst)` per channel — coverage-correct via backdrop-aware shader. */ Lighten = 6, + /** Overlay: darkens or lightens depending on backdrop luminosity. */ + Overlay = 7, + /** Color Dodge: brightens the backdrop to reflect the source. */ + ColorDodge = 8, + /** Color Burn: darkens the backdrop to reflect the source. */ + ColorBurn = 9, + /** Hard Light: strong Overlay with source and backdrop roles swapped. */ + HardLight = 10, + /** Soft Light: softer Overlay effect. */ + SoftLight = 11, + /** Difference: absolute value of channel difference. */ + Difference = 12, + /** Exclusion: lower-contrast alternative to Difference. */ + Exclusion = 13, + /** Hue: source hue with backdrop saturation and luminosity. */ + Hue = 14, + /** Saturation: source saturation with backdrop hue and luminosity. */ + Saturation = 15, + /** Color: source hue+saturation with backdrop luminosity. */ + Color = 16, + /** Luminosity: source luminosity with backdrop hue+saturation. */ + Luminosity = 17, } +/** + * Returns `true` for blend modes that require the backdrop-aware compositor + * (shader-side blend + GPU texture copy). Modes 0–4 use fixed-function blending + * and return `false`. Modes 5–17 return `true`. + */ +export const isAdvancedBlendMode = (mode: BlendModes): boolean => mode >= BlendModes.Darken; + /** * Texture magnification and minification filter modes. * Values are WebGL2 GLenum constants and are passed directly to the GPU sampler. diff --git a/src/rendering/webgl2/WebGl2BackdropBlendCompositor.ts b/src/rendering/webgl2/WebGl2BackdropBlendCompositor.ts new file mode 100644 index 00000000..86b2b217 --- /dev/null +++ b/src/rendering/webgl2/WebGl2BackdropBlendCompositor.ts @@ -0,0 +1,287 @@ +import { Shader } from '#rendering/shader/Shader'; +import type { RenderTexture } from '#rendering/texture/RenderTexture'; +import type { Texture } from '#rendering/texture/Texture'; +import { BlendModes, BufferTypes, BufferUsage } from '#rendering/types'; + +import fragmentSource from './glsl/backdrop-blend.frag'; +import vertexSource from './glsl/backdrop-blend.vert'; +import type { WebGl2Backend } from './WebGl2Backend'; +import { WebGl2RenderBuffer, type WebGl2RenderBufferRuntime } from './WebGl2RenderBuffer'; +import { createWebGl2ShaderProgram } from './WebGl2ShaderProgram'; +import { WebGl2VertexArrayObject, type WebGl2VertexArrayObjectRuntime } from './WebGl2VertexArrayObject'; + +interface BackdropBlendCompositorConnection { + readonly gl: WebGL2RenderingContext; + readonly vaoHandle: WebGLVertexArrayObject; + readonly vao: WebGl2VertexArrayObject; + readonly indexBuffer: WebGl2RenderBuffer; + readonly vertexBuffer: WebGl2RenderBuffer; + readonly bufferHandles: Map; +} + +// 4 floats per vertex: position(x, y) + texcoord(u, v). +const vertexStrideBytes = 16; +const quadIndices = new Uint16Array([0, 1, 2, 0, 2, 3]); + +/** + * Single-quad backdrop-aware blend compositor used by + * `WebGl2Backend.composeWithBackdropBlend`. Samples the premultiplied source + * texture (slot 0) and the captured premultiplied backdrop texture (slot 1), + * computes the W3C blend for the requested {@link BlendModes}, and draws the + * result over the active target with normal (premultiplied source-over) + * blending — so the GPU composites the blended source over the backdrop already + * in the target. + * + * Mirrors {@link WebGl2MaskCompositor}'s structure; like it, this is invoked + * directly by the backend and never participates in renderer-registry dispatch. + */ +export class WebGl2BackdropBlendCompositor { + private readonly _shader: Shader = new Shader(vertexSource, fragmentSource); + private readonly _vertexData: ArrayBuffer = new ArrayBuffer(4 * vertexStrideBytes); + private readonly _float32View: Float32Array = new Float32Array(this._vertexData); + private readonly _sourceSamplerSlot: Int32Array = new Int32Array([0]); + private readonly _backdropSamplerSlot: Int32Array = new Int32Array([1]); + private readonly _modeValue: Int32Array = new Int32Array([0]); + private readonly _opaqueValue: Float32Array = new Float32Array([0]); + private _connection: BackdropBlendCompositorConnection | null = null; + + public connect(backend: WebGl2Backend): void { + if (this._connection !== null) { + return; + } + + const gl = backend.context; + const vaoHandle = gl.createVertexArray(); + + if (vaoHandle === null) { + throw new Error('WebGl2BackdropBlendCompositor: could not create vertex array object.'); + } + + this._shader.connect(createWebGl2ShaderProgram(gl)); + + const bufferHandles = new Map(); + const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, quadIndices, BufferUsage.StaticDraw).connect( + this._createBufferRuntime(gl, bufferHandles), + ); + const vertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw).connect( + this._createBufferRuntime(gl, bufferHandles), + ); + + // Force shader finalize so getAttribute() below sees a populated attribute table. + this._shader.sync(); + + const vao = new WebGl2VertexArrayObject() + .addIndex(indexBuffer) + .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes, 0) + .addAttribute(vertexBuffer, this._shader.getAttribute('a_texcoord'), gl.FLOAT, false, vertexStrideBytes, 8) + .connect(this._createVaoRuntime(gl, vaoHandle)); + + this._connection = { gl, vaoHandle, vao, indexBuffer, vertexBuffer, bufferHandles }; + } + + public disconnect(): void { + const connection = this._connection; + + if (connection === null) { + return; + } + + connection.indexBuffer.destroy(); + connection.vertexBuffer.destroy(); + connection.vao.destroy(); + this._shader.disconnect(); + this._connection = null; + } + + /** + * Composite `source` over the active target's current contents under an + * advanced (backdrop-aware) blend mode. Captures the target's `[x, y, width, + * height]` region (view units) as the backdrop, runs the blend in a shader, + * and draws the blended source over the untouched backdrop with normal + * premultiplied source-over. + */ + public compose(backend: WebGl2Backend, source: Texture | RenderTexture, x: number, y: number, width: number, height: number, blendMode: BlendModes): void { + if (this._connection === null) { + throw new Error('WebGl2BackdropBlendCompositor: not connected.'); + } + + if (width <= 0 || height <= 0) { + return; + } + + const gl = backend.context; + const target = backend.renderTarget; + const scaleX = target.root && target.width > 0 ? gl.drawingBufferWidth / target.width : 1; + const scaleY = target.root && target.height > 0 ? gl.drawingBufferHeight / target.height : 1; + const px = Math.max(0, Math.floor(x * scaleX)); + const py = Math.max(0, Math.floor(gl.drawingBufferHeight - (y + height) * scaleY)); + const backdrop = backend.acquireRenderTexture(width, height); + const cw = Math.min(backdrop.width, Math.max(0, Math.round(width * scaleX))); + const ch = Math.min(backdrop.height, Math.max(0, Math.round(height * scaleY))); + // An opaque framebuffer (the default alpha-less root canvas) reports a + // captured backdrop alpha of 0; treat such a backdrop as fully covered. + const opaqueBackdrop = target.root && !(gl.getContextAttributes()?.alpha ?? false); + + try { + // Capture the target region into the backdrop via blit; copyTexSubImage2D + // reads the opaque default framebuffer as black, so blit is the reliable + // path for the on-screen root canvas. + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, backend._renderTargetFramebuffer(target)); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, backend._renderTargetFramebuffer(backdrop)); + gl.blitFramebuffer(px, py, px + cw, py + ch, 0, 0, cw, ch, gl.COLOR_BUFFER_BIT, gl.NEAREST); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + backend._rebindActiveTarget(); + + this._drawBlend(backend, source, backdrop, x, y, width, height, blendMode, opaqueBackdrop); + } finally { + backend.releaseRenderTexture(backdrop); + } + } + + private _drawBlend( + backend: WebGl2Backend, + source: Texture | RenderTexture, + backdrop: Texture | RenderTexture, + x: number, + y: number, + width: number, + height: number, + blendMode: BlendModes, + opaqueBackdrop: boolean, + ): void { + const connection = this._connection; + + if (connection === null) { + throw new Error('WebGl2BackdropBlendCompositor: not connected.'); + } + + this._writeQuadVertices(x, y, x + width, y + height); + + backend.bindShader(this._shader); + + const projection = backend.view.getTransform().toArray(false); + + this._modeValue[0] = blendMode; + this._opaqueValue[0] = opaqueBackdrop ? 1 : 0; + this._shader.getUniform('u_projection').setValue(projection); + this._shader.getUniform('u_source').setValue(this._sourceSamplerSlot); + this._shader.getUniform('u_backdrop').setValue(this._backdropSamplerSlot); + this._shader.getUniform('u_mode').setValue(this._modeValue); + this._shader.getUniform('u_opaqueBackdrop').setValue(this._opaqueValue); + this._shader.sync(); + + backend.bindTexture(source, 0); + backend.bindTexture(backdrop, 1); + // The blend math is in the shader; composite the blended source over the + // backdrop already in the target with normal premultiplied source-over. + backend.setBlendMode(BlendModes.Normal); + + backend.bindVertexArrayObject(connection.vao); + connection.vertexBuffer.upload(this._float32View); + connection.vao.draw(6, 0); + + backend.stats.batches++; + backend.stats.drawCalls++; + + backend.bindTexture(null, 1); + } + + private _writeQuadVertices(left: number, top: number, right: number, bottom: number): void { + const view = this._float32View; + + // Vertex 0: top-left (UV 0, 0) + view[0] = left; + view[1] = top; + view[2] = 0; + view[3] = 0; + + // Vertex 1: top-right (UV 1, 0) + view[4] = right; + view[5] = top; + view[6] = 1; + view[7] = 0; + + // Vertex 2: bottom-right (UV 1, 1) + view[8] = right; + view[9] = bottom; + view[10] = 1; + view[11] = 1; + + // Vertex 3: bottom-left (UV 0, 1) + view[12] = left; + view[13] = bottom; + view[14] = 0; + view[15] = 1; + } + + private _createBufferRuntime(gl: WebGL2RenderingContext, handles: Map): WebGl2RenderBufferRuntime { + const handle = gl.createBuffer(); + + if (handle === null) { + throw new Error('WebGl2BackdropBlendCompositor: could not create render buffer.'); + } + + return { + bind: (buffer: WebGl2RenderBuffer): void => { + gl.bindBuffer(buffer.type, handle); + }, + upload: (buffer: WebGl2RenderBuffer): void => { + const data = buffer.data; + + gl.bindBuffer(buffer.type, handle); + gl.bufferData(buffer.type, data, buffer.usage); + handles.set(buffer, handle); + }, + destroy: (buffer: WebGl2RenderBuffer): void => { + gl.deleteBuffer(handle); + handles.delete(buffer); + buffer.disconnect(); + }, + }; + } + + private _createVaoRuntime(gl: WebGL2RenderingContext, vaoHandle: WebGLVertexArrayObject): WebGl2VertexArrayObjectRuntime { + let appliedVersion = -1; + + return { + bind: (vao: WebGl2VertexArrayObject): void => { + gl.bindVertexArray(vaoHandle); + + if (appliedVersion !== vao.version) { + let lastBuffer: WebGl2RenderBuffer | null = null; + + for (const attribute of vao.attributes) { + if (lastBuffer !== attribute.buffer) { + attribute.buffer.bind(); + lastBuffer = attribute.buffer; + } + + gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start); + gl.enableVertexAttribArray(attribute.location); + } + + if (vao.indexBuffer) { + vao.indexBuffer.bind(); + } + + appliedVersion = vao.version; + } + }, + unbind: (): void => { + gl.bindVertexArray(null); + }, + draw: (vao: WebGl2VertexArrayObject, size: number, start: number, type: number): void => { + if (vao.indexBuffer) { + gl.drawElements(type, size, gl.UNSIGNED_SHORT, start); + } else { + gl.drawArrays(type, start, size); + } + }, + destroy: (vao: WebGl2VertexArrayObject): void => { + gl.deleteVertexArray(vaoHandle); + vao.disconnect(); + }, + }; + } +} diff --git a/src/rendering/webgl2/WebGl2Backend.ts b/src/rendering/webgl2/WebGl2Backend.ts index 1caf8461..c9e660ba 100644 --- a/src/rendering/webgl2/WebGl2Backend.ts +++ b/src/rendering/webgl2/WebGl2Backend.ts @@ -27,6 +27,7 @@ import { TransformBuffer } from '#rendering/TransformBuffer'; import { BlendModes } from '#rendering/types'; import type { View } from '#rendering/View'; +import { WebGl2BackdropBlendCompositor } from './WebGl2BackdropBlendCompositor'; import { WebGl2MaskCompositor } from './WebGl2MaskCompositor'; import { WebGl2MeshRenderer } from './WebGl2MeshRenderer'; import { WebGl2PassCoordinator } from './WebGl2PassCoordinator'; @@ -153,6 +154,8 @@ export class WebGl2Backend implements RenderBackend { private readonly _clipPointB: Vector = new Vector(); private readonly _maskCompositor: WebGl2MaskCompositor = new WebGl2MaskCompositor(); private _maskCompositorConnected = false; + private readonly _backdropBlendCompositor: WebGl2BackdropBlendCompositor = new WebGl2BackdropBlendCompositor(); + private _backdropBlendCompositorConnected = false; private readonly _stencilClipper: WebGl2StencilClipper = new WebGl2StencilClipper(); private readonly _stencilStates: Map = new Map(); private _stencilClipperConnected = false; @@ -178,6 +181,8 @@ export class WebGl2Backend implements RenderBackend { private _transformTextureCount = -1; private _activeDrawCommand: DrawCommand | null = null; private _drawPlanDepth = 0; + private readonly _planBaseStack: number[] = []; + private readonly _planHashStack: number[] = []; public constructor(app: Application) { const canvasOptions = app.options.canvas ?? {}; @@ -276,13 +281,27 @@ export class WebGl2Backend implements RenderBackend { public resetStats(): this { resetRenderStats(this._stats); + // The transform buffer is frame-scoped: reset it once per frame here (was + // previously reset per render() call in _beginDrawPlan). + this._transformBuffer.begin(); return this; } + /** Frame-global slot base the plan builder indexes from. @internal */ + public get transformBufferCount(): number { + return this._transformBuffer.count; + } + /** @internal */ - public _beginDrawPlan(nodeCount: number): void { - this._transformBuffer.begin(nodeCount); + public _beginDrawPlan(_nodeCount: number): void { + // Do NOT reset the transform buffer here — it is frame-scoped (reset in + // resetStats). The builder already based this plan's node indices at the + // current buffer count, so writes land in fresh frame-global slots and + // batches survive across render() calls. Remember this plan's base so a + // nested plan can free its rows on end. + this._planBaseStack.push(this._transformBuffer.count); + this._planHashStack.push(this._transformBuffer.frameHash); this._activeDrawCommand = null; this._drawPlanDepth++; } @@ -392,13 +411,23 @@ export class WebGl2Backend implements RenderBackend { public _endDrawPlan(): void { this._activeDrawCommand = null; + const planBase = this._planBaseStack.pop() ?? 0; + const planHash = this._planHashStack.pop() ?? 0; + if (this._drawPlanDepth > 0) { this._drawPlanDepth--; } - // Only assert balance at the outermost plan: cacheAsBitmap draws a cache - // sprite via a nested render(), whose inner _endDrawPlan sees the still-open - // outer clips — those are not leaks. + // A nested plan (filter / cacheAsBitmap) just ended: flush its draws, then + // free its transform rows so the frame-scoped buffer only grows with + // top-level render() calls. Top-level plans (depth back to 0) keep their rows + // so cross-call batching survives to the frame-end flush. + if (this._drawPlanDepth > 0) { + this._flushActiveRenderer(); + this._transformBuffer.rewindTo(planBase, planHash); + } + + // Only assert balance at the outermost plan. if (this._drawPlanDepth === 0) { this._assertBalancedStencil(); } @@ -647,6 +676,44 @@ export class WebGl2Backend implements RenderBackend { return this; } + public composeWithBackdropBlend(source: RenderTexture, x: number, y: number, width: number, height: number, mode: BlendModes): this { + if (width <= 0 || height <= 0) { + return this; + } + + this._flushActiveRenderer(); + this._setActiveRenderer(null); + + if (!this._backdropBlendCompositorConnected) { + this._backdropBlendCompositor.connect(this); + this._backdropBlendCompositorConnected = true; + } + + this._backdropBlendCompositor.compose(this, source, x, y, width, height, mode); + + return this; + } + + /** + * Return the GL framebuffer for `target`, preparing the render-target state so + * the texture is attached. Used internally by {@link WebGl2BackdropBlendCompositor} + * for framebuffer blits. Null for the root (default) framebuffer. + * @internal + */ + public _renderTargetFramebuffer(target: RenderTarget): WebGLFramebuffer | null { + return this._prepareRenderTarget(target).framebuffer; + } + + /** + * Re-bind the currently active render target as the GL DRAW framebuffer and + * restore the viewport. Called by {@link WebGl2BackdropBlendCompositor} after + * it unbinds the framebuffer for a blit operation. + * @internal + */ + public _rebindActiveTarget(): void { + this._bindRenderTarget(this._renderTarget); + } + public acquireRenderTexture(width: number, height: number): RenderTexture { for (let index = 0; index < this._temporaryRenderTextures.length; index++) { // In-bounds: `index` ranges over `0..length-1`. @@ -674,7 +741,12 @@ export class WebGl2Backend implements RenderBackend { } public setView(view: View | null): this { - this._flushActiveRenderer(); + // Only flush the open batch when the view actually changes. The unconditional + // flush forced one draw call per render() call (each render() re-applies the + // same camera view), defeating cross-call batching. + if (this._renderTarget.view !== view) { + this._flushActiveRenderer(); + } this._renderTarget.setView(view); this._bindRenderTarget(this._renderTarget); @@ -763,11 +835,23 @@ export class WebGl2Backend implements RenderBackend { throw new Error('Transform texture must be initialized before binding.'); } + // A skipped flush (all three guards false) leaves the dirty range uncleared + // until the next begin(). Safe: every write() mixes its slot into _frameHash, + // so a non-empty dirty range always coincides with snapshot.changed = true — + // the upload branch is always taken before any dirty rows could be stale. if (snapshot.changed || snapshot.count !== this._transformTextureCount || snapshot.hash !== this._transformTextureHash) { - nextTransformTexture.commitRect(0, 0, 3, snapshot.count); - this._transformBuffer.recordUpload(snapshot.count); - this._transformTextureHash = snapshot.hash; + // Upload only the rows actually written since the last upload (delta), so + // barrier-heavy frames don't re-upload the whole growing buffer. A reused + // slot below the high-water mark is in the dirty range, so it re-uploads. + const { firstRow, rowCount } = this._transformBuffer.consumeDirtyRange(snapshot.count); + + if (rowCount > 0) { + nextTransformTexture.commitRect(0, firstRow, 3, rowCount); + this._transformBuffer.recordUpload(rowCount); + } + this._transformTextureCount = snapshot.count; + this._transformTextureHash = snapshot.hash; } return this.bindTexture(nextTransformTexture, unit); @@ -796,17 +880,6 @@ export class WebGl2Backend implements RenderBackend { gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_COLOR); break; - case BlendModes.Darken: - // MIN/MAX ignore blendFunc factors, so this cannot account for source - // coverage — transparent (premultiplied rgb=0) texels darken to black. - // Reliable only for opaque sources. See {@link BlendModes.Darken}. - gl.blendEquation(gl.MIN); - gl.blendFunc(gl.ONE, gl.ONE); - break; - case BlendModes.Lighten: - gl.blendEquation(gl.MAX); - gl.blendFunc(gl.ONE, gl.ONE); - break; default: gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); @@ -895,6 +968,11 @@ export class WebGl2Backend implements RenderBackend { this._maskCompositorConnected = false; } + if (this._backdropBlendCompositorConnected) { + this._backdropBlendCompositor.disconnect(); + this._backdropBlendCompositorConnected = false; + } + if (this._stencilClipperConnected) { this._stencilClipper.disconnect(); this._stencilClipperConnected = false; diff --git a/src/rendering/webgl2/glsl/backdrop-blend.frag b/src/rendering/webgl2/glsl/backdrop-blend.frag new file mode 100644 index 00000000..83bd6767 --- /dev/null +++ b/src/rendering/webgl2/glsl/backdrop-blend.frag @@ -0,0 +1,169 @@ +#version 300 es +precision highp float; + +// Backdrop-aware blend compositor (advanced blend modes). +// +// Samples the premultiplied source (the drawable rendered to a texture) and the +// captured premultiplied backdrop (the target contents behind it), computes the +// W3C blend B(Cb, Cs) for the requested mode, and outputs the blended source +// premultiplied by its own alpha. The caller draws this with normal +// (premultiplied source-over) blending, so the GPU composites it over the +// untouched backdrop already in the target — transparent source regions +// (alpha 0) leave the backdrop showing through instead of going black. +// +// Mode values match the BlendModes enum (src/rendering/types.ts). + +uniform sampler2D u_source; +uniform sampler2D u_backdrop; +uniform int u_mode; +// 1.0 when the target is opaque (the on-screen root canvas), whose captured +// alpha is unreliable — an opaque framebuffer reports backdrop alpha 0, which +// would make the blend ignore the backdrop. Forces backdrop coverage to full. +uniform float u_opaqueBackdrop; + +in vec2 v_texcoord; + +layout(location = 0) out vec4 fragColor; + +const int MODE_MULTIPLY = 3; +const int MODE_SCREEN = 4; +const int MODE_DARKEN = 5; +const int MODE_LIGHTEN = 6; +const int MODE_OVERLAY = 7; +const int MODE_COLOR_DODGE = 8; +const int MODE_COLOR_BURN = 9; +const int MODE_HARD_LIGHT = 10; +const int MODE_SOFT_LIGHT = 11; +const int MODE_DIFFERENCE = 12; +const int MODE_EXCLUSION = 13; +const int MODE_HUE = 14; +const int MODE_SATURATION = 15; +const int MODE_COLOR = 16; + +vec3 unpremultiply(vec4 color) { + return color.a > 0.0 ? color.rgb / color.a : vec3(0.0); +} + +// W3C separable blend B(Cb, Cs) for one channel (straight color in [0, 1]). +float blendChannel(int mode, float cb, float cs) { + if (mode == MODE_MULTIPLY) { + return cb * cs; + } + if (mode == MODE_SCREEN) { + return cb + cs - cb * cs; + } + if (mode == MODE_DARKEN) { + return min(cb, cs); + } + if (mode == MODE_LIGHTEN) { + return max(cb, cs); + } + if (mode == MODE_OVERLAY) { + return cb <= 0.5 ? (2.0 * cb * cs) : (1.0 - 2.0 * (1.0 - cb) * (1.0 - cs)); + } + if (mode == MODE_HARD_LIGHT) { + return cs <= 0.5 ? (2.0 * cb * cs) : (1.0 - 2.0 * (1.0 - cb) * (1.0 - cs)); + } + if (mode == MODE_COLOR_DODGE) { + if (cb <= 0.0) { + return 0.0; + } + return cs >= 1.0 ? 1.0 : min(1.0, cb / (1.0 - cs)); + } + if (mode == MODE_COLOR_BURN) { + if (cb >= 1.0) { + return 1.0; + } + return cs <= 0.0 ? 0.0 : 1.0 - min(1.0, (1.0 - cb) / cs); + } + if (mode == MODE_SOFT_LIGHT) { + if (cs <= 0.5) { + return cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb); + } + float d = cb <= 0.25 ? (((16.0 * cb - 12.0) * cb + 4.0) * cb) : sqrt(cb); + return cb + (2.0 * cs - 1.0) * (d - cb); + } + if (mode == MODE_DIFFERENCE) { + return abs(cb - cs); + } + if (mode == MODE_EXCLUSION) { + return cb + cs - 2.0 * cb * cs; + } + return min(cb, cs); // default: Darken +} + +vec3 blendSeparable(int mode, vec3 cb, vec3 cs) { + return vec3(blendChannel(mode, cb.r, cs.r), blendChannel(mode, cb.g, cs.g), blendChannel(mode, cb.b, cs.b)); +} + +// Non-separable helpers (W3C): operate on the whole color. +float lum(vec3 c) { + return dot(c, vec3(0.3, 0.59, 0.11)); +} + +vec3 clipColor(vec3 c) { + float l = lum(c); + float n = min(min(c.r, c.g), c.b); + float x = max(max(c.r, c.g), c.b); + + if (n < 0.0) { + c = l + ((c - l) * l) / (l - n); + } + if (x > 1.0) { + c = l + ((c - l) * (1.0 - l)) / (x - l); + } + + return c; +} + +vec3 setLum(vec3 c, float l) { + return clipColor(c + (l - lum(c))); +} + +float sat(vec3 c) { + return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b); +} + +// Map the channels so min → 0, max → s, mid → proportional (W3C SetSat result). +vec3 setSat(vec3 c, float s) { + float mn = min(min(c.r, c.g), c.b); + float mx = max(max(c.r, c.g), c.b); + + return mx > mn ? (c - mn) * (s / (mx - mn)) : vec3(0.0); +} + +vec3 blendNonSeparable(int mode, vec3 cb, vec3 cs) { + if (mode == MODE_HUE) { + return setLum(setSat(cs, sat(cb)), lum(cb)); + } + if (mode == MODE_SATURATION) { + return setLum(setSat(cb, sat(cs)), lum(cb)); + } + if (mode == MODE_COLOR) { + return setLum(cs, lum(cb)); + } + return setLum(cb, lum(cs)); // default: Luminosity +} + +vec3 blendAdvanced(int mode, vec3 cb, vec3 cs) { + return mode >= MODE_HUE ? blendNonSeparable(mode, cb, cs) : blendSeparable(mode, cb, cs); +} + +void main(void) { + vec4 src = texture(u_source, v_texcoord); + // The backdrop is captured from the framebuffer (bottom-left origin), so its + // V axis is flipped relative to the source/quad UVs. + vec4 dst = texture(u_backdrop, vec2(v_texcoord.x, 1.0 - v_texcoord.y)); + + float alphaSource = src.a; + float alphaBackdrop = max(dst.a, u_opaqueBackdrop); + vec3 colorSource = unpremultiply(src); + vec3 colorBackdrop = unpremultiply(dst); + + vec3 blended = blendAdvanced(u_mode, colorBackdrop, colorSource); + // Cs' = (1 - αb)·Cs + αb·B(Cb, Cs) + vec3 mixedSource = mix(colorSource, blended, alphaBackdrop); + + // Premultiplied blended source; GPU source-over composites it over backdrop. + fragColor = vec4(mixedSource * alphaSource, alphaSource); +} diff --git a/src/rendering/webgl2/glsl/backdrop-blend.vert b/src/rendering/webgl2/glsl/backdrop-blend.vert new file mode 100644 index 00000000..75ce0da3 --- /dev/null +++ b/src/rendering/webgl2/glsl/backdrop-blend.vert @@ -0,0 +1,14 @@ +#version 300 es +precision mediump float; + +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texcoord; + +uniform mat3 u_projection; + +out vec2 v_texcoord; + +void main(void) { + gl_Position = vec4((u_projection * vec3(a_position, 1.0)).xy, 0.0, 1.0); + v_texcoord = a_texcoord; +} diff --git a/src/rendering/webgpu/WebGpuBackdropBlendCompositor.ts b/src/rendering/webgpu/WebGpuBackdropBlendCompositor.ts new file mode 100644 index 00000000..962ab1fa --- /dev/null +++ b/src/rendering/webgpu/WebGpuBackdropBlendCompositor.ts @@ -0,0 +1,570 @@ +/// + +import { Matrix } from '#math/Matrix'; +import type { RenderTexture } from '#rendering/texture/RenderTexture'; +import type { Texture } from '#rendering/texture/Texture'; +import { BlendModes } from '#rendering/types'; + +import type { WebGpuBackend } from './WebGpuBackend'; +import { getWebGpuBlendState } from './WebGpuBlendState'; +import { stencilContentDepthStencilState } from './WebGpuStencilState'; + +const compositorShaderSource = ` +struct ProjectionUniforms { + matrix: mat4x4, +}; + +struct BlendUniforms { + mode: u32, + opaqueBackdrop: f32, +}; + +@group(0) @binding(0) +var projection: ProjectionUniforms; + +@group(1) @binding(0) +var sourceTexture: texture_2d; +@group(1) @binding(1) +var sourceSampler: sampler; +@group(1) @binding(2) +var backdropTexture: texture_2d; +@group(1) @binding(3) +var backdropSampler: sampler; + +@group(2) @binding(0) +var blend: BlendUniforms; + +struct VertexInput { + @location(0) position: vec2, + @location(1) texcoord: vec2, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) texcoord: vec2, +}; + +@vertex +fn vertexMain(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + output.position = projection.matrix * vec4(input.position, 0.0, 1.0); + output.texcoord = input.texcoord; + + return output; +} + +fn unpremultiply(color: vec4) -> vec3 { + if (color.a > 0.0) { + return color.rgb / color.a; + } + + return vec3(0.0); +} + +// W3C separable blend B(Cb, Cs) for one channel (straight color in [0, 1]). +// Mode values match the BlendModes enum (src/rendering/types.ts). +fn blendChannel(mode: u32, cb: f32, cs: f32) -> f32 { + switch mode { + case 3u { return cb * cs; } // Multiply + case 4u { return cb + cs - cb * cs; } // Screen + case 5u { return min(cb, cs); } // Darken + case 6u { return max(cb, cs); } // Lighten + case 7u { return select(1.0 - 2.0 * (1.0 - cb) * (1.0 - cs), 2.0 * cb * cs, cb <= 0.5); } // Overlay + case 8u { // ColorDodge + if (cb <= 0.0) { return 0.0; } + return select(min(1.0, cb / (1.0 - cs)), 1.0, cs >= 1.0); + } + case 9u { // ColorBurn + if (cb >= 1.0) { return 1.0; } + return select(1.0 - min(1.0, (1.0 - cb) / cs), 0.0, cs <= 0.0); + } + case 10u { return select(1.0 - 2.0 * (1.0 - cb) * (1.0 - cs), 2.0 * cb * cs, cs <= 0.5); } // HardLight + case 11u { // SoftLight + if (cs <= 0.5) { return cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb); } + let d = select(sqrt(cb), ((16.0 * cb - 12.0) * cb + 4.0) * cb, cb <= 0.25); + return cb + (2.0 * cs - 1.0) * (d - cb); + } + case 12u { return abs(cb - cs); } // Difference + case 13u { return cb + cs - 2.0 * cb * cs; } // Exclusion + default { return min(cb, cs); } // Darken + } +} + +fn blendSeparable(mode: u32, cb: vec3, cs: vec3) -> vec3 { + return vec3(blendChannel(mode, cb.x, cs.x), blendChannel(mode, cb.y, cs.y), blendChannel(mode, cb.z, cs.z)); +} + +// Non-separable helpers (W3C): operate on the whole color. +fn lum(c: vec3) -> f32 { + return dot(c, vec3(0.3, 0.59, 0.11)); +} + +fn clipColor(input: vec3) -> vec3 { + var c = input; + let l = lum(c); + let n = min(min(c.x, c.y), c.z); + let x = max(max(c.x, c.y), c.z); + + if (n < 0.0) { c = l + ((c - l) * l) / (l - n); } + if (x > 1.0) { c = l + ((c - l) * (1.0 - l)) / (x - l); } + + return c; +} + +fn setLum(c: vec3, l: f32) -> vec3 { + return clipColor(c + (l - lum(c))); +} + +fn sat(c: vec3) -> f32 { + return max(max(c.x, c.y), c.z) - min(min(c.x, c.y), c.z); +} + +// Map the channels so min -> 0, max -> s, mid -> proportional (W3C SetSat result). +fn setSat(c: vec3, s: f32) -> vec3 { + let mn = min(min(c.x, c.y), c.z); + let mx = max(max(c.x, c.y), c.z); + + return select(vec3(0.0), (c - mn) * (s / (mx - mn)), mx > mn); +} + +fn blendNonSeparable(mode: u32, cb: vec3, cs: vec3) -> vec3 { + switch mode { + case 14u { return setLum(setSat(cs, sat(cb)), lum(cb)); } // Hue + case 15u { return setLum(setSat(cb, sat(cs)), lum(cb)); } // Saturation + case 16u { return setLum(cs, lum(cb)); } // Color + default { return setLum(cb, lum(cs)); } // Luminosity + } +} + +fn blendAdvanced(mode: u32, cb: vec3, cs: vec3) -> vec3 { + if (mode >= 14u) { return blendNonSeparable(mode, cb, cs); } + return blendSeparable(mode, cb, cs); +} + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4 { + let src = textureSample(sourceTexture, sourceSampler, input.texcoord); + // copyTextureToTexture preserves the target's top-left orientation, so the + // backdrop is sampled at the same UV as the quad — no V-flip (unlike the + // WebGL2 framebuffer-blit path, which reads bottom-left order). + let dst = textureSample(backdropTexture, backdropSampler, input.texcoord); + + let alphaSource = src.a; + // An opaque target (the on-screen root canvas, alphaMode 'opaque') has an + // unreliable captured alpha; force full backdrop coverage so the blend is + // not skipped. Offscreen RenderTextures carry real alpha. + let alphaBackdrop = max(dst.a, blend.opaqueBackdrop); + let colorSource = unpremultiply(src); + let colorBackdrop = unpremultiply(dst); + + let blended = blendAdvanced(blend.mode, colorBackdrop, colorSource); + // Cs' = (1 - αb)·Cs + αb·B(Cb, Cs) + let mixedSource = mix(colorSource, blended, alphaBackdrop); + + // Premultiplied blended source; the GPU source-over composites it over the + // untouched backdrop already in the target (αs = 0 passes the backdrop through). + return vec4(mixedSource * alphaSource, alphaSource); +} +`; + +// 4 floats per vertex: position(x, y) + texcoord(u, v). +const vertexStrideBytes = 16; + +// 16 floats per mat4x4 projection uniform. +const projectionUniformBytes = 64; + +// mode (u32) + opaqueBackdrop (f32), padded to the 16-byte uniform alignment. +const blendUniformBytes = 16; + +/** + * Single-quad backdrop-aware blend compositor used by + * `WebGpuBackend.composeWithBackdropBlend` (spike: invoked directly). Captures + * the active target's `[x, y, width, height]` region into a compositor-owned + * texture via `copyTextureToTexture`, samples the premultiplied source (group 1, + * slot 0) and captured backdrop (slot 2), computes the W3C blend for the + * requested {@link BlendModes}, and draws the result over the target with normal + * (premultiplied source-over) blending — so the GPU composites the blended + * source over the backdrop already in the target. + * + * Mirrors {@link WebGpuMaskCompositor}'s structure. The backdrop texture is + * compositor-owned (not a pooled {@link RenderTexture}) because + * `copyTextureToTexture` requires the destination format to equal the source: + * the root canvas uses the preferred format (often `bgra8unorm`) while pooled + * render textures are `rgba8unorm`, so a pool RT cannot receive a root-target + * capture. The owned texture is allocated in the target's own format. + * + * Pipelines are cached per (target format, stencil). The compositor is not an + * {@link AbstractWebGpuRenderer} and never participates in renderer registry + * dispatch — the backend invokes it directly. + */ +export class WebGpuBackdropBlendCompositor { + private readonly _projectionData: Float32Array = new Float32Array(16); + private readonly _vertexData: Float32Array = new Float32Array(16); // 4 verts * 4 floats + private readonly _indexData: Uint16Array = new Uint16Array([0, 1, 2, 0, 2, 3]); + private readonly _blendData: ArrayBuffer = new ArrayBuffer(blendUniformBytes); + private readonly _blendModeView: Uint32Array = new Uint32Array(this._blendData, 0, 1); + private readonly _blendOpaqueView: Float32Array = new Float32Array(this._blendData, 4, 1); + private readonly _projectionMatrix: Matrix = new Matrix(); + private readonly _pipelines: Map = new Map(); + + private _device: GPUDevice | null = null; + private _shaderModule: GPUShaderModule | null = null; + private _projectionBindGroupLayout: GPUBindGroupLayout | null = null; + private _textureBindGroupLayout: GPUBindGroupLayout | null = null; + private _blendBindGroupLayout: GPUBindGroupLayout | null = null; + private _pipelineLayout: GPUPipelineLayout | null = null; + private _vertexBuffer: GPUBuffer | null = null; + private _indexBuffer: GPUBuffer | null = null; + private _projectionBuffer: GPUBuffer | null = null; + private _blendBuffer: GPUBuffer | null = null; + private _projectionBindGroup: GPUBindGroup | null = null; + private _blendBindGroup: GPUBindGroup | null = null; + private _backdropSampler: GPUSampler | null = null; + + // Compositor-owned capture target, re-allocated when the region size or the + // target format changes (see class doc for why a pooled RT cannot be used). + private _backdropTexture: GPUTexture | null = null; + private _backdropView: GPUTextureView | null = null; + private _backdropWidth = 0; + private _backdropHeight = 0; + private _backdropFormat: GPUTextureFormat | null = null; + + public connect(device: GPUDevice): void { + if (this._device !== null) { + return; + } + + this._device = device; + this._shaderModule = device.createShaderModule({ code: compositorShaderSource }); + + this._projectionBindGroupLayout = device.createBindGroupLayout({ + entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }], + }); + + this._textureBindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + ], + }); + + this._blendBindGroupLayout = device.createBindGroupLayout({ + entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }], + }); + + this._pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [this._projectionBindGroupLayout, this._textureBindGroupLayout, this._blendBindGroupLayout], + }); + + this._vertexBuffer = device.createBuffer({ + size: 4 * vertexStrideBytes, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + + this._indexBuffer = device.createBuffer({ + size: 6 * Uint16Array.BYTES_PER_ELEMENT, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); + + device.queue.writeBuffer(this._indexBuffer, 0, this._indexData); + + this._projectionBuffer = device.createBuffer({ + size: projectionUniformBytes, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + this._blendBuffer = device.createBuffer({ + size: blendUniformBytes, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + this._backdropSampler = device.createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + }); + + this._projectionBindGroup = device.createBindGroup({ + layout: this._projectionBindGroupLayout, + entries: [{ binding: 0, resource: { buffer: this._projectionBuffer } }], + }); + + this._blendBindGroup = device.createBindGroup({ + layout: this._blendBindGroupLayout, + entries: [{ binding: 0, resource: { buffer: this._blendBuffer } }], + }); + } + + public disconnect(): void { + if (this._device === null) { + return; + } + + this._vertexBuffer?.destroy(); + this._indexBuffer?.destroy(); + this._projectionBuffer?.destroy(); + this._blendBuffer?.destroy(); + this._backdropTexture?.destroy(); + + this._vertexBuffer = null; + this._indexBuffer = null; + this._projectionBuffer = null; + this._blendBuffer = null; + this._backdropTexture = null; + this._backdropView = null; + this._backdropWidth = 0; + this._backdropHeight = 0; + this._backdropFormat = null; + this._backdropSampler = null; + this._projectionBindGroup = null; + this._blendBindGroup = null; + this._pipelineLayout = null; + this._blendBindGroupLayout = null; + this._textureBindGroupLayout = null; + this._projectionBindGroupLayout = null; + this._shaderModule = null; + this._pipelines.clear(); + this._device = null; + } + + /** + * Composite `source` over the active target's current contents under an + * advanced (backdrop-aware) blend mode. Materializes any pending clear/draws, + * captures the target's `[x, y, width, height]` region (view units) into the + * compositor-owned backdrop texture, runs the blend in a shader, and draws the + * blended source over the untouched backdrop with normal premultiplied + * source-over. + */ + public compose(manager: WebGpuBackend, source: Texture | RenderTexture, x: number, y: number, width: number, height: number, blendMode: BlendModes): void { + if (this._device === null) { + throw new Error('WebGpuBackdropBlendCompositor: not connected.'); + } + + if (width <= 0 || height <= 0) { + return; + } + + const device = this._device; + const target = manager.renderTarget; + + // Clears/draws are deferred on WebGPU; flush so the target texture holds the + // real backdrop before the standalone copy below reads it. + manager.flush(); + + const format = manager.renderTargetFormat; + const attachment = manager._getAttachmentPixelSize(target); + const scaleX = target.root && target.width > 0 ? attachment.width / target.width : 1; + const scaleY = target.root && target.height > 0 ? attachment.height / target.height : 1; + const ox = Math.max(0, Math.floor(x * scaleX)); + const oy = Math.max(0, Math.floor(y * scaleY)); + const cw = Math.max(0, Math.min(Math.round(width * scaleX), attachment.width - ox)); + const ch = Math.max(0, Math.min(Math.round(height * scaleY), attachment.height - oy)); + + if (cw <= 0 || ch <= 0) { + return; + } + + const backdropView = this._ensureBackdrop(device, cw, ch, format); + + // Capture the target region into the backdrop on a standalone encoder; a copy + // cannot run inside a render pass (the coordinator's passes self-submit). + const encoder = device.createCommandEncoder(); + + encoder.copyTextureToTexture( + { texture: manager._renderTargetTexture(target), origin: { x: ox, y: oy, z: 0 } }, + { texture: this._backdropTexture!, origin: { x: 0, y: 0, z: 0 } }, + { width: cw, height: ch, depthOrArrayLayers: 1 }, + ); + + device.queue.submit([encoder.finish()]); + + this._drawBlend(manager, source, backdropView, x, y, width, height, blendMode, target.root); + } + + private _ensureBackdrop(device: GPUDevice, width: number, height: number, format: GPUTextureFormat): GPUTextureView { + if (this._backdropTexture === null || this._backdropWidth !== width || this._backdropHeight !== height || this._backdropFormat !== format) { + this._backdropTexture?.destroy(); + this._backdropTexture = device.createTexture({ + size: { width, height }, + format, + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING, + }); + this._backdropView = this._backdropTexture.createView(); + this._backdropWidth = width; + this._backdropHeight = height; + this._backdropFormat = format; + } + + return this._backdropView!; + } + + private _drawBlend( + manager: WebGpuBackend, + source: Texture | RenderTexture, + backdropView: GPUTextureView, + x: number, + y: number, + width: number, + height: number, + blendMode: BlendModes, + opaqueBackdrop: boolean, + ): void { + const device = this._device!; + + this._writeQuadVertices(x, y, x + width, y + height); + device.queue.writeBuffer(this._vertexBuffer!, 0, this._vertexData); + + this._writeProjectionMatrix(manager.view.getTransform()); + device.queue.writeBuffer(this._projectionBuffer!, 0, this._projectionData); + + this._blendModeView[0] = blendMode; + this._blendOpaqueView[0] = opaqueBackdrop ? 1 : 0; + device.queue.writeBuffer(this._blendBuffer!, 0, this._blendData); + + const sourceBinding = manager.getTextureBinding(source); + + const textureBindGroup = device.createBindGroup({ + layout: this._textureBindGroupLayout!, + entries: [ + { binding: 0, resource: sourceBinding.view }, + { binding: 1, resource: sourceBinding.sampler }, + { binding: 2, resource: backdropView }, + { binding: 3, resource: this._backdropSampler! }, + ], + }); + + const targetFormat = manager.renderTargetFormat; + // A geometric stencil clip can wrap the block (the executor pushes the clip + // outermost), so the compositor may draw into a stencil-enabled pass. Select + // the matching pipeline variant — a stencil-free pipeline is incompatible + // with the pass's depth/stencil attachment. + const stencil = manager._passCoordinator.stencilActive; + const pipeline = this._getOrCreatePipeline(targetFormat, stencil); + + // The blend math is in the shader; composite the blended source over the + // backdrop already in the target with normal premultiplied source-over. + const pass = manager._passCoordinator.acquirePass().pass; + + pass.setPipeline(pipeline); + pass.setBindGroup(0, this._projectionBindGroup); + pass.setBindGroup(1, textureBindGroup); + pass.setBindGroup(2, this._blendBindGroup); + pass.setVertexBuffer(0, this._vertexBuffer); + pass.setIndexBuffer(this._indexBuffer!, 'uint16'); + pass.drawIndexed(6); + + manager.stats.batches++; + manager.stats.drawCalls++; + + manager._passCoordinator.endPass(); + } + + private _getOrCreatePipeline(format: GPUTextureFormat, stencil: boolean): GPURenderPipeline { + const key = `${format}|${stencil ? 's' : 'n'}`; + const cached = this._pipelines.get(key); + + if (cached !== undefined) { + return cached; + } + + const device = this._device!; + const descriptor: GPURenderPipelineDescriptor = { + layout: this._pipelineLayout!, + vertex: { + module: this._shaderModule!, + entryPoint: 'vertexMain', + buffers: [ + { + arrayStride: vertexStrideBytes, + attributes: [ + { shaderLocation: 0, offset: 0, format: 'float32x2' }, + { shaderLocation: 1, offset: 8, format: 'float32x2' }, + ], + }, + ], + }, + fragment: { + module: this._shaderModule!, + entryPoint: 'fragmentMain', + targets: [ + { + format, + // The shader produced the final premultiplied blended source; the + // GPU just source-over composites it over the backdrop. + blend: getWebGpuBlendState(BlendModes.Normal), + }, + ], + }, + primitive: { topology: 'triangle-list' }, + }; + + if (stencil) { + descriptor.depthStencil = stencilContentDepthStencilState(); + } + + const pipeline = device.createRenderPipeline(descriptor); + + this._pipelines.set(key, pipeline); + + return pipeline; + } + + private _writeQuadVertices(left: number, top: number, right: number, bottom: number): void { + const view = this._vertexData; + + // Vertex 0: top-left (UV 0, 0) + view[0] = left; + view[1] = top; + view[2] = 0; + view[3] = 0; + + // Vertex 1: top-right (UV 1, 0) + view[4] = right; + view[5] = top; + view[6] = 1; + view[7] = 0; + + // Vertex 2: bottom-right (UV 1, 1) + view[8] = right; + view[9] = bottom; + view[10] = 1; + view[11] = 1; + + // Vertex 3: bottom-left (UV 0, 1) + view[12] = left; + view[13] = bottom; + view[14] = 0; + view[15] = 1; + } + + private _writeProjectionMatrix(viewMatrix: Matrix): void { + // Pack the 3x3 affine view matrix into a 4x4 column-major mat4x4 for WGSL. + const m = this._projectionMatrix.copy(viewMatrix); + const data = this._projectionData; + + // col 0 + data[0] = m.a; + data[1] = m.c; + data[2] = 0; + data[3] = 0; + // col 1 + data[4] = m.b; + data[5] = m.d; + data[6] = 0; + data[7] = 0; + // col 2 + data[8] = 0; + data[9] = 0; + data[10] = 1; + data[11] = 0; + // col 3 + data[12] = m.x; + data[13] = m.y; + data[14] = 0; + data[15] = 1; + } +} diff --git a/src/rendering/webgpu/WebGpuBackend.ts b/src/rendering/webgpu/WebGpuBackend.ts index 67fb7d55..2a3f187a 100644 --- a/src/rendering/webgpu/WebGpuBackend.ts +++ b/src/rendering/webgpu/WebGpuBackend.ts @@ -29,6 +29,7 @@ import type { BlendModes } from '#rendering/types'; import { ScaleModes, WrapModes } from '#rendering/types'; import type { View } from '#rendering/View'; +import { WebGpuBackdropBlendCompositor } from './WebGpuBackdropBlendCompositor'; import { WebGpuMaskCompositor } from './WebGpuMaskCompositor'; import { WebGpuMeshRenderer } from './WebGpuMeshRenderer'; import { WebGpuPassCoordinator } from './WebGpuPassCoordinator'; @@ -103,6 +104,8 @@ export class WebGpuBackend implements RenderBackend { private readonly _clipPointB: Vector = new Vector(); private readonly _maskCompositor: WebGpuMaskCompositor = new WebGpuMaskCompositor(); private _maskCompositorConnected = false; + private readonly _backdropBlendCompositor: WebGpuBackdropBlendCompositor = new WebGpuBackdropBlendCompositor(); + private _backdropBlendCompositorConnected = false; private _mipmapShaderModule: GPUShaderModule | null = null; private _mipmapBindGroupLayout: GPUBindGroupLayout | null = null; private _mipmapPipelineLayout: GPUPipelineLayout | null = null; @@ -124,6 +127,8 @@ export class WebGpuBackend implements RenderBackend { private _activeDrawCommand: DrawCommand | null = null; private _passCoordinatorInstance: WebGpuPassCoordinator | null = null; private _drawPlanDepth = 0; + private readonly _planBaseStack: number[] = []; + private readonly _planHashStack: number[] = []; public constructor(app: Application) { const canvasOptions = app.options.canvas ?? {}; @@ -240,22 +245,37 @@ export class WebGpuBackend implements RenderBackend { public resetStats(): this { resetRenderStats(this._stats); + // The transform buffer is frame-scoped: reset it once per frame here (was + // previously reset per render() call in _beginDrawPlan). + this._getTransformStorage().buffer.begin(); return this; } + /** Frame-global slot base the plan builder indexes from. @internal */ + public get transformBufferCount(): number { + return this._getTransformStorage().buffer.count; + } + /** @internal */ public _beginDrawPlan(nodeCount: number): void { const storage = this._getTransformStorage(); - storage.begin(nodeCount); + // Do NOT reset the transform buffer here — it is frame-scoped (reset in + // resetStats). The builder already based this plan's node indices at the + // current buffer count, so writes land in fresh frame-global slots and + // batches survive across render() calls. Remember this plan's base so a + // nested plan can free its rows on end. + this._planBaseStack.push(storage.buffer.count); + this._planHashStack.push(storage.buffer.frameHash); // Pre-allocate the GPU storage buffer for the full plan before any group - // flush runs. Without this, a later flush with a higher maxNodeIndex would - // destroy and replace the buffer mid-frame while earlier command buffers - // may still reference the old allocation. - if (nodeCount > 0 && this._device !== null && !this._deviceLost) { - storage.reserve(this._device, nodeCount, this._accountant); + // flush runs. Base the reservation on the frame-global count + this plan's + // nodes so the buffer grows to cover both pre-existing frame rows and new rows. + const reserveCount = storage.buffer.count + nodeCount; + + if (reserveCount > 0 && this._device !== null && !this._deviceLost) { + storage.reserve(this._device, reserveCount, this._accountant); } this._activeDrawCommand = null; @@ -308,10 +328,22 @@ export class WebGpuBackend implements RenderBackend { public _endDrawPlan(): void { this._activeDrawCommand = null; + const planBase = this._planBaseStack.pop() ?? 0; + const planHash = this._planHashStack.pop() ?? 0; + if (this._drawPlanDepth > 0) { this._drawPlanDepth--; } + // A nested plan (filter / cacheAsBitmap) just ended: flush its draws, then + // free its transform rows so the frame-scoped buffer only grows with + // top-level render() calls. Top-level plans (depth back to 0) keep their rows + // so cross-call batching survives to the frame-end flush. + if (this._drawPlanDepth > 0) { + this._flushActiveRenderer(); + this._getTransformStorage().buffer.rewindTo(planBase, planHash); + } + // Only assert balance at the outermost plan: a nested render() (e.g. // cacheAsBitmap drawing its cache sprite) sees the still-open outer clips, // which are not leaks. @@ -458,6 +490,48 @@ export class WebGpuBackend implements RenderBackend { return this; } + public composeWithBackdropBlend(source: RenderTexture, x: number, y: number, width: number, height: number, mode: BlendModes): this { + if (width <= 0 || height <= 0) { + return this; + } + + if (this._deviceLost || this._device === null) { + return this; + } + + this._flushActiveRenderer(); + this._setActiveRenderer(null); + + if (!this._backdropBlendCompositorConnected) { + this._backdropBlendCompositor.connect(this.device); + this._backdropBlendCompositorConnected = true; + } + + this._backdropBlendCompositor.compose(this, source, x, y, width, height, mode); + + return this; + } + + /** + * Return the GPU texture backing `target`. For the root canvas target this is + * `context.getCurrentTexture()` (requires `COPY_SRC` usage configured on the + * canvas context). For a {@link RenderTexture} target it is the managed GPU + * texture. Used internally by {@link WebGpuBackdropBlendCompositor} for + * `copyTextureToTexture` backdrop capture. + * @internal + */ + public _renderTargetTexture(target: RenderTarget): GPUTexture { + if (target === this._rootRenderTarget) { + return this.context.getCurrentTexture(); + } + + if (target instanceof RenderTexture) { + return this._getTextureState(target).texture; + } + + throw new Error('WebGpuBackend._renderTargetTexture: unsupported render target type.'); + } + public popScissorRect(): this { if (this._clipBoundsStack.length === 0) { return this; @@ -549,7 +623,12 @@ export class WebGpuBackend implements RenderBackend { } public setView(view: View | null): this { - this._flushActiveRenderer(); + // Only flush the open batch when the view actually changes. The unconditional + // flush forced one draw call per render() call (each render() re-applies the + // same camera view), defeating cross-call batching. + if (this._renderTarget.view !== view) { + this._flushActiveRenderer(); + } this._renderTarget.setView(view); return this; @@ -612,6 +691,11 @@ export class WebGpuBackend implements RenderBackend { this._maskCompositorConnected = false; } + if (this._backdropBlendCompositorConnected) { + this._backdropBlendCompositor.disconnect(); + this._backdropBlendCompositorConnected = false; + } + this._transformStorage?.destroy(); this._transformStorage = null; this._activeDrawCommand = null; @@ -862,6 +946,9 @@ export class WebGpuBackend implements RenderBackend { device, format, alphaMode: 'opaque', + // COPY_SRC is required by WebGpuBackdropBlendCompositor to capture + // the root-canvas backdrop via copyTextureToTexture. + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); } catch (error) { throw this._createInitializationError('Failed to configure the WebGPU canvas context.', error); @@ -998,6 +1085,11 @@ export class WebGpuBackend implements RenderBackend { this._maskCompositorConnected = false; } + if (this._backdropBlendCompositorConnected) { + this._backdropBlendCompositor.disconnect(); + this._backdropBlendCompositorConnected = false; + } + // Mipmap pipeline cache is keyed to the dead device — drop it. this._mipmapShaderModule = null; this._mipmapBindGroupLayout = null; @@ -1325,7 +1417,9 @@ export class WebGpuBackend implements RenderBackend { const mipmapUsage = this._getMipLevelCount(texture) > 1 ? GPUTextureUsage.RENDER_ATTACHMENT : 0; if (texture instanceof RenderTexture) { - return GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | mipmapUsage; + // COPY_SRC is required by WebGpuBackdropBlendCompositor to capture the + // backdrop from an offscreen RenderTexture target via copyTextureToTexture. + return GPUTextureUsage.COPY_SRC | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | mipmapUsage; } return GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING | mipmapUsage; diff --git a/src/rendering/webgpu/WebGpuBlendState.ts b/src/rendering/webgpu/WebGpuBlendState.ts index 10cc3dcd..3fb80ce2 100644 --- a/src/rendering/webgpu/WebGpuBlendState.ts +++ b/src/rendering/webgpu/WebGpuBlendState.ts @@ -60,36 +60,12 @@ export function getWebGpuBlendState(blendMode: BlendModes): GPUBlendState { dstFactor: 'one-minus-src-alpha', }, }; - case BlendModes.Darken: - // `min`/`max` ignore the blend factors, so this cannot account for source - // coverage — transparent (premultiplied rgb=0) texels darken to black. - // Reliable only for opaque sources. See {@link BlendModes.Darken}. - return { - color: { - operation: 'min', - srcFactor: 'one', - dstFactor: 'one', - }, - alpha: { - operation: 'min', - srcFactor: 'one', - dstFactor: 'one', - }, - }; - case BlendModes.Lighten: - return { - color: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - alpha: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - }; default: + // Modes 5–17 (Darken, Lighten, Overlay, …, Luminosity) are compositor-handled + // (backdrop-aware shader path). Inside the barrier texture capture they render + // as Normal so the captured sprite is pristine premultiplied RGBA; the actual + // W3C blend happens in WebGpuBackdropBlendCompositor. All other unrecognised + // modes fall back to Normal (premultiplied source-over). return { color: { operation: 'add', diff --git a/src/rendering/webgpu/WebGpuTransformStorage.ts b/src/rendering/webgpu/WebGpuTransformStorage.ts index 72a3c9d8..529e3e0c 100644 --- a/src/rendering/webgpu/WebGpuTransformStorage.ts +++ b/src/rendering/webgpu/WebGpuTransformStorage.ts @@ -14,6 +14,7 @@ export class WebGpuTransformStorage { private _storageCapacity = 0; private _storageHash = 0; private _storageCount = -1; + private _needsFullUpload = false; private _accountant: GpuResourceAccountant | null = null; /** GPU bytes currently booked for the storage buffer with the resource accountant. */ private _accountedBytes = 0; @@ -27,7 +28,8 @@ export class WebGpuTransformStorage { return this._buffer; } - public begin(nodeCount: number): void { + /** Reset the underlying frame-scoped buffer. Used directly by tests. @internal */ + public begin(nodeCount = 0): void { this._buffer.begin(nodeCount); } @@ -98,12 +100,40 @@ export class WebGpuTransformStorage { this._growBuffer(device, requiredBytes); } + // A skipped flush (all three guards false) leaves the dirty range uncleared + // until the next begin(). Safe: every write() mixes its slot into _frameHash, + // so a non-empty dirty range always coincides with snapshot.changed = true — + // the upload branch is always taken before any dirty rows could be stale. if (snapshot.changed || snapshot.hash !== this._storageHash || snapshot.count !== this._storageCount) { - const bytes = snapshot.count * slotFloatCount * Float32Array.BYTES_PER_ELEMENT; + // Always consume the dirty range first to clear it — regardless of whether + // the full-upload path (post-grow) or the delta path runs below. Both paths + // are inside this if-branch; the skip case (snapshot unchanged) never reaches + // here, so the dirty range is only consumed when an upload is actually issued. + const { firstRow, rowCount } = this._buffer.consumeDirtyRange(snapshot.count); + + const slotBytes = slotFloatCount * Float32Array.BYTES_PER_ELEMENT; + + if (this._needsFullUpload) { + // Post-grow: the new GPUBuffer is empty; upload the full [0, snapshot.count) + // range so rows already consumed by earlier flushes this frame are present. + device.queue.writeBuffer(this._storageBuffer!, 0, this._buffer.data.buffer, this._buffer.data.byteOffset, snapshot.count * slotBytes); + this._buffer.recordUpload(snapshot.count); + this._accountant?.recordBufferUpload(snapshot.count * slotBytes); + this._needsFullUpload = false; + } else if (rowCount > 0) { + // Normal delta path: upload only the rows written since the last upload. + // A reused slot below the high-water mark is in the dirty range, so it re-uploads. + device.queue.writeBuffer( + this._storageBuffer!, + firstRow * slotBytes, + this._buffer.data.buffer, + this._buffer.data.byteOffset + firstRow * slotBytes, + rowCount * slotBytes, + ); + this._buffer.recordUpload(rowCount); + this._accountant?.recordBufferUpload(rowCount * slotBytes); + } - device.queue.writeBuffer(this._storageBuffer!, 0, this._buffer.data.buffer, this._buffer.data.byteOffset, bytes); - this._buffer.recordUpload(snapshot.count); - this._accountant?.recordBufferUpload(bytes); this._storageHash = snapshot.hash; this._storageCount = snapshot.count; } @@ -142,6 +172,7 @@ export class WebGpuTransformStorage { this._storageCapacity = nextCapacity; this._storageHash = 0; this._storageCount = -1; + this._needsFullUpload = true; // Re-book the storage footprint (free the prior buffer's bytes, allocate the new). this._accountedBytes = this._accountant?.reallocate(this._accountedBytes, nextCapacity) ?? this._accountedBytes; } diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 6900228c..5dce723b 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -304,6 +304,10 @@ export class Loader { private _concurrency: number; private _nextTypeId = 1; + private _fgBatchActive = 0; + private _fgBatchLoaded = 0; + private _fgBatchTotal = 0; + private _backgroundQueue: QueueEntry[] = []; private _backgroundActive = 0; private _backgroundTotal = 0; @@ -319,6 +323,15 @@ export class Loader { /** Dispatched when an asset fails to load during background or bundle loading. */ public readonly onError = new Signal<[type: AssetConstructor, alias: string, error: Error]>(); + /** Fired when the first asset in a new load batch starts fetching. */ + public readonly onLoadStart = new Signal<[key: string, url: string]>(); + /** Fired after each asset settles (loaded or failed). `loaded` = resolved count, `total` = batch size. */ + public readonly onLoadProgress = new Signal<[loaded: number, total: number, key: string]>(); + /** Fired when all queued assets in the batch have settled. */ + public readonly onLoadComplete = new Signal(); + /** Fired when an asset fails to load. Does NOT prevent onLoadComplete. */ + public readonly onLoadError = new Signal<[key: string, error: Error]>(); + public constructor(options: LoaderOptions = {}) { this._basePath = options.basePath ?? ''; this._fetchOptions = options.fetchOptions ?? {}; @@ -770,14 +783,17 @@ export class Loader { // FontAsset requires a family option — infer it from the filename when not provided const options: unknown = ctor === FontAsset ? { family: (path.split('/').pop()?.split(/[?#]/)[0] ?? '').replace(/\.[^.]+$/, '') } : undefined; + this._onFgBatchStart(path, path); let notifyFn: ((success: boolean) => void) | null = null; const promise = this._loadSingle(ctor, path, options).then( v => { notifyFn?.(true); + this._onFgBatchSettled(path, true); return v; }, e => { notifyFn?.(false); + this._onFgBatchSettled(path, false, this._normalizeError(e)); throw e; }, ); @@ -793,14 +809,17 @@ export class Loader { const options = arg2; if (typeof source === 'string') { + this._onFgBatchStart(source, source); let notifyFn: ((success: boolean) => void) | null = null; const promise = this._loadSingle(ctor, source, options).then( v => { notifyFn?.(true); + this._onFgBatchSettled(source, true); return v; }, e => { notifyFn?.(false); + this._onFgBatchSettled(source, false, this._normalizeError(e)); throw e; }, ); @@ -815,18 +834,21 @@ export class Loader { const paths = source as readonly string[]; let notifyFn: ((success: boolean) => void) | null = null; const results: unknown[] = new Array(paths.length); - const promises = paths.map((path, i) => - this._loadSingle(ctor, path, options).then( + const promises = paths.map((path, i) => { + this._onFgBatchStart(path, path); + return this._loadSingle(ctor, path, options).then( v => { results[i] = v; notifyFn?.(true); + this._onFgBatchSettled(path, true); }, e => { notifyFn?.(false); + this._onFgBatchSettled(path, false, this._normalizeError(e)); throw e; }, - ), - ); + ); + }); const promise = Promise.all(promises).then(() => results); @@ -847,13 +869,17 @@ export class Loader { ? options : { ...pathOrConfig, ...(typeof options === 'object' && options !== null ? (options as Record) : {}) }; + this._onFgBatchStart(alias, path); + return this._loadSingle(ctor, alias, itemOptions, path).then( v => { result[alias] = v; notifyFn?.(true); + this._onFgBatchSettled(alias, true); }, e => { notifyFn?.(false); + this._onFgBatchSettled(alias, false, this._normalizeError(e)); throw e; }, ); @@ -1320,6 +1346,10 @@ export class Loader { this.onBundleProgress.destroy(); this.onLoaded.destroy(); this.onError.destroy(); + this.onLoadStart.destroy(); + this.onLoadProgress.destroy(); + this.onLoadComplete.destroy(); + this.onLoadError.destroy(); } // ----------------------------------------------------------------------- @@ -1410,6 +1440,7 @@ export class Loader { let notifyFn: ((success: boolean) => void) | null = null; const itemPromises = items.map(({ alias, asset }) => { + this._onFgBatchStart(alias, asset.source); const ctor = this._assetTypeMap.get(asset.type); if (!ctor) { @@ -1420,6 +1451,7 @@ export class Loader { }, error => { notifyFn?.(false); + this._onFgBatchSettled(alias, false, this._normalizeError(error)); throw error; }, ); @@ -1429,9 +1461,11 @@ export class Loader { resource => { results.set(alias, resource); notifyFn?.(true); + this._onFgBatchSettled(alias, true); }, error => { notifyFn?.(false); + this._onFgBatchSettled(alias, false, this._normalizeError(error)); throw error; }, ); @@ -1641,6 +1675,39 @@ export class Loader { } } + // ----------------------------------------------------------------------- + // Internal — foreground batch tracking + // ----------------------------------------------------------------------- + + private _onFgBatchStart(key: string, url: string): void { + if (this._fgBatchActive === 0) { + this._fgBatchLoaded = 0; + } + + this._fgBatchActive++; + this._fgBatchTotal++; + + if (this._fgBatchActive === 1) { + this.onLoadStart.dispatch(key, url); + } + } + + private _onFgBatchSettled(key: string, success: boolean, error?: Error): void { + if (success) { + this._fgBatchLoaded++; + } else if (error !== undefined) { + this.onLoadError.dispatch(key, error); + } + + this._fgBatchActive--; + this.onLoadProgress.dispatch(this._fgBatchLoaded, this._fgBatchTotal, key); + + if (this._fgBatchActive === 0) { + this._fgBatchTotal = 0; + this.onLoadComplete.dispatch(); + } + } + // ----------------------------------------------------------------------- // Internal — background queue // ----------------------------------------------------------------------- diff --git a/src/ui/ScrollContainer.ts b/src/ui/ScrollContainer.ts new file mode 100644 index 00000000..12bf4020 --- /dev/null +++ b/src/ui/ScrollContainer.ts @@ -0,0 +1,133 @@ +import type { Stage } from '#core/Stage'; +import type { Vector } from '#math/Vector'; +import { Container } from '#rendering/Container'; + +import { Widget } from './Widget'; + +/** Direction(s) in which a {@link ScrollContainer} can scroll. */ +export type ScrollDirection = 'vertical' | 'horizontal' | 'both'; + +/** Options for {@link ScrollContainer}. */ +export interface ScrollContainerOptions { + /** Visible width in pixels. */ + width: number; + /** Visible height in pixels. */ + height: number; + /** Scroll axis. Default `'vertical'`. */ + direction?: ScrollDirection; +} + +/** + * Clipped container that scrolls its content via the mouse wheel. + * + * Add child nodes to {@link ScrollContainer.content} rather than to the + * `ScrollContainer` itself. The content container is offset as the user scrolls, + * while the outer widget is clipped to its declared `width` × `height`. + * + * Mouse-wheel events from the global {@link InputManager} are consumed only + * when the pointer is within the widget's bounds. The container subscribes to + * the app's `onMouseWheel` signal when it enters the scene tree, and + * unsubscribes on detach. + * + * @example + * ```ts + * const scroll = new ScrollContainer({ width: 300, height: 400 }); + * for (let i = 0; i < 20; i++) { + * scroll.content.addChild(new Label(`Item ${i}`).setPosition(0, i * 30)); + * } + * scene.ui.addChild(scroll); + * ``` + */ +export class ScrollContainer extends Widget { + /** Add children here — not to the `ScrollContainer` itself. */ + public readonly content: Container; + + private readonly _direction: ScrollDirection; + private _scrollX = 0; + private _scrollY = 0; + + private readonly _onWheel = (delta: Vector): void => { + const pos = this._stage?.app?.input.getPrimaryPointerPosition(); + + if (pos === null || pos === undefined) { + return; + } + + const bounds = this.getBounds(); + + if (!bounds.contains(pos.x, pos.y)) { + return; + } + + this.scrollBy(this._direction !== 'vertical' ? delta.x : 0, this._direction !== 'horizontal' ? delta.y : 0); + }; + + public constructor(options: ScrollContainerOptions) { + super(); + + this._direction = options.direction ?? 'vertical'; + this.content = new Container(); + this.clip = true; + this.interactive = true; + + this.addChild(this.content); + this.setSize(options.width, options.height); + } + + /** Current horizontal scroll position in pixels. */ + public get scrollX(): number { + return this._scrollX; + } + + /** Current vertical scroll position in pixels. */ + public get scrollY(): number { + return this._scrollY; + } + + /** + * Scroll by `(dx, dy)` pixels, clamped to the scrollable content range. + * Positive values scroll right / down; negative values scroll left / up. + */ + public scrollBy(dx: number, dy: number): void { + this.scrollTo(this._scrollX + dx, this._scrollY + dy); + } + + /** + * Scroll to an absolute `(x, y)` position in pixels, clamped to the + * content range so the content never scrolls past its edges. + */ + public scrollTo(x: number, y: number): void { + const contentBounds = this.content.getBounds(); + + const maxX = Math.max(0, contentBounds.width - this._uiWidth); + const maxY = Math.max(0, contentBounds.height - this._uiHeight); + + this._scrollX = Math.max(0, Math.min(x, maxX)); + this._scrollY = Math.max(0, Math.min(y, maxY)); + + this.content.setPosition(-this._scrollX, -this._scrollY); + } + + protected override _relayout(): void { + // Re-clamp scroll in case the widget was resized. + this.scrollTo(this._scrollX, this._scrollY); + } + + /** @internal — subscribe to the app's wheel signal when entering the scene tree. */ + public override _setStage(stage: Stage | null): void { + const prevApp = this._stage?.app; + const nextApp = stage?.app; + + super._setStage(stage); + + if (prevApp !== nextApp) { + prevApp?.input.onMouseWheel.remove(this._onWheel); + nextApp?.input.onMouseWheel.add(this._onWheel); + } + } + + public override destroy(): void { + this._stage?.app?.input.onMouseWheel.remove(this._onWheel); + super.destroy(); + } +} diff --git a/src/ui/Tooltip.ts b/src/ui/Tooltip.ts new file mode 100644 index 00000000..923fe2b2 --- /dev/null +++ b/src/ui/Tooltip.ts @@ -0,0 +1,181 @@ +import { Color } from '#core/Color'; +import type { InteractionEvent } from '#input/InteractionEvent'; +import { Container } from '#rendering/Container'; +import { Graphics } from '#rendering/primitives/Graphics'; +import type { RenderNode } from '#rendering/RenderNode'; +import { Text } from '#rendering/text/Text'; + +import { UIRoot } from './UIRoot'; + +/** Options for {@link Tooltip}. */ +export interface TooltipOptions { + /** Label text to display. */ + text: string; + /** Horizontal pixel offset from the pointer position. Default `12`. */ + offsetX?: number; + /** Vertical pixel offset from the pointer position. Default `-28`. */ + offsetY?: number; + /** Seconds to wait before the tooltip appears. Default `0.3`. */ + delay?: number; + /** Background fill color as a packed 0xRRGGBB integer. Default `0x222222`. */ + background?: number; + /** Text color as a packed 0xRRGGBB integer. Default `0xffffff`. */ + textColor?: number; + /** Inner padding in pixels around the text. Default `6`. */ + padding?: number; + /** Font size in pixels. Default `12`. */ + fontSize?: number; +} + +/** + * Hover tooltip attached to a {@link RenderNode}. Shows a small text label + * near the pointer after a short delay when the pointer enters `target`, and + * hides it immediately on pointer-out. + * + * The tooltip node is parented to the nearest {@link UIRoot} ancestor of + * `target`, so it always renders in screen space above other content. + * + * The target must have `interactive = true` for the hover signals to fire. + * + * @example + * ```ts + * button.interactive = true; + * const tip = new Tooltip(button, { text: 'Click me!' }); + * // Later: + * tip.destroy(); + * ``` + */ +export class Tooltip { + private readonly _target: RenderNode; + private readonly _offsetX: number; + private readonly _offsetY: number; + private readonly _delayMs: number; + private readonly _background: number; + private readonly _textColor: number; + private readonly _padding: number; + private readonly _fontSize: number; + private readonly _text: string; + + private _node: Container | null = null; + private _timer: ReturnType | null = null; + + private readonly _onPointerOver = (event: InteractionEvent): void => { + this._scheduleShow(event.worldX, event.worldY); + }; + + private readonly _onPointerOut = (): void => { + this._hide(); + }; + + public constructor(target: RenderNode, options: TooltipOptions) { + this._target = target; + this._text = options.text; + this._offsetX = options.offsetX ?? 12; + this._offsetY = options.offsetY ?? -28; + this._delayMs = (options.delay ?? 0.3) * 1000; + this._background = options.background ?? 0x222222; + this._textColor = options.textColor ?? 0xffffff; + this._padding = options.padding ?? 6; + this._fontSize = options.fontSize ?? 12; + + target.onPointerOver.add(this._onPointerOver); + target.onPointerOut.add(this._onPointerOut); + } + + /** Remove the tooltip and clean up all listeners. */ + public destroy(): void { + this._hide(); + this._target.onPointerOver.remove(this._onPointerOver); + this._target.onPointerOut.remove(this._onPointerOut); + } + + private _scheduleShow(x: number, y: number): void { + this._cancelTimer(); + this._timer = setTimeout(() => { + this._show(x, y); + }, this._delayMs); + } + + private _show(x: number, y: number): void { + // Remove any existing tooltip node first. + this._removeNode(); + + const uiRoot = this._findUIRoot(); + + if (uiRoot === null) { + return; + } + + const hex = (packed: number): Color => { + return new Color((packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff, 1); + }; + + const label = new Text(this._text, { + fillColor: hex(this._textColor), + fontSize: this._fontSize, + }); + + const labelBounds = label.getLocalBounds(); + const w = labelBounds.width + this._padding * 2; + const h = labelBounds.height + this._padding * 2; + + const bg = new Graphics(); + + bg.fillColor = hex(this._background); + bg.drawRoundedRectangle(0, 0, w, h, 4); + + label.setPosition(this._padding, this._padding); + + const node = new Container(); + + node.addChild(bg); + node.addChild(label); + node.setPosition(x + this._offsetX, y + this._offsetY); + + this._node = node; + uiRoot.addChild(node); + } + + private _hide(): void { + this._cancelTimer(); + this._removeNode(); + } + + private _removeNode(): void { + if (this._node !== null) { + const p = this._node.parent; + + if (p !== null) { + p.removeChild(this._node); + } + + this._node = null; + } + } + + private _cancelTimer(): void { + if (this._timer !== null) { + clearTimeout(this._timer); + this._timer = null; + } + } + + /** + * Walk up the target's parent chain to find the nearest {@link UIRoot}. + * Returns `null` when the target is not attached to a UI layer. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) + private _findUIRoot(): UIRoot | null { + let current = this._target.parent; + + while (current !== null) { + if (current instanceof UIRoot) { + return current; + } + + current = current.parent; + } + + return null; + } +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 69f6cac7..e627209c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -5,8 +5,12 @@ export type { PanelOptions } from './Panel'; export { Panel } from './Panel'; export type { ProgressBarOptions } from './ProgressBar'; export { ProgressBar } from './ProgressBar'; +export type { ScrollContainerOptions, ScrollDirection } from './ScrollContainer'; +export { ScrollContainer } from './ScrollContainer'; export type { StackDirection, StackOptions } from './Stack'; export { Stack } from './Stack'; +export type { TooltipOptions } from './Tooltip'; +export { Tooltip } from './Tooltip'; export { UIRoot } from './UIRoot'; export type { WidgetAnchor } from './Widget'; export { Widget } from './Widget'; diff --git a/svgo.config.mjs b/svgo.config.mjs new file mode 100644 index 00000000..7b3ce8a7 --- /dev/null +++ b/svgo.config.mjs @@ -0,0 +1,26 @@ +// SVGO config for the ExoJS brand assets (site/public/brand/) and favicons. +// Keeps viewBox + currentColor + accessibility hooks; strips fixed dimensions +// so embedded SVGs scale to their box. +// +// Regenerate (see site/public/brand/README.md for the full recipe): +// for f in /*.svg; do +// npx svgo --multipass --config svgo.config.mjs -i "$f" -o "site/public/brand/$(basename "$f")" +// done +// Favicons are rasterised from site/public/brand/mark-e-dot-dark.svg via +// ImageMagick (a 1024 master -> 512/192/180/96 PNGs + a 48/32/16 .ico). +export default { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, + removeUnknownsAndDefaults: false, + }, + }, + }, + 'removeDimensions', + 'sortAttrs', + ], +}; diff --git a/test/animation/TweenSequencer.test.ts b/test/animation/TweenSequencer.test.ts new file mode 100644 index 00000000..6c073cd6 --- /dev/null +++ b/test/animation/TweenSequencer.test.ts @@ -0,0 +1,472 @@ +import { Tween } from '#animation/Tween'; +import { TweenManager } from '#animation/TweenManager'; +import { TweenSequencer, TweenSequencerState } from '#animation/TweenSequencer'; +import { TweenState } from '#animation/types'; +import { Time } from '#core/Time'; + +/** Wrap a seconds value so it can be passed to TweenManager.update(). */ +const sec = (seconds: number): Time => new Time(seconds, Time.seconds); + +/** Create a minimal target object and a tween that animates x 0→100 over `duration` seconds. */ +const makeTween = (duration = 1.0): { tween: Tween<{ x: number }>; target: { x: number } } => { + const target = { x: 0 }; + const tween = new Tween(target).to({ x: 100 }, duration); + return { tween, target }; +}; + +// ─── Standalone helpers ──────────────────────────────────────────────────────── +// +// Most tests drive the sequencer directly (no TweenManager) so they can use +// precise sub-frame deltas without needing a manager clock. + +describe('TweenSequencer', () => { + // ── Initial state ────────────────────────────────────────────────────────── + + describe('initial state', () => { + test('new sequencer is Idle', () => { + const seq = new TweenSequencer(); + expect(seq.state).toBe(TweenSequencerState.Idle); + }); + + test('progress is 1 when there are no stages', () => { + const seq = new TweenSequencer(); + expect(seq.progress).toBe(1); + }); + + test('start() transitions state to Active', () => { + const seq = new TweenSequencer(); + seq.start(); + expect(seq.state).toBe(TweenSequencerState.Active); + }); + }); + + // ── Sequential stages ────────────────────────────────────────────────────── + + describe('sequential stages', () => { + test('two stages play in order — stage 2 does not start until stage 1 completes', () => { + const { tween: t1, target: a } = makeTween(1.0); + const { tween: t2, target: b } = makeTween(1.0); + + const seq = new TweenSequencer().then(t1).then(t2).start(); + + // After 1 s: t1 should be done, t2 just started. + seq.update(1.0); + expect(t1.state).toBe(TweenState.Complete); + expect(a.x).toBe(100); + + // t2 is now active but has not been ticked yet at this point (it was + // just started inside the same update call's _advanceStage path). One + // more tick advances it. + seq.update(1.0); + expect(t2.state).toBe(TweenState.Complete); + expect(b.x).toBe(100); + }); + + test('three stages complete in sequence', () => { + const { tween: t1 } = makeTween(1.0); + const { tween: t2 } = makeTween(1.0); + const { tween: t3 } = makeTween(1.0); + const onComplete = vi.fn(); + + const seq = new TweenSequencer().then(t1).then(t2).then(t3).onComplete(onComplete).start(); + + seq.update(1.0); // t1 done + seq.update(1.0); // t2 done + seq.update(1.0); // t3 done + + expect(seq.state).toBe(TweenSequencerState.Complete); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + // ── Parallel stage ───────────────────────────────────────────────────────── + + describe('parallel stage (array of tweens)', () => { + test('tweens in an array stage all start at the same time', () => { + const { tween: t1, target: a } = makeTween(1.0); + const { tween: t2, target: b } = makeTween(1.0); + + const seq = new TweenSequencer().then([t1, t2]).start(); + + // Both tweens must be active after start. + expect(t1.state).toBe(TweenState.Active); + expect(t2.state).toBe(TweenState.Active); + + seq.update(0.5); + expect(a.x).toBeCloseTo(50, 5); + expect(b.x).toBeCloseTo(50, 5); + }); + + test('parallel stage waits for the slower tween before advancing', () => { + const { tween: fast } = makeTween(0.5); + const { tween: slow } = makeTween(1.0); + const { tween: next } = makeTween(1.0); + const onComplete = vi.fn(); + + new TweenSequencer().then([fast, slow]).then(next).onComplete(onComplete).start(); + + // After 0.5 s: fast done, slow still running; stage should not have advanced. + fast.update(0.5); + slow.update(0.5); + // Manually simulate a sequencer tick (stand-alone mode) + // Note: we already ticked the tweens above; re-check stage completion. + // Use a dedicated sequencer to drive everything properly. + const { tween: f2 } = makeTween(0.5); + const { tween: s2 } = makeTween(1.0); + const { tween: n2, target: nt } = makeTween(1.0); + const seq2 = new TweenSequencer().then([f2, s2]).then(n2).onComplete(onComplete).start(); + + seq2.update(0.5); // f2 done, s2 at 50% + expect(f2.state).toBe(TweenState.Complete); + expect(s2.state).toBe(TweenState.Active); + expect(n2.state).toBe(TweenState.Idle); // next stage not started yet + + seq2.update(0.5); // s2 done → advance to next stage, n2 starts + expect(s2.state).toBe(TweenState.Complete); + + seq2.update(1.0); // n2 runs to completion + expect(n2.state).toBe(TweenState.Complete); + expect(nt.x).toBe(100); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + // ── wait() delay ─────────────────────────────────────────────────────────── + + describe('wait()', () => { + test('inserts a timed pause between stages', () => { + const { tween: t1 } = makeTween(1.0); + const { tween: t2 } = makeTween(1.0); + const onComplete = vi.fn(); + + const seq = new TweenSequencer().then(t1).wait(0.5).then(t2).onComplete(onComplete).start(); + + seq.update(1.0); // t1 done → enters delay + expect(t1.state).toBe(TweenState.Complete); + expect(t2.state).toBe(TweenState.Idle); // not yet + + seq.update(0.4); // 0.4 s into 0.5 s delay — t2 still idle + expect(t2.state).toBe(TweenState.Idle); + + seq.update(0.2); // 0.6 s total in delay → past 0.5 s threshold → t2 starts + expect(t2.state).toBe(TweenState.Active); + + seq.update(1.0); // t2 completes + expect(seq.state).toBe(TweenSequencerState.Complete); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + // ── onComplete ───────────────────────────────────────────────────────────── + + describe('onComplete', () => { + test('fires exactly once after all stages finish', () => { + const { tween } = makeTween(1.0); + const onComplete = vi.fn(); + + const seq = new TweenSequencer().then(tween).onComplete(onComplete).start(); + + seq.update(1.0); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(seq.state).toBe(TweenSequencerState.Complete); + }); + + test('does not fire on stop()', () => { + const { tween } = makeTween(1.0); + const onComplete = vi.fn(); + + const seq = new TweenSequencer().then(tween).onComplete(onComplete).start(); + seq.stop(); + + expect(onComplete).not.toHaveBeenCalled(); + }); + + test('fires once even with empty stage list', () => { + const onComplete = vi.fn(); + const seq = new TweenSequencer().onComplete(onComplete).start(); + seq.update(0.016); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + // ── onStart ──────────────────────────────────────────────────────────────── + + describe('onStart', () => { + test('fires on the first update() after start()', () => { + const { tween } = makeTween(1.0); + const onStart = vi.fn(); + + const seq = new TweenSequencer().then(tween).onStart(onStart).start(); + + expect(onStart).not.toHaveBeenCalled(); // not yet — no update + + seq.update(0.1); + expect(onStart).toHaveBeenCalledTimes(1); + + seq.update(0.1); + expect(onStart).toHaveBeenCalledTimes(1); // still just once + }); + }); + + // ── stop() ───────────────────────────────────────────────────────────────── + + describe('stop()', () => { + test('sets state to Stopped', () => { + const { tween } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + seq.stop(); + expect(seq.state).toBe(TweenSequencerState.Stopped); + }); + + test('stops the current-stage tweens', () => { + const { tween } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + seq.update(0.3); // tween now mid-flight + seq.stop(); + expect(tween.state).toBe(TweenState.Stopped); + }); + + test('update() after stop() is a no-op', () => { + const { tween, target } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + seq.update(0.5); + const xAtStop = target.x; + seq.stop(); + seq.update(1.0); + expect(target.x).toBe(xAtStop); + }); + + test('stop() on an Idle sequencer does nothing', () => { + const seq = new TweenSequencer(); + expect(() => seq.stop()).not.toThrow(); + expect(seq.state).toBe(TweenSequencerState.Idle); + }); + }); + + // ── pause() / resume() ───────────────────────────────────────────────────── + + describe('pause() / resume()', () => { + test('pause() freezes progress; resume() continues', () => { + const { tween, target } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + + seq.update(0.3); + seq.pause(); + const xAtPause = target.x; + + // Further updates while paused must not advance things. + seq.update(0.5); + expect(target.x).toBe(xAtPause); + expect(seq.state).toBe(TweenSequencerState.Paused); + + seq.resume(); + seq.update(0.7); // 0.3 + 0.7 = 1.0 s total — tween should complete + expect(target.x).toBe(100); + expect(seq.state).toBe(TweenSequencerState.Complete); + }); + + test('pause() pauses current-stage tweens', () => { + const { tween } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + seq.update(0.3); + seq.pause(); + expect(tween.state).toBe(TweenState.Paused); + }); + + test('resume() resumes current-stage tweens', () => { + const { tween } = makeTween(1.0); + const seq = new TweenSequencer().then(tween).start(); + seq.update(0.3); + seq.pause(); + seq.resume(); + expect(tween.state).toBe(TweenState.Active); + }); + }); + + // ── repeat() ─────────────────────────────────────────────────────────────── + + describe('repeat()', () => { + test('repeat(2) plays the sequence 3 times; onComplete fires once at the very end', () => { + const onComplete = vi.fn(); + + const makeSeq = (): TweenSequencer => { + const { tween: t1 } = makeTween(1.0); + const { tween: t2 } = makeTween(1.0); + return new TweenSequencer().then(t1).then(t2).repeat(2).onComplete(onComplete); + }; + + const seq = makeSeq().start(); + + // Pass 1 + seq.update(1.0); // t1 done + seq.update(1.0); // t2 done → triggers next pass + + expect(onComplete).not.toHaveBeenCalled(); + expect(seq.state).toBe(TweenSequencerState.Active); + + // Pass 2 — tweens restarted from inside _startCurrentStage + seq.update(1.0); + seq.update(1.0); + expect(onComplete).not.toHaveBeenCalled(); + + // Pass 3 (final) + seq.update(1.0); + seq.update(1.0); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(seq.state).toBe(TweenSequencerState.Complete); + }); + + test('repeat(-1) keeps the sequence active indefinitely', () => { + const { tween } = makeTween(0.1); + const seq = new TweenSequencer().then(tween).repeat(-1).start(); + + for (let i = 0; i < 50; i++) { + seq.update(0.1); + } + + expect(seq.state).toBe(TweenSequencerState.Active); + }); + }); + + // ── progress ─────────────────────────────────────────────────────────────── + + describe('progress', () => { + test('starts at 0, advances to 1 as stages complete', () => { + const { tween: t1 } = makeTween(1.0); + const { tween: t2 } = makeTween(1.0); + const { tween: t3 } = makeTween(1.0); + + const seq = new TweenSequencer().then(t1).then(t2).then(t3).start(); + + expect(seq.progress).toBeCloseTo(0, 5); + + seq.update(1.0); // stage 0 done + expect(seq.progress).toBeCloseTo(1 / 3, 5); + + seq.update(1.0); // stage 1 done + expect(seq.progress).toBeCloseTo(2 / 3, 5); + + seq.update(1.0); // stage 2 done + expect(seq.progress).toBeCloseTo(1, 5); + }); + }); + + // ── TweenManager integration ─────────────────────────────────────────────── + + describe('TweenManager integration', () => { + test('createSequencer() returns a sequencer bound to the manager', () => { + const manager = new TweenManager(); + const seq = manager.createSequencer(); + expect(seq).toBeInstanceOf(TweenSequencer); + }); + + test('manager drives the sequencer and its tweens each frame', () => { + const manager = new TweenManager(); + const { tween: t1, target: a } = makeTween(1.0); + const { tween: t2, target: b } = makeTween(1.0); + + const seq = manager.createSequencer().then(t1).then(t2).start(); + + // Frame 1: manager ticks tweens first (t1 advances), then ticks sequencer. + manager.update(sec(1.0)); // t1 completes; sequencer sees it and starts t2 + expect(t1.state).toBe(TweenState.Complete); + expect(a.x).toBe(100); + + // Frame 2: t2 advances and completes. + manager.update(sec(1.0)); + expect(t2.state).toBe(TweenState.Complete); + expect(b.x).toBe(100); + expect(seq.state).toBe(TweenSequencerState.Complete); + }); + + test('sequencer is removed from manager on complete', () => { + const manager = new TweenManager(); + const { tween } = makeTween(1.0); + const seq = manager.createSequencer().then(tween).start(); + + manager.update(sec(1.0)); // completes + expect(seq.state).toBe(TweenSequencerState.Complete); + + // Subsequent manager updates must not error (ticker already removed). + expect(() => manager.update(sec(1.0))).not.toThrow(); + }); + + test('sequencer is removed from manager on stop()', () => { + const manager = new TweenManager(); + const { tween } = makeTween(1.0); + const seq = manager.createSequencer().then(tween).start(); + + manager.update(sec(0.3)); + seq.stop(); + + // No crash and no further advancement. + expect(() => manager.update(sec(1.0))).not.toThrow(); + }); + + test('manager.clear() also removes tickers', () => { + const manager = new TweenManager(); + const { tween } = makeTween(1.0); + const onComplete = vi.fn(); + + manager.createSequencer().then(tween).onComplete(onComplete).start(); + manager.clear(); + manager.update(sec(2.0)); + + expect(onComplete).not.toHaveBeenCalled(); + }); + + test('addTicker is idempotent — registering the same sequencer twice does not double-tick', () => { + const manager = new TweenManager(); + const { tween, target } = makeTween(1.0); + const seq = manager.createSequencer().then(tween).start(); + + // Simulate accidentally calling start() again (which calls addTicker again). + // The sequencer resets, but the ticker must not be in the list twice. + seq.start(); + manager.update(sec(0.5)); + expect(target.x).toBeCloseTo(50, 5); // exactly one advancement + }); + }); + + // ── Nested: 3 stages × 2 tweens each ────────────────────────────────────── + + describe('nested parallel stages', () => { + test('3 stages, each with 2 parallel tweens, all complete correctly', () => { + const targets = Array.from({ length: 6 }, () => ({ x: 0 })); + const tweens = targets.map(t => new Tween(t).to({ x: 100 }, 1.0)); + const onComplete = vi.fn(); + + const seq = new TweenSequencer() + .then([tweens[0]!, tweens[1]!]) + .then([tweens[2]!, tweens[3]!]) + .then([tweens[4]!, tweens[5]!]) + .onComplete(onComplete) + .start(); + + // Stage 0 + seq.update(1.0); + expect(tweens[0]!.state).toBe(TweenState.Complete); + expect(tweens[1]!.state).toBe(TweenState.Complete); + expect(tweens[2]!.state).toBe(TweenState.Active); + expect(tweens[3]!.state).toBe(TweenState.Active); + + // Stage 1 + seq.update(1.0); + expect(tweens[2]!.state).toBe(TweenState.Complete); + expect(tweens[3]!.state).toBe(TweenState.Complete); + expect(tweens[4]!.state).toBe(TweenState.Active); + expect(tweens[5]!.state).toBe(TweenState.Active); + + // Stage 2 + seq.update(1.0); + expect(tweens[4]!.state).toBe(TweenState.Complete); + expect(tweens[5]!.state).toBe(TweenState.Complete); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(seq.state).toBe(TweenSequencerState.Complete); + + for (const t of targets) { + expect(t.x).toBe(100); + } + }); + }); +}); diff --git a/test/audio/audio-manager-update.test.ts b/test/audio/audio-manager-update.test.ts index a02d83b0..870b8c7a 100644 --- a/test/audio/audio-manager-update.test.ts +++ b/test/audio/audio-manager-update.test.ts @@ -129,6 +129,7 @@ describe('AudioManager.update()', () => { elapsedTime: { milliseconds: 16, seconds: 0.016 }, restart: vi.fn(), }; + rawApp['_fixed'] = { advance: () => 0, alpha: 0 }; rawApp['_updateHandler'] = vi.fn(); rawApp['_frameCount'] = 0; rawApp['onFrame'] = { dispatch: vi.fn() }; diff --git a/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index fc1b146a..0294231f 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -39,6 +39,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "ChannelSize", "Circle", "Clock", + "Codec", "Collision", "CollisionType", "Color", @@ -92,6 +93,8 @@ exports[`root index export surface snapshot > sorted runtime export names match "LinearGradient", "Loader", "LoadingQueue", + "LogSeverity", + "Logger", "LowpassFilter", "LutFilter", "Material", @@ -137,6 +140,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Scene", "SceneManager", "SceneNode", + "ScrollContainer", "Segment", "SerializationRegistry", "Shader", @@ -172,8 +176,11 @@ exports[`root index export surface snapshot > sorted runtime export names match "TextureRegion", "Time", "Timer", + "Tooltip", "Tween", "TweenManager", + "TweenSequencer", + "TweenSequencerState", "TweenState", "UIRoot", "Vector", @@ -199,7 +206,9 @@ exports[`root index export surface snapshot > sorted runtime export names match "defineAssetManifest", "getAudioContext", "getOfflineAudioContext", + "isAdvancedBlendMode", "isAudioContextReady", + "logger", "maxPointers", "onAudioContextReady", "pointerSlotSize", diff --git a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap index 7d372a0a..0ad001a6 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -80,6 +80,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "CircleLike: interface", "Clock: class", "Cloneable: interface", + "Codec: variable", "Collidable: interface", "Collision: variable", "CollisionResponse: interface", @@ -99,6 +100,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "DataTextureOptions: interface", "Database: interface", "DecodedImage: type alias", + "DecompressFormat: type alias", "DeserializeContext: interface", "Destroyable: interface", "DisposalScope: class", @@ -204,6 +206,10 @@ exports[`root index type-level export inventory > all exported symbols with kind "LoaderOptions: interface", "LoadingProgress: interface", "LoadingQueue: class", + "LogChannel: type alias", + "LogEntry: interface", + "LogSeverity: enum", + "Logger: class", "Loopable: interface", "LowpassFilter: class", "LowpassFilterOptions: interface", @@ -291,6 +297,9 @@ exports[`root index type-level export inventory > all exported symbols with kind "SceneNode: class", "SceneNodeConstructor: type alias", "SceneTransition: type alias", + "ScrollContainer: class", + "ScrollContainerOptions: interface", + "ScrollDirection: type alias", "Seekable: interface", "Segment: class", "SerializationRegistry: class", @@ -359,10 +368,14 @@ exports[`root index type-level export inventory > all exported symbols with kind "Time: class", "TimeInterval: type alias", "Timer: class", + "Tooltip: class", + "TooltipOptions: interface", "Topology: type alias", "Tween: class", "TweenLifecycleCallback: type alias", "TweenManager: class", + "TweenSequencer: class", + "TweenSequencerState: enum", "TweenState: enum", "TweenUpdateCallback: type alias", "TypedArray: type alias", @@ -404,7 +417,9 @@ exports[`root index type-level export inventory > all exported symbols with kind "defineAssetManifest: function", "getAudioContext: variable", "getOfflineAudioContext: variable", + "isAdvancedBlendMode: variable", "isAudioContextReady: variable", + "logger: variable", "maxPointers: variable", "onAudioContextReady: variable", "pointerSlotSize: variable", diff --git a/test/core/application-loop.test.ts b/test/core/application-loop.test.ts index 5feef0c9..55a3a621 100644 --- a/test/core/application-loop.test.ts +++ b/test/core/application-loop.test.ts @@ -364,4 +364,59 @@ describe('Application.update() — loop timing (F1 + F2)', () => { expect(app.backend.resetStats).not.toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // Fixed timestep — accumulator-driven fixedUpdate / onFixedFrame / frameAlpha + // ------------------------------------------------------------------------- + + describe('Fixed timestep', () => { + const STEP_MS = 1000 / 60; + + test('runs one fixed step per single-step frame', () => { + const fixedSpy = vi.spyOn(app.scene, 'fixedUpdate'); + + mockFrameElapsed(app, STEP_MS); + app.update(); + + expect(fixedSpy).toHaveBeenCalledTimes(1); + }); + + test('runs multiple fixed steps for a multi-step frame', () => { + const fixedSpy = vi.spyOn(app.scene, 'fixedUpdate'); + + mockFrameElapsed(app, STEP_MS * 3); + app.update(); + + expect(fixedSpy).toHaveBeenCalledTimes(3); + }); + + test('dispatches onFixedFrame once per fixed step', () => { + let count = 0; + app.onFixedFrame.add(() => { + count++; + }); + + mockFrameElapsed(app, STEP_MS * 2); + app.update(); + + expect(count).toBe(2); + }); + + test('frameAlpha reports the leftover sub-step fraction', () => { + mockFrameElapsed(app, STEP_MS * 1.5); + app.update(); + + expect(app.frameAlpha).toBeCloseTo(0.5, 4); + }); + + test('caps fixed steps per frame (spiral-of-death guard)', () => { + const fixedSpy = vi.spyOn(app.scene, 'fixedUpdate'); + + // The frame delta is clamped to 100 ms first → 6 steps wanted, capped at 5. + mockFrameElapsed(app, 1000); + app.update(); + + expect(fixedSpy).toHaveBeenCalledTimes(5); + }); + }); }); diff --git a/test/core/application-on-frame.test.ts b/test/core/application-on-frame.test.ts index 0b61a35c..45e23578 100644 --- a/test/core/application-on-frame.test.ts +++ b/test/core/application-on-frame.test.ts @@ -176,6 +176,7 @@ describe('Application.onFrame', () => { rawApp['scene'] = sceneManager; rawApp['_backend'] = backend; rawApp['_frameClock'] = { elapsedTime: { milliseconds: 16, seconds: 0.016 }, restart: vi.fn() }; + rawApp['_fixed'] = { advance: () => 0, alpha: 0 }; rawApp['_updateHandler'] = vi.fn(); rawApp['_frameCount'] = 0; rawApp['onFrame'] = onFrame; diff --git a/test/core/application.test.ts b/test/core/application.test.ts index 15634a15..2890cd09 100644 --- a/test/core/application.test.ts +++ b/test/core/application.test.ts @@ -214,6 +214,7 @@ describe('Application', () => { rawApp['scene'] = sceneManager; rawApp['_backend'] = backend; rawApp['_frameClock'] = frameClock; + rawApp['_fixed'] = { advance: () => 0, alpha: 0 }; rawApp['_updateHandler'] = vi.fn(); rawApp['_frameCount'] = 0; rawApp['onFrame'] = { dispatch: vi.fn() }; @@ -507,6 +508,7 @@ describe('Application', () => { rawApp['scene'] = sceneManager; rawApp['_activeClock'] = activeClock; rawApp['_frameClock'] = frameClock; + rawApp['_fixed'] = { advance: () => 0, alpha: 0 }; app.stop(); await Promise.resolve(); diff --git a/test/core/codec.test.ts b/test/core/codec.test.ts new file mode 100644 index 00000000..5dc914ae --- /dev/null +++ b/test/core/codec.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { Codec } from '#core/Codec'; + +// Helper: gzip/deflate a byte buffer with the native CompressionStream so the +// decompress round-trip can be asserted without committing binary fixtures. +async function compress(bytes: Uint8Array, format: 'gzip' | 'deflate'): Promise { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes as BufferSource); + controller.close(); + }, + }); + const reader = source.pipeThrough(new CompressionStream(format)).getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +describe('Codec.decodeBase64', () => { + it('decodes a standard base64 string to bytes', () => { + // "Man" → TWFu + expect([...Codec.decodeBase64('TWFu')]).toEqual([0x4d, 0x61, 0x6e]); + }); + + it('ignores embedded whitespace and newlines', () => { + const withWhitespace = 'TW\nFu '; + expect([...Codec.decodeBase64(withWhitespace)]).toEqual([0x4d, 0x61, 0x6e]); + }); + + it('round-trips arbitrary bytes via btoa', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + const b64 = btoa(binary); + expect([...Codec.decodeBase64(b64)]).toEqual([...bytes]); + }); +}); + +describe('Codec.decompress', () => { + it('round-trips gzip-compressed data', async () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const gz = await compress(original, 'gzip'); + const out = await Codec.decompress(gz, 'gzip'); + expect([...out]).toEqual([...original]); + }); + + it('round-trips zlib (deflate) data', async () => { + const original = new Uint8Array(Array.from({ length: 64 }, (_, i) => i % 7)); + const zl = await compress(original, 'deflate'); + const out = await Codec.decompress(zl, 'deflate'); + expect([...out]).toEqual([...original]); + }); +}); diff --git a/test/core/fixed-timestep.test.ts b/test/core/fixed-timestep.test.ts new file mode 100644 index 00000000..437e068c --- /dev/null +++ b/test/core/fixed-timestep.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { FixedTimestep } from '#core/FixedTimestep'; + +/** + * The fixed-timestep accumulator that converts variable frame deltas into a + * whole number of fixed-size steps plus a leftover interpolation alpha. + */ +describe('FixedTimestep', () => { + const STEP = 1000 / 60; // ms + + it('runs one step when exactly one step of time elapses', () => { + const fixed = new FixedTimestep(STEP, 5); + + expect(fixed.advance(STEP)).toBe(1); + expect(fixed.alpha).toBeCloseTo(0, 5); + }); + + it('runs no step and reports a fractional alpha for a partial step', () => { + const fixed = new FixedTimestep(STEP, 5); + + expect(fixed.advance(STEP * 0.5)).toBe(0); + expect(fixed.alpha).toBeCloseTo(0.5, 5); + }); + + it('accumulates leftover time across frames', () => { + const fixed = new FixedTimestep(STEP, 5); + + expect(fixed.advance(STEP * 0.7)).toBe(0); + expect(fixed.alpha).toBeCloseTo(0.7, 5); + expect(fixed.advance(STEP * 0.7)).toBe(1); // 1.4 steps accumulated → 1 step, 0.4 left + expect(fixed.alpha).toBeCloseTo(0.4, 5); + }); + + it('runs multiple steps for a multi-step delta', () => { + const fixed = new FixedTimestep(STEP, 5); + + expect(fixed.advance(STEP * 3)).toBe(3); + expect(fixed.alpha).toBeCloseTo(0, 5); + }); + + it('caps steps at maxSteps and drops the backlog (spiral-of-death guard)', () => { + const fixed = new FixedTimestep(STEP, 5); + + expect(fixed.advance(STEP * 100)).toBe(5); // capped + expect(fixed.alpha).toBeGreaterThanOrEqual(0); + expect(fixed.alpha).toBeLessThan(1); // backlog dropped, not carried into the next frame + + expect(fixed.advance(STEP * 0.1)).toBeLessThanOrEqual(1); // no replay of the dropped backlog + }); + + it('reset() clears the accumulator', () => { + const fixed = new FixedTimestep(STEP, 5); + + fixed.advance(STEP * 0.8); + fixed.reset(); + + expect(fixed.alpha).toBe(0); + }); +}); diff --git a/test/core/oriented-bounds.test.ts b/test/core/oriented-bounds.test.ts new file mode 100644 index 00000000..6907df76 --- /dev/null +++ b/test/core/oriented-bounds.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { SceneNode } from '#core/SceneNode'; +import { Rectangle } from '#math/Rectangle'; +import { Container } from '#rendering/Container'; +import { Drawable } from '#rendering/Drawable'; + +/** + * Oriented bounding-box (OBB) collision axes for rotated nodes. A rotated node's + * SAT collision must use its true oriented-box normals/projection — not the loose + * axis-aligned `getBounds()` AABB, which reports the wrong separating axes and so + * accepts collisions the oriented box rejects. + */ +describe('SceneNode oriented bounds (rotated SAT)', () => { + it('getNormals() returns oriented (non-axis-aligned) axes for a rotated node', () => { + const node = new SceneNode(); + node.getLocalBounds().set(0, 0, 100, 40); + node.setRotation(45); + node.updateParentTransform(); + + const normals = node.getNormals(); + + // At 45° at least one edge normal must be diagonal — an AABB reports only + // axis-aligned (±1, 0) / (0, ±1) normals. + const hasDiagonal = normals.some(n => Math.abs(n.x) > 0.1 && Math.abs(n.y) > 0.1); + expect(hasDiagonal).toBe(true); + }); + + it('intersectsWith() rejects an AABB false-positive against a rotated node', () => { + // An 80×80 square rotated 45° about its centre at world (100, 100): its AABB is + // ~[43, 157]² but the oriented diamond does not reach the AABB corners. + const node = new SceneNode(); + node.getLocalBounds().set(0, 0, 80, 80); + node.setOrigin(40, 40); + node.setPosition(100, 100); + node.setRotation(45); + node.updateParentTransform(); + + // A small target in the AABB corner — inside the AABB, outside the diamond. + const target = new Rectangle(148, 148, 6, 6); + + expect(node.getBounds().intersectsWith(target)).toBe(true); // genuinely inside the loose AABB + expect(node.intersectsWith(target)).toBe(false); // but the oriented box rejects it + }); + + it('uses oriented axes when the rotation is inherited from a rotated parent', () => { + // The child has no rotation of its own — its world box is rotated only because + // the parent is. isAlignedBox (a node-local check) is therefore true, yet the + // collision must still test the oriented world box. + const parent = new Container(); + parent.setPosition(100, 100); + parent.setRotation(45); + + const child = new Drawable(); + child.getLocalBounds().set(-40, -40, 80, 80); // centred on the parent's pivot + parent.addChild(child); + child.updateParentTransform(); + + expect(child.isAlignedBox).toBe(true); // the child's own rotation is zero + + const target = new Rectangle(148, 148, 6, 6); // in the world AABB corner, outside the diamond + + expect(child.getBounds().intersectsWith(target)).toBe(true); // genuinely inside the loose world AABB + expect(child.intersectsWith(target)).toBe(false); // but the inherited-oriented box rejects it + }); +}); diff --git a/test/core/scene-manager.test.ts b/test/core/scene-manager.test.ts index 40363edc..969a2c02 100644 --- a/test/core/scene-manager.test.ts +++ b/test/core/scene-manager.test.ts @@ -92,7 +92,7 @@ const tick = (manager: SceneManager, milliseconds = 16): void => { manager.update(new Time(milliseconds)); }; -type SceneHooks = Partial>; +type SceneHooks = Partial>; const makeScene = (hooks: SceneHooks = {}): Scene => Object.assign(new Scene(), hooks); @@ -204,6 +204,21 @@ describe('SceneManager', () => { expect(secondInit).toHaveBeenCalledTimes(1); }); + test('fixedUpdate dispatches to the active scene unless it is paused', async () => { + const manager = new SceneManager(createApplicationStub()); + const fixedUpdate = vi.fn(); + const scene = makeScene({ fixedUpdate }); + + await manager.setScene(scene); + + manager.fixedUpdate(new Time(16)); + expect(fixedUpdate).toHaveBeenCalledTimes(1); + + scene.paused = true; + manager.fixedUpdate(new Time(16)); + expect(fixedUpdate).toHaveBeenCalledTimes(1); // paused → skipped + }); + test('setScene to the already-active scene is a no-op', async () => { const manager = new SceneManager(createApplicationStub()); const init = vi.fn(async () => undefined); diff --git a/test/perf/rendering/harness.ts b/test/perf/rendering/harness.ts index cd21b523..2af75114 100644 --- a/test/perf/rendering/harness.ts +++ b/test/perf/rendering/harness.ts @@ -7,6 +7,7 @@ * * @internal Test/perf-only. */ +import { playRenderTree } from '#rendering/plan/playRenderTree'; import type { RenderNode } from '#rendering/RenderNode'; import type { View } from '#rendering/View'; import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; @@ -161,3 +162,49 @@ export const measureSteadyFrame = (harness: WebGl2Harness, root: RenderNode, war return metrics!; }; + +/** + * Render each node via its own setView + playRenderTree (exactly what + * RenderingContext.render does per call), then flush once — i.e. the + * "one context.render() per drawable in a loop" pattern. Returns the metrics of + * the final warmed frame. + */ +export const measureCrossCallFrame = (harness: WebGl2Harness, nodes: readonly RenderNode[], warmupFrames = 2): FrameMetrics => { + const { backend, recorder } = harness; + let metrics: FrameMetrics | null = null; + + for (let i = 0; i <= warmupFrames; i++) { + backend.resetStats(); + recorder.reset(); + backend.clear(); + + const view = backend.view; + for (const node of nodes) { + backend.setView(view); + playRenderTree(node, backend); + } + backend.flush(); + + const stats = backend.stats; + metrics = { + drawCalls: stats.drawCalls, + batches: stats.batches, + instances: recorder.instances, + visibleNodes: stats.submittedNodes, + culledNodes: stats.culledNodes, + renderPasses: stats.renderPasses, + textureBinds: recorder.textureBinds, + samplerBinds: recorder.samplerBinds, + programChanges: recorder.programChanges, + blendChanges: recorder.blendChanges, + bufferUploads: recorder.bufferUploads, + bufferReallocations: recorder.bufferReallocations, + uploadedBufferBytes: recorder.bufferUploadBytes, + transformRows: recorder.transformRows, + transformUploads: recorder.transformUploads, + transformUploadBytes: recorder.transformUploadBytes, + }; + } + + return metrics!; +}; diff --git a/test/perf/rendering/structural-sprite.test.ts b/test/perf/rendering/structural-sprite.test.ts index 00fd1cb9..2ca4440c 100644 --- a/test/perf/rendering/structural-sprite.test.ts +++ b/test/perf/rendering/structural-sprite.test.ts @@ -14,7 +14,7 @@ import { Sprite } from '#rendering/sprite/Sprite'; import type { BlendModes } from '#rendering/types'; import { buildSpriteScene, makeTextures } from './fixtures'; -import { createWebGl2Harness, measureSteadyFrame, type WebGl2Harness } from './harness'; +import { createWebGl2Harness, measureCrossCallFrame, measureSteadyFrame, type WebGl2Harness } from './harness'; const withHarness = (fn: (harness: WebGl2Harness) => void): void => { const harness = createWebGl2Harness(); @@ -138,6 +138,25 @@ describe('structural — Sprite', () => { }); }); + it('1000 per-call renders / 1 texture → one draw (cross-call batching)', () => { + withHarness(harness => { + const [texture] = makeTextures(1); + const sprites = Array.from({ length: 1000 }, (_, i) => { + const sprite = new Sprite(texture); + sprite.setPosition(i % 100, Math.floor(i / 100)); + return sprite; + }); + + const m = measureCrossCallFrame(harness, sprites, 2); + + expect(m.drawCalls).toBe(1); + expect(m.instances).toBe(1000); + expect(m.visibleNodes).toBe(1000); + + for (const sprite of sprites) sprite.destroy(); + }); + }); + it('static transforms skip re-upload; moving transforms re-upload all rows', () => { withHarness(harness => { const staticScene = buildSpriteScene({ count: 500, textures: makeTextures(1) }); @@ -164,4 +183,26 @@ describe('structural — Sprite', () => { root.destroy(); }); }); + + it('per-call renders match a Container render (same draws, instances, transform rows)', () => { + withHarness(harness => { + const [texture] = makeTextures(1); + + const loose = Array.from({ length: 500 }, (_, i) => { + const sprite = new Sprite(texture); + sprite.setPosition((i * 7) % 640, (i * 13) % 480); + return sprite; + }); + const crossCall = measureCrossCallFrame(harness, loose, 2); + for (const sprite of loose) sprite.destroy(); + + const { root } = buildSpriteScene({ count: 500, textures: makeTextures(1) }); + const container = measureSteadyFrame(harness, root, 2); + root.destroy(); + + expect(crossCall.drawCalls).toBe(container.drawCalls); + expect(crossCall.instances).toBe(container.instances); + expect(crossCall.transformRows).toBe(container.transformRows); + }); + }); }); diff --git a/test/release/manifest.test.ts b/test/release/manifest.test.ts index 4ca18e3b..8ccecd32 100644 --- a/test/release/manifest.test.ts +++ b/test/release/manifest.test.ts @@ -75,7 +75,7 @@ describe('renderChecksums', () => { fullZip: { file: 'exojs-v0.13.0-full.zip', sha256: 'zzz', bytes: 1 }, }); const lines = body.trimEnd().split('\n'); - expect(lines).toHaveLength(6); + expect(lines).toHaveLength(PUBLISH_ORDER.length); expect(body).not.toContain('full.zip'); for (const line of lines) expect(line).toMatch(/^[a-f0-9]{64} {2}codexo-/); }); diff --git a/test/release/publish.test.ts b/test/release/publish.test.ts index 4aabd0c2..5a3d41a5 100644 --- a/test/release/publish.test.ts +++ b/test/release/publish.test.ts @@ -51,7 +51,7 @@ const publishedPackages = (invocations: CommandInvocation[]): string[] => publis const liveOptions = (): PublishOptions => ({ ...defaultPublishOptions('0.13.0'), dryRun: false, checkExisting: true }); describe('publishRelease — dry-run', () => { - it('publishes all six to latest in Core→Particles→Tilemap→Tiled→Physics→AudioFx order, every call carries --dry-run', () => { + it('publishes every package to latest in PUBLISH_ORDER, every call carries --dry-run', () => { const runner = createRecordingRunner(inv => (inv.args[0] === 'view' ? fail('E404') : ok())); const report = publishRelease(manifest, defaultPublishOptions('0.13.0'), runner, resolveArtifact); @@ -59,16 +59,9 @@ describe('publishRelease — dry-run', () => { expect(report.dryRun).toBe(true); const published = publishCalls(runner.invocations); - expect(published).toHaveLength(6); - // order by tarball file name reflects Core→Particles→Tilemap→Tiled→Physics→AudioFx - expect(published.map(i => i.args[1])).toEqual([ - resolveArtifact(tarballName('@codexo/exojs')), - resolveArtifact(tarballName('@codexo/exojs-particles')), - resolveArtifact(tarballName('@codexo/exojs-tilemap')), - resolveArtifact(tarballName('@codexo/exojs-tiled')), - resolveArtifact(tarballName('@codexo/exojs-physics')), - resolveArtifact(tarballName('@codexo/exojs-audio-fx')), - ]); + expect(published).toHaveLength(PUBLISH_ORDER.length); + // Tarball order reflects the canonical PUBLISH_ORDER (Core first). + expect(published.map(i => i.args[1])).toEqual(PUBLISH_ORDER.map(name => resolveArtifact(tarballName(name)))); for (const call of published) { expect(call.args).toContain('--dry-run'); expect(call.args).toContain('--tag'); @@ -80,12 +73,12 @@ describe('publishRelease — dry-run', () => { }); describe('publishRelease — live happy path', () => { - it('publishes all six directly to latest', () => { + it('publishes every package directly to latest', () => { const runner = createRecordingRunner(inv => (inv.args[0] === 'view' ? fail('E404') : ok())); const report = publishRelease(manifest, liveOptions(), runner, resolveArtifact); expect(report.ok).toBe(true); - expect(report.packages.map(p => p.publish)).toEqual(['published', 'published', 'published', 'published', 'published', 'published']); + expect(report.packages.map(p => p.publish)).toEqual(PUBLISH_ORDER.map(() => 'published')); // No dist-tag promotion step — direct publish replaces the former staging→promote flow. expect(distTagCalls(runner.invocations)).toHaveLength(0); @@ -105,19 +98,9 @@ describe('publishRelease — idempotent resume', () => { expect(report.ok).toBe(true); expect(report.packages[0].publish).toBe('already-published'); - expect(report.packages[1].publish).toBe('published'); - expect(report.packages[2].publish).toBe('published'); - expect(report.packages[3].publish).toBe('published'); - expect(report.packages[4].publish).toBe('published'); - expect(report.packages[5].publish).toBe('published'); - // Core was NOT re-published. - expect(publishedPackages(runner.invocations)).toEqual([ - resolveArtifact(tarballName('@codexo/exojs-particles')), - resolveArtifact(tarballName('@codexo/exojs-tilemap')), - resolveArtifact(tarballName('@codexo/exojs-tiled')), - resolveArtifact(tarballName('@codexo/exojs-physics')), - resolveArtifact(tarballName('@codexo/exojs-audio-fx')), - ]); + expect(report.packages.slice(1).every(p => p.publish === 'published')).toBe(true); + // Core was NOT re-published; every other package was, in order. + expect(publishedPackages(runner.invocations)).toEqual(PUBLISH_ORDER.slice(1).map(name => resolveArtifact(tarballName(name)))); }); it('a fully-published release re-run publishes nothing', () => { @@ -171,7 +154,9 @@ describe('publishRelease — partial failure stops the chain', () => { const report = publishRelease(manifest, liveOptions(), runner, resolveArtifact); expect(report.ok).toBe(false); - expect(report.packages.map(p => p.publish)).toEqual(['published', 'published', 'published', 'failed', 'not-attempted', 'not-attempted']); + // Tiled is index 3 (Core, Particles, Tilemap published; Tiled fails; rest never attempted). + const expected = PUBLISH_ORDER.map((_, i) => (i < 3 ? 'published' : i === 3 ? 'failed' : 'not-attempted')); + expect(report.packages.map(p => p.publish)).toEqual(expected); expect(distTagCalls(runner.invocations)).toHaveLength(0); }); }); diff --git a/test/rendering/browser/_blendReference.ts b/test/rendering/browser/_blendReference.ts new file mode 100644 index 00000000..3719735a --- /dev/null +++ b/test/rendering/browser/_blendReference.ts @@ -0,0 +1,147 @@ +/** + * CPU reference implementation of the W3C Compositing & Blending Level 1 blend + * functions, in straight (un-premultiplied) color space [0, 1]. Mirrors the + * GLSL/WGSL backdrop-blend shaders and serves as the pixel-value oracle for the + * `webgl2-backdrop-blend` / `webgpu-backdrop-blend` suite tests — written + * independently of the shaders so an agreement is a real cross-check, not a + * tautology. + */ + +import { BlendModes } from '#rendering/types'; + +export type Rgb = readonly [number, number, number]; + +// Every advanced (backdrop-aware) blend mode, in enum order. The fixed-function +// modes (Normal/Additive/Subtract) are not part of the shader path. +export const ADVANCED_BLEND_MODES: readonly BlendModes[] = [ + BlendModes.Multiply, + BlendModes.Screen, + BlendModes.Darken, + BlendModes.Lighten, + BlendModes.Overlay, + BlendModes.ColorDodge, + BlendModes.ColorBurn, + BlendModes.HardLight, + BlendModes.SoftLight, + BlendModes.Difference, + BlendModes.Exclusion, + BlendModes.Hue, + BlendModes.Saturation, + BlendModes.Color, + BlendModes.Luminosity, +]; + +const blendChannel = (mode: BlendModes, cb: number, cs: number): number => { + switch (mode) { + case BlendModes.Multiply: + return cb * cs; + case BlendModes.Screen: + return cb + cs - cb * cs; + case BlendModes.Darken: + return Math.min(cb, cs); + case BlendModes.Lighten: + return Math.max(cb, cs); + case BlendModes.Overlay: + return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case BlendModes.HardLight: + return cs <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case BlendModes.ColorDodge: + if (cb <= 0) { + return 0; + } + return cs >= 1 ? 1 : Math.min(1, cb / (1 - cs)); + case BlendModes.ColorBurn: + if (cb >= 1) { + return 1; + } + return cs <= 0 ? 0 : 1 - Math.min(1, (1 - cb) / cs); + case BlendModes.SoftLight: { + if (cs <= 0.5) { + return cb - (1 - 2 * cs) * cb * (1 - cb); + } + const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + case BlendModes.Difference: + return Math.abs(cb - cs); + case BlendModes.Exclusion: + return cb + cs - 2 * cb * cs; + default: + return Math.min(cb, cs); + } +}; + +const lum = (c: Rgb): number => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; + +const clipColor = (c: Rgb): Rgb => { + const l = lum(c); + const n = Math.min(c[0], c[1], c[2]); + const x = Math.max(c[0], c[1], c[2]); + let out: Rgb = c; + + if (n < 0) { + out = [l + ((out[0] - l) * l) / (l - n), l + ((out[1] - l) * l) / (l - n), l + ((out[2] - l) * l) / (l - n)]; + } + + if (x > 1) { + out = [l + ((out[0] - l) * (1 - l)) / (x - l), l + ((out[1] - l) * (1 - l)) / (x - l), l + ((out[2] - l) * (1 - l)) / (x - l)]; + } + + return out; +}; + +const setLum = (c: Rgb, l: number): Rgb => { + const d = l - lum(c); + + return clipColor([c[0] + d, c[1] + d, c[2] + d]); +}; + +const sat = (c: Rgb): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]); + +const setSat = (c: Rgb, s: number): Rgb => { + const mn = Math.min(c[0], c[1], c[2]); + const mx = Math.max(c[0], c[1], c[2]); + + if (mx <= mn) { + return [0, 0, 0]; + } + + const scale = s / (mx - mn); + + return [(c[0] - mn) * scale, (c[1] - mn) * scale, (c[2] - mn) * scale]; +}; + +const blendNonSeparable = (mode: BlendModes, cb: Rgb, cs: Rgb): Rgb => { + switch (mode) { + case BlendModes.Hue: + return setLum(setSat(cs, sat(cb)), lum(cb)); + case BlendModes.Saturation: + return setLum(setSat(cb, sat(cs)), lum(cb)); + case BlendModes.Color: + return setLum(cs, lum(cb)); + default: + return setLum(cb, lum(cs)); // Luminosity + } +}; + +/** W3C blend function B(Cb, Cs) for `mode`, straight color in [0, 1]. */ +export const w3cBlend = (mode: BlendModes, cb: Rgb, cs: Rgb): Rgb => { + if (mode >= BlendModes.Hue) { + return blendNonSeparable(mode, cb, cs); + } + + return [blendChannel(mode, cb[0], cs[0]), blendChannel(mode, cb[1], cs[1]), blendChannel(mode, cb[2], cs[2])]; +}; + +/** + * Expected 0..255 RGB for an OPAQUE source blended over an OPAQUE backdrop + * through the compositor: with αs = αb = 1 the source-over recombination reduces + * to the raw blend result B(Cb, Cs). Inputs are 0..255 bytes. + */ +export const expectedOpaqueBlend = (mode: BlendModes, backdrop: Rgb, source: Rgb): [number, number, number] => { + const cb: Rgb = [backdrop[0] / 255, backdrop[1] / 255, backdrop[2] / 255]; + const cs: Rgb = [source[0] / 255, source[1] / 255, source[2] / 255]; + const blended = w3cBlend(mode, cb, cs); + + return [Math.round(blended[0] * 255), Math.round(blended[1] * 255), Math.round(blended[2] * 255)]; +}; diff --git a/test/rendering/browser/webgl2-backdrop-blend.test.ts b/test/rendering/browser/webgl2-backdrop-blend.test.ts new file mode 100644 index 00000000..65bd8ccf --- /dev/null +++ b/test/rendering/browser/webgl2-backdrop-blend.test.ts @@ -0,0 +1,312 @@ +/** + * WebGL2 backdrop-aware blend SPIKE — proves the advanced-blend primitive + * (`WebGl2BackdropBlendCompositor`) end-to-end in isolation, before any + * render-plan integration. Mode = Darken (the motivating bug). + * + * Verifies the two things the spike exists to de-risk: + * 1. Backdrop capture + composite math: a transparent source region shows the + * backdrop through (NOT black — the old fixed-function Darken bug), and a + * covered region equals min(backdrop, source). + * 2. Spatial / V-flip correctness: the captured backdrop is composited at the + * right place (a vertically-split backdrop under an opaque white source + * comes back unflipped). + * + * Run via: pnpm test:browser:webgl + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Texture } from '#rendering/texture/Texture'; +import { BlendModes } from '#rendering/types'; +import { WebGl2BackdropBlendCompositor } from '#rendering/webgl2/WebGl2BackdropBlendCompositor'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { ADVANCED_BLEND_MODES, expectedOpaqueBlend } from './_blendReference'; + +type RgbaTuple = [number, number, number, number]; + +const shaderSources = vi.hoisted(() => ({ + vert: `#version 300 es +precision mediump float; +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texcoord; +uniform mat3 u_projection; +out vec2 v_texcoord; +void main(void) { + gl_Position = vec4((u_projection * vec3(a_position, 1.0)).xy, 0.0, 1.0); + v_texcoord = a_texcoord; +}`, + // MUST stay in sync with src/rendering/webgl2/glsl/backdrop-blend.frag — the + // browser project stubs .frag imports to "" (shaderStubPlugin), so this mock + // supplies the real GLSL. The full-suite test below exercises every branch. + frag: `#version 300 es +precision highp float; +uniform sampler2D u_source; +uniform sampler2D u_backdrop; +uniform int u_mode; +uniform float u_opaqueBackdrop; +in vec2 v_texcoord; +layout(location = 0) out vec4 fragColor; +const int MODE_MULTIPLY = 3; +const int MODE_SCREEN = 4; +const int MODE_DARKEN = 5; +const int MODE_LIGHTEN = 6; +const int MODE_OVERLAY = 7; +const int MODE_COLOR_DODGE = 8; +const int MODE_COLOR_BURN = 9; +const int MODE_HARD_LIGHT = 10; +const int MODE_SOFT_LIGHT = 11; +const int MODE_DIFFERENCE = 12; +const int MODE_EXCLUSION = 13; +const int MODE_HUE = 14; +const int MODE_SATURATION = 15; +const int MODE_COLOR = 16; +vec3 unpremultiply(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : vec3(0.0); } +float blendChannel(int mode, float cb, float cs) { + if (mode == MODE_MULTIPLY) { return cb * cs; } + if (mode == MODE_SCREEN) { return cb + cs - cb * cs; } + if (mode == MODE_DARKEN) { return min(cb, cs); } + if (mode == MODE_LIGHTEN) { return max(cb, cs); } + if (mode == MODE_OVERLAY) { return cb <= 0.5 ? (2.0 * cb * cs) : (1.0 - 2.0 * (1.0 - cb) * (1.0 - cs)); } + if (mode == MODE_HARD_LIGHT) { return cs <= 0.5 ? (2.0 * cb * cs) : (1.0 - 2.0 * (1.0 - cb) * (1.0 - cs)); } + if (mode == MODE_COLOR_DODGE) { + if (cb <= 0.0) { return 0.0; } + return cs >= 1.0 ? 1.0 : min(1.0, cb / (1.0 - cs)); + } + if (mode == MODE_COLOR_BURN) { + if (cb >= 1.0) { return 1.0; } + return cs <= 0.0 ? 0.0 : 1.0 - min(1.0, (1.0 - cb) / cs); + } + if (mode == MODE_SOFT_LIGHT) { + if (cs <= 0.5) { return cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb); } + float d = cb <= 0.25 ? (((16.0 * cb - 12.0) * cb + 4.0) * cb) : sqrt(cb); + return cb + (2.0 * cs - 1.0) * (d - cb); + } + if (mode == MODE_DIFFERENCE) { return abs(cb - cs); } + if (mode == MODE_EXCLUSION) { return cb + cs - 2.0 * cb * cs; } + return min(cb, cs); +} +vec3 blendSeparable(int mode, vec3 cb, vec3 cs) { + return vec3(blendChannel(mode, cb.r, cs.r), blendChannel(mode, cb.g, cs.g), blendChannel(mode, cb.b, cs.b)); +} +float lum(vec3 c) { return dot(c, vec3(0.3, 0.59, 0.11)); } +vec3 clipColor(vec3 c) { + float l = lum(c); + float n = min(min(c.r, c.g), c.b); + float x = max(max(c.r, c.g), c.b); + if (n < 0.0) { c = l + ((c - l) * l) / (l - n); } + if (x > 1.0) { c = l + ((c - l) * (1.0 - l)) / (x - l); } + return c; +} +vec3 setLum(vec3 c, float l) { return clipColor(c + (l - lum(c))); } +float sat(vec3 c) { return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b); } +vec3 setSat(vec3 c, float s) { + float mn = min(min(c.r, c.g), c.b); + float mx = max(max(c.r, c.g), c.b); + return mx > mn ? (c - mn) * (s / (mx - mn)) : vec3(0.0); +} +vec3 blendNonSeparable(int mode, vec3 cb, vec3 cs) { + if (mode == MODE_HUE) { return setLum(setSat(cs, sat(cb)), lum(cb)); } + if (mode == MODE_SATURATION) { return setLum(setSat(cb, sat(cs)), lum(cb)); } + if (mode == MODE_COLOR) { return setLum(cs, lum(cb)); } + return setLum(cb, lum(cs)); +} +vec3 blendAdvanced(int mode, vec3 cb, vec3 cs) { + return mode >= MODE_HUE ? blendNonSeparable(mode, cb, cs) : blendSeparable(mode, cb, cs); +} +void main(void) { + vec4 src = texture(u_source, v_texcoord); + vec4 dst = texture(u_backdrop, vec2(v_texcoord.x, 1.0 - v_texcoord.y)); + float as = src.a; + float ab = max(dst.a, u_opaqueBackdrop); + vec3 cs = unpremultiply(src); + vec3 cb = unpremultiply(dst); + vec3 blended = blendAdvanced(u_mode, cb, cs); + vec3 mixedSource = mix(cs, blended, ab); + fragColor = vec4(mixedSource * as, as); +}`, +})); + +vi.mock('#rendering/webgl2/glsl/backdrop-blend.vert', () => ({ default: shaderSources.vert })); +vi.mock('#rendering/webgl2/glsl/backdrop-blend.frag', () => ({ default: shaderSources.frag })); + +const canvasSize = 64; + +const defaultWebGlAttributes: WebGLContextAttributes = { + alpha: false, // engine default: opaque root canvas (exercises the opaque-backdrop path) + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, +}; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize, pixelRatio: 1 }, + rendering: { debug: false, webglAttributes: defaultWebGlAttributes }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + + return backend; +}; + +/** Composite a full-canvas source over the backend's current target. */ +const composeBackdropBlend = (backend: WebGl2Backend, source: Texture, mode: BlendModes): void => { + const compositor = new WebGl2BackdropBlendCompositor(); + + compositor.connect(backend); + + try { + compositor.compose(backend, source, 0, 0, canvasSize, canvasSize, mode); + } finally { + compositor.disconnect(); + } +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const pixel = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), gl.drawingBufferHeight - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + + return [pixel[0], pixel[1], pixel[2], pixel[3]]; +}; + +const expectRgbNear = (actual: RgbaTuple, expected: [number, number, number], tolerance = 4): void => { + for (let index = 0; index < 3; index++) { + expect(Math.abs(actual[index] - expected[index]), `channel ${index}: got [${actual.join(', ')}] expected rgb [${expected.join(', ')}]`).toBeLessThanOrEqual( + tolerance, + ); + } +}; + +/** Left half opaque `color`, right half fully transparent. */ +const createLeftOpaqueTexture = (color: string): Texture => { + const source = document.createElement('canvas'); + + source.width = canvasSize; + source.height = canvasSize; + + const ctx = source.getContext('2d'); + + if (!ctx) { + throw new Error('2D context required.'); + } + + ctx.clearRect(0, 0, canvasSize, canvasSize); + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvasSize / 2, canvasSize); + + return new Texture(source); +}; + +const createSolidTexture = (color: string): Texture => { + const source = document.createElement('canvas'); + + source.width = canvasSize; + source.height = canvasSize; + + const ctx = source.getContext('2d'); + + if (!ctx) { + throw new Error('2D context required.'); + } + + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvasSize, canvasSize); + + return new Texture(source); +}; + +describe('WebGL2 backdrop-aware blend (Darken spike)', () => { + test('transparent source region shows the backdrop; covered region is min(backdrop, source)', async () => { + const backend = await createBackend(); + // Source: opaque red on the left, transparent on the right. + const source = createLeftOpaqueTexture('#ff0000'); + + try { + backend.clear(new Color(60, 120, 200)); // backdrop + composeBackdropBlend(backend, source, BlendModes.Darken); + + // Left (red over blue, Darken): min((60,120,200),(255,0,0)) = (60,0,0). + expectRgbNear(readPixel(backend, 16, 32), [60, 0, 0]); + // Right (transparent): the backdrop shows through — NOT black. + expectRgbNear(readPixel(backend, 48, 32), [60, 120, 200]); + } finally { + source.destroy(); + backend.destroy(); + } + }); + + test('backdrop is captured and composited unflipped (vertical split survives)', async () => { + const backend = await createBackend(); + const white = createSolidTexture('#ffffff'); + const gl = backend.context; + + try { + // Backdrop: red top half, blue bottom half (scissor in bottom-left origin). + backend.clear(new Color(200, 40, 40)); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(0, 0, canvasSize, canvasSize / 2); // bottom half + gl.clearColor(40 / 255, 40 / 255, 200 / 255, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.SCISSOR_TEST); + + // Opaque white under Darken = min(white, backdrop) = backdrop. The result + // must match the backdrop spatially (top red, bottom blue) — a V-flip bug + // would swap them. + composeBackdropBlend(backend, white, BlendModes.Darken); + + expectRgbNear(readPixel(backend, 32, 8), [200, 40, 40]); // top + expectRgbNear(readPixel(backend, 32, 56), [40, 40, 200]); // bottom + } finally { + white.destroy(); + backend.destroy(); + } + }); + + test('every advanced blend mode matches the W3C reference (opaque over opaque)', async () => { + const backend = await createBackend(); + const backdropColor: [number, number, number] = [180, 110, 60]; + const sourceColor: [number, number, number] = [90, 200, 150]; + + // Oracle self-check with hand-computed values (independent of the shader), so + // a shared formula error cannot make GPU and reference agree on a wrong number. + expect(expectedOpaqueBlend(BlendModes.Multiply, backdropColor, sourceColor)).toEqual([64, 86, 35]); + expect(expectedOpaqueBlend(BlendModes.Difference, backdropColor, sourceColor)).toEqual([90, 90, 90]); + expect(expectedOpaqueBlend(BlendModes.Luminosity, backdropColor, sourceColor)).toEqual([216, 146, 96]); + + const source = createSolidTexture(`rgb(${sourceColor[0]}, ${sourceColor[1]}, ${sourceColor[2]})`); + const compositor = new WebGl2BackdropBlendCompositor(); + + compositor.connect(backend); + + try { + for (const mode of ADVANCED_BLEND_MODES) { + // Re-establish the opaque backdrop each iteration (the previous compose + // overwrote it) and blend the opaque source over it. + backend.clear(new Color(backdropColor[0], backdropColor[1], backdropColor[2])); + compositor.compose(backend, source, 0, 0, canvasSize, canvasSize, mode); + + expectRgbNear(readPixel(backend, 32, 32), expectedOpaqueBlend(mode, backdropColor, sourceColor), 5); + } + } finally { + compositor.disconnect(); + source.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgpu-backdrop-blend.test.ts b/test/rendering/browser/webgpu-backdrop-blend.test.ts new file mode 100644 index 00000000..4214cfc3 --- /dev/null +++ b/test/rendering/browser/webgpu-backdrop-blend.test.ts @@ -0,0 +1,284 @@ +/** + * WebGPU backdrop-aware blend SPIKE — proves the advanced-blend primitive + * (`WebGpuBackdropBlendCompositor`) end-to-end in isolation, before any + * render-plan integration. Mode = Darken (the motivating bug); mirrors the + * WebGL2 spike (`webgl2-backdrop-blend`). + * + * Verifies the two things the spike exists to de-risk on WebGPU: + * 1. Backdrop capture (copyTextureToTexture) + composite math: a transparent + * source region shows the backdrop through (NOT black — the old + * fixed-function Darken bug), and a covered region equals min(backdrop, + * source). + * 2. Spatial / V-flip correctness: the captured backdrop is composited at the + * right place (a vertically-split backdrop under an opaque white source comes + * back unflipped — copyTextureToTexture preserves top-left order, unlike the + * WebGL2 framebuffer blit). + * + * All tests skip gracefully when WebGPU is unavailable or the software adapter + * drops the device mid-test. + * + * Run via: pnpm test:browser:webgpu + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Mesh } from '#rendering/mesh/Mesh'; +import { DataTexture } from '#rendering/texture/DataTexture'; +import { BlendModes, ScaleModes } from '#rendering/types'; +import { WebGpuBackdropBlendCompositor } from '#rendering/webgpu/WebGpuBackdropBlendCompositor'; +import { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { ADVANCED_BLEND_MODES, expectedOpaqueBlend } from './_blendReference'; +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDeviceOrSkip } from './webgpu-test-helpers'; + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const solidDataTexture = (r: number, g: number, b: number): DataTexture => + new DataTexture({ + width: 1, + height: 1, + format: 'rgba8', + data: new Uint8Array([r, g, b, 255]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const setupBackend = async (ctx: { skip: (reason: string) => void }): Promise => { + if (!navigator.gpu) { + ctx.skip('WebGPU unavailable: navigator.gpu is absent'); + } + + const adapter = await navigator.gpu.requestAdapter(); + + if (!adapter) { + ctx.skip('WebGPU unavailable: requestAdapter() returned null'); + } + + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + + return backend; +}; + +// On the software (swiftshader) adapter the WebGPU device can drop mid-test; +// treat that as an unavailable-adapter skip rather than a failure. +const isDeviceLoss = (error: unknown): boolean => error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError'); + +// A full-canvas quad in pixel space with UVs spanning the whole texture. +const fullQuadVertices = (): Float32Array => new Float32Array([0, 0, canvasSize, 0, canvasSize, canvasSize, 0, 0, canvasSize, canvasSize, 0, canvasSize]); +const fullQuadUvs = (): Float32Array => new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]); + +// Read the presented WebGPU canvas back through a 2D canvas (drawImage accepts a +// WebGPU-configured canvas as an image source), giving CPU-side pixel access. +const readCanvas = (backend: WebGpuBackend): ((x: number, y: number) => RgbaTuple) => { + const source = backend.context.canvas as HTMLCanvasElement; + const readback = document.createElement('canvas'); + + readback.width = canvasSize; + readback.height = canvasSize; + + const ctx = readback.getContext('2d'); + + if (!ctx) { + throw new Error('2D context is required for canvas readback.'); + } + + ctx.drawImage(source, 0, 0); + + return (x: number, y: number): RgbaTuple => { + const { data } = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1); + + return [data[0], data[1], data[2], data[3]]; + }; +}; + +const expectRgbNear = (actual: RgbaTuple, expected: readonly [number, number, number], tolerance = 4): void => { + for (let index = 0; index < 3; index++) { + expect(Math.abs(actual[index] - expected[index]), `channel ${index}: got [${actual.join(', ')}] expected rgb [${expected.join(', ')}]`).toBeLessThanOrEqual( + tolerance, + ); + } +}; + +// Paint `texture` across the whole canvas through the real mesh path, so the +// captured backdrop reflects rendered (premultiplied) content. +const drawBackdrop = (backend: WebGpuBackend, texture: DataTexture): void => { + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + backend.resetStats(); + backend.clear(Color.black); + mesh.render(backend); + backend.flush(); + } finally { + mesh.destroy(); + } +}; + +const composeBackdropBlend = (backend: WebGpuBackend, source: DataTexture, mode: BlendModes): void => { + const compositor = new WebGpuBackdropBlendCompositor(); + + compositor.connect(backend.device); + + try { + compositor.compose(backend, source, 0, 0, canvasSize, canvasSize, mode); + } finally { + compositor.disconnect(); + } +}; + +describe('WebGPU backdrop-aware blend (Darken spike)', () => { + test('transparent source region shows the backdrop; covered region is min(backdrop, source)', async ctx => { + const backend = await setupBackend(ctx); + + if (!getBackendDeviceOrSkip(ctx, backend)) { + return; + } + + // Source: opaque red (texel 0, left half) then transparent (texel 1, right). + const source = new DataTexture({ + width: 2, + height: 1, + format: 'rgba8', + data: new Uint8Array([255, 0, 0, 255, 0, 0, 0, 0]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + + try { + backend.clear(new Color(60, 120, 200)); // backdrop (deferred; compose flushes it) + composeBackdropBlend(backend, source, BlendModes.Darken); + + const readPixel = readCanvas(backend); + + // Left (red over blue, Darken): min((60,120,200),(255,0,0)) = (60,0,0). + expectRgbNear(readPixel(16, 32), [60, 0, 0]); + // Right (transparent): the backdrop shows through — NOT black. + expectRgbNear(readPixel(48, 32), [60, 120, 200]); + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return; + } + + throw error; + } finally { + source.destroy(); + backend.destroy(); + } + }); + + test('backdrop is captured and composited unflipped (vertical split survives)', async ctx => { + const backend = await setupBackend(ctx); + + if (!getBackendDeviceOrSkip(ctx, backend)) { + return; + } + + // Backdrop: red top row, blue bottom row (top-left origin → top of canvas). + const backdrop = new DataTexture({ + width: 1, + height: 2, + format: 'rgba8', + data: new Uint8Array([200, 40, 40, 255, 40, 40, 200, 255]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + // Opaque white under Darken = min(white, backdrop) = backdrop, so the result + // must match the backdrop spatially (top red, bottom blue) — a V-flip or a + // channel-swap bug would change these. + const white = new DataTexture({ + width: 1, + height: 1, + format: 'rgba8', + data: new Uint8Array([255, 255, 255, 255]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + + try { + drawBackdrop(backend, backdrop); + composeBackdropBlend(backend, white, BlendModes.Darken); + + const readPixel = readCanvas(backend); + + expectRgbNear(readPixel(32, 8), [200, 40, 40]); // top + expectRgbNear(readPixel(32, 56), [40, 40, 200]); // bottom + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return; + } + + throw error; + } finally { + white.destroy(); + backdrop.destroy(); + backend.destroy(); + } + }); + + test('every advanced blend mode matches the W3C reference (opaque over opaque)', async ctx => { + const backend = await setupBackend(ctx); + + if (!getBackendDeviceOrSkip(ctx, backend)) { + return; + } + + const backdropColor: [number, number, number] = [180, 110, 60]; + const sourceColor: [number, number, number] = [90, 200, 150]; + + // Oracle self-check with hand-computed values (independent of the shader), so + // a shared formula error cannot make GPU and reference agree on a wrong number. + expect(expectedOpaqueBlend(BlendModes.Multiply, backdropColor, sourceColor)).toEqual([64, 86, 35]); + expect(expectedOpaqueBlend(BlendModes.Difference, backdropColor, sourceColor)).toEqual([90, 90, 90]); + expect(expectedOpaqueBlend(BlendModes.Luminosity, backdropColor, sourceColor)).toEqual([216, 146, 96]); + + const backdrop = solidDataTexture(...backdropColor); + const source = solidDataTexture(...sourceColor); + const compositor = new WebGpuBackdropBlendCompositor(); + + compositor.connect(backend.device); + + try { + for (const mode of ADVANCED_BLEND_MODES) { + // Re-establish the opaque backdrop each iteration (the previous compose + // overwrote the canvas) and blend the opaque source over it. + drawBackdrop(backend, backdrop); + compositor.compose(backend, source, 0, 0, canvasSize, canvasSize, mode); + + expectRgbNear(readCanvas(backend)(32, 32), expectedOpaqueBlend(mode, backdropColor, sourceColor), 5); + } + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return; + } + + throw error; + } finally { + compositor.disconnect(); + source.destroy(); + backdrop.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/transform-buffer.test.ts b/test/rendering/transform-buffer.test.ts index 5d5d5d30..ed74a847 100644 --- a/test/rendering/transform-buffer.test.ts +++ b/test/rendering/transform-buffer.test.ts @@ -168,4 +168,91 @@ describe('TransformBuffer', () => { parent.destroy(); }); + + test('consumeDirtyRange returns empty sentinel on a fresh buffer after begin()', () => { + const buffer = new TransformBuffer(); + + buffer.begin(); + const result = buffer.consumeDirtyRange(10); + + expect(result.rowCount).toBe(0); + expect(result.firstRow).toBe(0); + }); + + test('consumeDirtyRange covers all written slots and clears itself on second call', () => { + const buffer = new TransformBuffer(); + const identity = new Matrix(); + + buffer.begin(); + buffer.write(0, identity, Color.white); + buffer.write(1, identity, Color.white); + buffer.write(2, identity, Color.white); + + const first = buffer.consumeDirtyRange(3); + + expect(first).toEqual({ firstRow: 0, rowCount: 3 }); + + const second = buffer.consumeDirtyRange(3); + + expect(second.rowCount).toBe(0); + }); + + test('consumeDirtyRange tracks reuse below the high-water mark', () => { + const buffer = new TransformBuffer(); + const identity = new Matrix(); + + buffer.begin(); + buffer.write(0, identity, Color.white); + buffer.write(1, identity, Color.white); + buffer.write(2, identity, Color.white); + buffer.consumeDirtyRange(3); // clear after first writes + + buffer.write(1, identity, Color.white); // reuse slot 1 below high-water mark + + const result = buffer.consumeDirtyRange(3); + + expect(result).toEqual({ firstRow: 1, rowCount: 1 }); + }); + + test('consumeDirtyRange clamps to maxCount — a write above the limit is excluded', () => { + const buffer = new TransformBuffer(); + const identity = new Matrix(); + + buffer.begin(); + buffer.write(5, identity, Color.white); // slot 5 is above maxCount = 3 + + const result = buffer.consumeDirtyRange(3); + + expect(result.rowCount).toBe(0); + }); + + test('rewindTo restores the write cursor and optionally the frame hash', () => { + const buffer = new TransformBuffer(); + const identity = new Matrix(); + + buffer.begin(); + buffer.write(0, identity, Color.white); + const savedHash = buffer.frameHash; + + buffer.write(1, identity, Color.white); + buffer.rewindTo(1, savedHash); + + expect(buffer.count).toBe(1); + expect(buffer.frameHash).toBe(savedHash); + }); + + test('begin() resets the dirty range so consumeDirtyRange returns empty', () => { + const buffer = new TransformBuffer(); + const identity = new Matrix(); + + buffer.begin(); + buffer.write(0, identity, Color.white); + buffer.write(1, identity, Color.white); + + buffer.begin(); // should reset dirty range + + const result = buffer.consumeDirtyRange(10); + + expect(result.rowCount).toBe(0); + }); }); diff --git a/test/rendering/webgpu-backend.test.ts b/test/rendering/webgpu-backend.test.ts index d0e7c284..0e057969 100644 --- a/test/rendering/webgpu-backend.test.ts +++ b/test/rendering/webgpu-backend.test.ts @@ -1790,10 +1790,15 @@ describe('WebGpuBackend', () => { manager.flush(); manager.destroy(); - // The sprite's world transform now lives in the shared transform storage - // buffer (uploaded as the last writeBuffer of the sprite flush), not inline - // in the instance buffer. Slot 0 = (a, b, c, d, tx, ty, 0, 0, tint…); an + // The sprite's world transform lives in the shared transform storage buffer + // (the last writeBuffer of the sprite flush carries the whole buffer's + // ArrayBuffer), not inline in the instance buffer. The buffer is frame-scoped + // (cross-call batching): the graphics rendered into the RenderTexture is the + // first shared-buffer write (slot 0), so the sprite is the second and lands + // in slot 1. Each slot is 12 floats (a, b, c, d, tx, ty, 0, 0, tint…); an // unrotated sprite at (24, 18) has b == 0 and carries that translation. + const slotFloats = 12; + const spriteBase = 1 * slotFloats; // slot 1 const transformWrite = environment.queue.writeBuffer.mock.calls[environment.queue.writeBuffer.mock.calls.length - 1]; const data = new Float32Array(transformWrite[2] as ArrayBuffer); @@ -1801,9 +1806,9 @@ describe('WebGpuBackend', () => { expect(environment.pass.drawIndexed).toHaveBeenCalled(); expect(environment.queue.submit.mock.calls.length).toBeGreaterThanOrEqual(2); expect(environment.textures.length).toBeGreaterThan(0); - expect(data[1]).toBe(0); - expect(data[4]).toBe(24); - expect(data[5]).toBe(18); + expect(data[spriteBase + 1]).toBe(0); + expect(data[spriteBase + 4]).toBe(24); + expect(data[spriteBase + 5]).toBe(18); } finally { environment.restore(); } diff --git a/tsconfig.guides.json b/tsconfig.guides.json index 48b43552..76943c09 100644 --- a/tsconfig.guides.json +++ b/tsconfig.guides.json @@ -43,7 +43,12 @@ "@codexo/exojs-tiled": ["./packages/exojs-tiled/src/index.ts"], "@codexo/exojs-tiled/register": ["./packages/exojs-tiled/src/register.ts"], "@codexo/exojs-tilemap": ["./packages/exojs-tilemap/src/index.ts"], - "@codexo/exojs-tilemap/register": ["./packages/exojs-tilemap/src/register.ts"] + "@codexo/exojs-tilemap/register": ["./packages/exojs-tilemap/src/register.ts"], + "@codexo/exojs-physics": ["./packages/exojs-physics/src/index.ts"], + "@codexo/exojs-aseprite": ["./packages/exojs-aseprite/src/index.ts"], + "@codexo/exojs-aseprite/register": ["./packages/exojs-aseprite/src/register.ts"], + "@codexo/exojs-ldtk": ["./packages/exojs-ldtk/src/index.ts"], + "@codexo/exojs-ldtk/register": ["./packages/exojs-ldtk/src/register.ts"] } }, "include": [".workspace/generated/guide-typecheck/**/*.ts", ".workspace/generated/guide-typecheck/**/*.js", "src/typings.d.ts"] diff --git a/vitest.config.ts b/vitest.config.ts index c30398fc..37c36c61 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,8 @@ const aliasConfig = [ // recipe (examples/shared/physics-tilemap.ts) can be unit-tested in-repo. { find: '@codexo/exojs-tilemap', replacement: fileURLToPath(new URL('./packages/exojs-tilemap/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-tiled', replacement: fileURLToPath(new URL('./packages/exojs-tiled/src/index.ts', import.meta.url)) }, + { find: '@codexo/exojs-aseprite', replacement: fileURLToPath(new URL('./packages/exojs-aseprite/src/index.ts', import.meta.url)) }, + { find: '@codexo/exojs-ldtk', replacement: fileURLToPath(new URL('./packages/exojs-ldtk/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-physics', replacement: fileURLToPath(new URL('./packages/exojs-physics/src/index.ts', import.meta.url)) }, ] as const; @@ -100,6 +102,16 @@ export default defineConfig({ alias: aliasConfig, include: ['packages/exojs-tiled/test/**/*.test.ts'], }), + createJsdomTestProject({ + name: 'exojs-aseprite', + alias: aliasConfig, + include: ['packages/exojs-aseprite/test/**/*.test.ts'], + }), + createJsdomTestProject({ + name: 'exojs-ldtk', + alias: aliasConfig, + include: ['packages/exojs-ldtk/test/**/*.test.ts'], + }), createJsdomTestProject({ name: 'exojs-physics', alias: aliasConfig, @@ -111,6 +123,21 @@ export default defineConfig({ include: ['packages/exojs-audio-fx/test/**/*.test.ts'], }), + // ── exojs-react — jsdom + React Testing Library (esbuild JSX) ──────── + // The shared jsdom factory is reused unchanged; the only addition is the + // esbuild automatic JSX runtime so `.tsx` test files need no React import. + // It is set at the project level (like rendering-perf's `plugins`) so the + // other jsdom projects keep esbuild's defaults byte-for-byte. + { + ...createJsdomTestProject({ + name: 'exojs-react', + alias: aliasConfig, + include: ['packages/exojs-react/test/**/*.{test.ts,test.tsx}'], + setupFiles: ['./packages/exojs-react/test/setup.ts'], + }), + esbuild: { jsx: 'automatic', jsxImportSource: 'react' }, + }, + // ── rendering-perf — Node renderer benchmark harness (real shaders) ── // Runs the real WebGL2 renderers against a recording fake GL context for // deterministic, GPU-free structural metrics. Uses the real-shader loader