From be5d69747edd06c601f0b2ce79bc83258aad9927 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:14:50 +0200 Subject: [PATCH 01/68] feat(core): add cullArea property to SceneNode Allows specifying a custom Rectangle for viewport cull intersection instead of the computed node bounds. Useful for nodes with off-screen effects or custom cull regions. Serialized in commonFields. --- site/src/content/api/abstract-text.mdx | 3 ++- site/src/content/api/animated-sprite.mdx | 3 ++- site/src/content/api/bitmap-text.mdx | 3 ++- site/src/content/api/button.mdx | 3 ++- site/src/content/api/container.mdx | 3 ++- site/src/content/api/drawable.mdx | 3 ++- site/src/content/api/graphics.mdx | 3 ++- site/src/content/api/htmltext.mdx | 3 ++- site/src/content/api/label.mdx | 3 ++- site/src/content/api/mesh.mdx | 3 ++- site/src/content/api/nine-slice-sprite.mdx | 3 ++- site/src/content/api/panel.mdx | 3 ++- site/src/content/api/particle-system.mdx | 3 ++- site/src/content/api/progress-bar.mdx | 3 ++- site/src/content/api/render-node.mdx | 3 ++- site/src/content/api/repeating-sprite.mdx | 3 ++- site/src/content/api/scene-node.mdx | 3 ++- site/src/content/api/sprite.mdx | 3 ++- site/src/content/api/stack.mdx | 3 ++- site/src/content/api/text.mdx | 3 ++- site/src/content/api/tile-layer-node.mdx | 3 ++- site/src/content/api/tile-map-band.mdx | 3 ++- site/src/content/api/tile-map-node.mdx | 3 ++- site/src/content/api/uiroot.mdx | 3 ++- site/src/content/api/video.mdx | 3 ++- site/src/content/api/widget.mdx | 3 ++- src/core/SceneNode.ts | 22 +++++++++++++++++++++- src/core/serialization/commonFields.ts | 8 ++++++++ 28 files changed, 81 insertions(+), 27 deletions(-) diff --git a/site/src/content/api/abstract-text.mdx b/site/src/content/api/abstract-text.mdx index fe603c1f..ec9d8d59 100644 --- a/site/src/content/api/abstract-text.mdx +++ b/site/src/content/api/abstract-text.mdx @@ -5,7 +5,7 @@ symbol: "AbstractText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 84 +memberCount: 85 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/AbstractText.ts" @@ -82,6 +82,7 @@ Subclasses: - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `interactive: boolean` - `isAlignedBox: boolean` diff --git a/site/src/content/api/animated-sprite.mdx b/site/src/content/api/animated-sprite.mdx index e90528bd..a7a38c84 100644 --- a/site/src/content/api/animated-sprite.mdx +++ b/site/src/content/api/animated-sprite.mdx @@ -5,7 +5,7 @@ symbol: "AnimatedSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 104 +memberCount: 105 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/AnimatedSprite.ts" @@ -96,6 +96,7 @@ from a Spritesheet's named animations. - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `currentClip: string | null` - `currentFrame: number` - `filters: readonly Filter[]` diff --git a/site/src/content/api/bitmap-text.mdx b/site/src/content/api/bitmap-text.mdx index 7952de1f..e00f15c5 100644 --- a/site/src/content/api/bitmap-text.mdx +++ b/site/src/content/api/bitmap-text.mdx @@ -5,7 +5,7 @@ symbol: "BitmapText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 92 +memberCount: 93 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/BitmapText.ts" @@ -96,6 +96,7 @@ label.style.align = 'center'; // immediate rebuild - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `font: BmFont` - `fontScale: number` diff --git a/site/src/content/api/button.mdx b/site/src/content/api/button.mdx index 01b80197..c5132a76 100644 --- a/site/src/content/api/button.mdx +++ b/site/src/content/api/button.mdx @@ -5,7 +5,7 @@ symbol: "Button" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 105 +memberCount: 106 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/Button.ts" @@ -92,6 +92,7 @@ Listen to Button.onClick for activation. - `colors: Readonly>` - `cornerRadius: number` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `filters: readonly Filter[]` - `fontSize: number` diff --git a/site/src/content/api/container.mdx b/site/src/content/api/container.mdx index c327c9b8..d9c7808e 100644 --- a/site/src/content/api/container.mdx +++ b/site/src/content/api/container.mdx @@ -5,7 +5,7 @@ symbol: "Container" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 91 +memberCount: 92 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/Container.ts" @@ -97,6 +97,7 @@ etc. — the base `Container` is a non-drawing grouping node. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/drawable.mdx b/site/src/content/api/drawable.mdx index 731ddc68..3151514f 100644 --- a/site/src/content/api/drawable.mdx +++ b/site/src/content/api/drawable.mdx @@ -5,7 +5,7 @@ symbol: "Drawable" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 80 +memberCount: 81 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/Drawable.ts" @@ -79,6 +79,7 @@ and are paired with a matching Renderer via RendererRegistry. - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `interactive: boolean` - `isAlignedBox: boolean` diff --git a/site/src/content/api/graphics.mdx b/site/src/content/api/graphics.mdx index 8b7bb6c4..6a6c788e 100644 --- a/site/src/content/api/graphics.mdx +++ b/site/src/content/api/graphics.mdx @@ -5,7 +5,7 @@ symbol: "Graphics" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 112 +memberCount: 113 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/primitives/Graphics.ts" @@ -117,6 +117,7 @@ tint, and mask support from Container. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `currentPoint: Vector` - `fillColor: Color` - `fillStyle: Color | Gradient` diff --git a/site/src/content/api/htmltext.mdx b/site/src/content/api/htmltext.mdx index 91f82ae6..a83ca8f6 100644 --- a/site/src/content/api/htmltext.mdx +++ b/site/src/content/api/htmltext.mdx @@ -5,7 +5,7 @@ symbol: "HTMLText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 98 +memberCount: 99 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/HTMLText.ts" @@ -103,6 +103,7 @@ because it is embedded inside an XML document. - `children: RenderNode[]` - `css: string` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `html: string` diff --git a/site/src/content/api/label.mdx b/site/src/content/api/label.mdx index 828ba348..09e54f60 100644 --- a/site/src/content/api/label.mdx +++ b/site/src/content/api/label.mdx @@ -5,7 +5,7 @@ symbol: "Label" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 101 +memberCount: 102 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/Label.ts" @@ -89,6 +89,7 @@ size in sync with the measured text, so it anchors and stacks correctly. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `filters: readonly Filter[]` - `height: number` diff --git a/site/src/content/api/mesh.mdx b/site/src/content/api/mesh.mdx index 04834391..9a289b70 100644 --- a/site/src/content/api/mesh.mdx +++ b/site/src/content/api/mesh.mdx @@ -5,7 +5,7 @@ symbol: "Mesh" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 90 +memberCount: 91 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/mesh/Mesh.ts" @@ -102,6 +102,7 @@ after in-place mutation is the caller's responsibility (call - `cacheAsBitmap: boolean` - `colors: Uint32Array | null` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `geometry: Geometry | null` - `indexCount: number` diff --git a/site/src/content/api/nine-slice-sprite.mdx b/site/src/content/api/nine-slice-sprite.mdx index dd2337ed..935a00c8 100644 --- a/site/src/content/api/nine-slice-sprite.mdx +++ b/site/src/content/api/nine-slice-sprite.mdx @@ -5,7 +5,7 @@ symbol: "NineSliceSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 91 +memberCount: 92 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/NineSliceSprite.ts" @@ -81,6 +81,7 @@ Corners stay pixel-perfect; edges/center fill by stretch, repeat, or mirror-repe - `border: Readonly` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/panel.mdx b/site/src/content/api/panel.mdx index f33dad79..591aa9ab 100644 --- a/site/src/content/api/panel.mdx +++ b/site/src/content/api/panel.mdx @@ -5,7 +5,7 @@ symbol: "Panel" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 104 +memberCount: 105 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/Panel.ts" @@ -95,6 +95,7 @@ The base building block for HUD boxes, dialogs, and menus — add content with - `color: Color` - `cornerRadius: number` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `filters: readonly Filter[]` - `height: number` diff --git a/site/src/content/api/particle-system.mdx b/site/src/content/api/particle-system.mdx index c127a9b1..8ca27e7c 100644 --- a/site/src/content/api/particle-system.mdx +++ b/site/src/content/api/particle-system.mdx @@ -5,7 +5,7 @@ symbol: "ParticleSystem" kind: "class" subsystem: "particles" importPath: "@codexo/exojs-particles" -memberCount: 119 +memberCount: 120 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-particles/src/ParticleSystem.ts" @@ -149,6 +149,7 @@ to `(0, 0)`. - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `deathModules: readonly DeathModule[]` - `filters: readonly Filter[]` - `frames: readonly Rectangle[]` diff --git a/site/src/content/api/progress-bar.mdx b/site/src/content/api/progress-bar.mdx index 90f9c6ee..4965dffe 100644 --- a/site/src/content/api/progress-bar.mdx +++ b/site/src/content/api/progress-bar.mdx @@ -5,7 +5,7 @@ symbol: "ProgressBar" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 103 +memberCount: 104 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/ProgressBar.ts" @@ -90,6 +90,7 @@ fraction in `[0, 1]`; setting it redraws only the fill. - `children: RenderNode[]` - `cornerRadius: number` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `fillColor: Color` - `filters: readonly Filter[]` diff --git a/site/src/content/api/render-node.mdx b/site/src/content/api/render-node.mdx index 5135393d..000abb99 100644 --- a/site/src/content/api/render-node.mdx +++ b/site/src/content/api/render-node.mdx @@ -5,7 +5,7 @@ symbol: "RenderNode" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 75 +memberCount: 76 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/RenderNode.ts" @@ -86,6 +86,7 @@ ParticleSystem (particles). - `anchor: ObservableVector` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `interactive: boolean` - `isAlignedBox: boolean` diff --git a/site/src/content/api/repeating-sprite.mdx b/site/src/content/api/repeating-sprite.mdx index 1bac7ea1..51f4f3d4 100644 --- a/site/src/content/api/repeating-sprite.mdx +++ b/site/src/content/api/repeating-sprite.mdx @@ -5,7 +5,7 @@ symbol: "RepeatingSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 93 +memberCount: 94 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/RepeatingSprite.ts" @@ -91,6 +91,7 @@ renderer uses. - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `fitX: RepeatFit` - `fitY: RepeatFit` diff --git a/site/src/content/api/scene-node.mdx b/site/src/content/api/scene-node.mdx index be3f06fa..48fd0921 100644 --- a/site/src/content/api/scene-node.mdx +++ b/site/src/content/api/scene-node.mdx @@ -5,7 +5,7 @@ symbol: "SceneNode" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 43 +memberCount: 44 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/core/SceneNode.ts" @@ -78,6 +78,7 @@ Subclasses: Container (carries children), RenderNode - `name: string | null` - `anchor: ObservableVector` - `cullable: boolean` +- `cullArea: Rectangle | null` - `isAlignedBox: boolean` - `origin: ObservableVector` - `parent: Container | null` diff --git a/site/src/content/api/sprite.mdx b/site/src/content/api/sprite.mdx index 83d87b0b..3148c3f3 100644 --- a/site/src/content/api/sprite.mdx +++ b/site/src/content/api/sprite.mdx @@ -5,7 +5,7 @@ symbol: "Sprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 89 +memberCount: 90 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/Sprite.ts" @@ -89,6 +89,7 @@ operate on the exact rotated quad rather than the AABB. - `blendMode: BlendModes` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/stack.mdx b/site/src/content/api/stack.mdx index 1572586f..27554d06 100644 --- a/site/src/content/api/stack.mdx +++ b/site/src/content/api/stack.mdx @@ -5,7 +5,7 @@ symbol: "Stack" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 104 +memberCount: 105 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/Stack.ts" @@ -93,6 +93,7 @@ Stack.addItem to add and re-flow in one step. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `direction: StackDirection` - `enabled: boolean` - `filters: readonly Filter[]` diff --git a/site/src/content/api/text.mdx b/site/src/content/api/text.mdx index 108ab926..279e05f4 100644 --- a/site/src/content/api/text.mdx +++ b/site/src/content/api/text.mdx @@ -5,7 +5,7 @@ symbol: "Text" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 91 +memberCount: 92 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/Text.ts" @@ -108,6 +108,7 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. - `cacheAsBitmap: boolean` - `colorGlyphs: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `interactive: boolean` - `isAlignedBox: boolean` diff --git a/site/src/content/api/tile-layer-node.mdx b/site/src/content/api/tile-layer-node.mdx index 2ee0a337..c8258822 100644 --- a/site/src/content/api/tile-layer-node.mdx +++ b/site/src/content/api/tile-layer-node.mdx @@ -5,7 +5,7 @@ symbol: "TileLayerNode" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 94 +memberCount: 95 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileLayerNode.ts" @@ -99,6 +99,7 @@ existing chunks are picked up automatically via chunk revisions. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/tile-map-band.mdx b/site/src/content/api/tile-map-band.mdx index 8a455228..7ab0587e 100644 --- a/site/src/content/api/tile-map-band.mdx +++ b/site/src/content/api/tile-map-band.mdx @@ -5,7 +5,7 @@ symbol: "TileMapBand" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 93 +memberCount: 94 tier: "advanced" sections: ["Import", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileMapBand.ts" @@ -107,6 +107,7 @@ TileMapView rather than directly. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/tile-map-node.mdx b/site/src/content/api/tile-map-node.mdx index c52dbf8c..651daed8 100644 --- a/site/src/content/api/tile-map-node.mdx +++ b/site/src/content/api/tile-map-node.mdx @@ -5,7 +5,7 @@ symbol: "TileMapNode" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 96 +memberCount: 97 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileMapNode.ts" @@ -100,6 +100,7 @@ after TileMapNode.refreshLayers. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/uiroot.mdx b/site/src/content/api/uiroot.mdx index ee98794b..411753e0 100644 --- a/site/src/content/api/uiroot.mdx +++ b/site/src/content/api/uiroot.mdx @@ -5,7 +5,7 @@ symbol: "UIRoot" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 94 +memberCount: 95 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/UIRoot.ts" @@ -94,6 +94,7 @@ fires whenever the screen size changes, so anchored widgets can re-layout. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `filters: readonly Filter[]` - `height: number` - `interactive: boolean` diff --git a/site/src/content/api/video.mdx b/site/src/content/api/video.mdx index 502f2f74..22fbca55 100644 --- a/site/src/content/api/video.mdx +++ b/site/src/content/api/video.mdx @@ -5,7 +5,7 @@ symbol: "Video" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 114 +memberCount: 115 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/video/Video.ts" @@ -97,6 +97,7 @@ node and can be directed to any AudioBus. - `bus: AudioBus | null` - `cacheAsBitmap: boolean` - `cullable: boolean` +- `cullArea: Rectangle | null` - `currentTime: number` - `duration: number` - `filters: readonly Filter[]` diff --git a/site/src/content/api/widget.mdx b/site/src/content/api/widget.mdx index 33f5c4e0..0df57091 100644 --- a/site/src/content/api/widget.mdx +++ b/site/src/content/api/widget.mdx @@ -5,7 +5,7 @@ symbol: "Widget" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 99 +memberCount: 100 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/Widget.ts" @@ -93,6 +93,7 @@ react to enable/disable in Widget._onEnabledChanged. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `filters: readonly Filter[]` - `height: number` diff --git a/src/core/SceneNode.ts b/src/core/SceneNode.ts index 3b18952c..3fbe1b88 100644 --- a/src/core/SceneNode.ts +++ b/src/core/SceneNode.ts @@ -109,6 +109,7 @@ export class SceneNode implements Collidable, ObservableVectorOwner { private _parentNode: Container | null = null; private _zIndex = 0; private _cullable = true; + private _cullArea: Rectangle | null = null; /** * Optional human-readable identity for this node. Defaults to `null`. @@ -208,6 +209,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 +221,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 @@ -487,7 +505,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 { 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) { From 0b3dc6c88ae1f6b07982de402b86fc471c4c857c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:15:26 +0200 Subject: [PATCH 02/68] feat(build): add IIFE CDN bundle and size-limit CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dist/exo.iife.js and dist/exo.iife.min.js as global IIFE bundles exposing ExoJS on globalThis. Enables script-tag CDN usage. Adds size-limit CI gate: ESM bundle ≤700KB gzip, IIFE min ≤250KB gzip. --- .github/workflows/_ci-checks.yml | 3 + .gitignore | 1 + .size-limit.cjs | 12 ++ package.json | 7 + pnpm-lock.yaml | 348 +++++++++++++++++++++++++++++++ rollup.config.ts | 45 +++- 6 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 .size-limit.cjs diff --git a/.github/workflows/_ci-checks.yml b/.github/workflows/_ci-checks.yml index a1211ba7..d659fd3f 100644 --- a/.github/workflows/_ci-checks.yml +++ b/.github/workflows/_ci-checks.yml @@ -368,6 +368,9 @@ 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. 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..3444d55f --- /dev/null +++ b/.size-limit.cjs @@ -0,0 +1,12 @@ +module.exports = [ + { + path: 'dist/exo.esm.js', + limit: '700 KB', + gzip: true, + }, + { + path: 'dist/exo.iife.min.js', + limit: '250 KB', + gzip: true, + }, +]; diff --git a/package.json b/package.json index ac424d42..ba261cc9 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" @@ -131,6 +135,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" }, @@ -148,6 +153,7 @@ }, "devDependencies": { "@codexo/exojs-config": "workspace:*", + "@size-limit/preset-small-lib": "^11.2.0", "@eslint-react/eslint-plugin": "^5.9.0", "@eslint/js": "^10.0.1", "@rollup/plugin-node-resolve": "^16.0.3", @@ -179,6 +185,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/pnpm-lock.yaml b/pnpm-lock.yaml index 4e4afcea..6efde61f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ 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) '@types/css-font-loading-module': specifier: 0.0.14 version: 0.0.14 @@ -104,6 +107,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 @@ -502,6 +508,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 +526,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 +544,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 +562,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 +580,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 +598,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 +616,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 +634,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 +652,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 +670,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 +688,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 +706,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 +724,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 +742,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 +760,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 +778,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 +796,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 +814,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 +832,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 +850,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 +868,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 +886,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 +904,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 +922,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 +940,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 +958,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,6 +1646,23 @@ 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==} @@ -1927,6 +2106,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'} @@ -2233,6 +2416,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 +3094,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==} @@ -3181,6 +3373,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==} @@ -3666,6 +3866,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'} @@ -4726,156 +4931,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,6 +5745,22 @@ 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': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -6100,6 +6399,8 @@ snapshots: builtin-modules@5.2.0: {} + bytes-iec@3.1.1: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001799: {} @@ -6376,6 +6677,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 +7573,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 @@ -7774,6 +8106,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: {} @@ -8386,6 +8724,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/rollup.config.ts b/rollup.config.ts index f91d157f..bcd21e5c 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -57,6 +57,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: 'ExoJS', + 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: 'ExoJS', + 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 +147,6 @@ const modules: RollupOptions = { ], }; -export default [bundled, debugBundled, modules]; +const productionOnlyConfigs = buildMode === 'production' ? [iifeMin] : []; + +export default [bundled, debugBundled, modules, iife, ...productionOnlyConfigs]; From 1ee0a0b4c44ace70b20b3bdc7a6fbb115dd1bc16 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:26:43 +0200 Subject: [PATCH 03/68] feat(tilemap): add Tiled parallax factor support Adds parallaxX/parallaxY to TileLayer (default 1.0). TiledMap.toTileMap() now forwards parallax factors from TiledLayer. TileLayerNode applies camera-relative parallax offset during render plan collection, enabling depth parallax effects from Tiled editor layer settings. --- packages/exojs-tiled/src/TiledMap.ts | 2 + packages/exojs-tiled/test/toTileMap.test.ts | 58 +++++++++++++++++++++ packages/exojs-tilemap/src/TileLayer.ts | 28 ++++++++++ packages/exojs-tilemap/src/TileLayerNode.ts | 22 +++++++- packages/exojs-tilemap/test/nodes.test.ts | 35 +++++++++++++ packages/exojs-tilemap/test/tilemap.test.ts | 33 ++++++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) diff --git a/packages/exojs-tiled/src/TiledMap.ts b/packages/exojs-tiled/src/TiledMap.ts index 370ab948..ce5bb7e4 100644 --- a/packages/exojs-tiled/src/TiledMap.ts +++ b/packages/exojs-tiled/src/TiledMap.ts @@ -195,6 +195,8 @@ export class TiledMap { opacity: layer.opacity, offsetX: layer.offsetX, offsetY: layer.offsetY, + parallaxX: layer.parallaxX, + parallaxY: layer.parallaxY, }); if (layer.data) { populateTileLayer(rLayer, layer.data, this.tilesets, indexToRuntime); diff --git a/packages/exojs-tiled/test/toTileMap.test.ts b/packages/exojs-tiled/test/toTileMap.test.ts index f0b8fffe..c2750dc7 100644 --- a/packages/exojs-tiled/test/toTileMap.test.ts +++ b/packages/exojs-tiled/test/toTileMap.test.ts @@ -340,3 +340,61 @@ describe('TiledMap.toTileMap() — object layers', () => { expect(layer?.query({ kind: 'tile' })).toHaveLength(1); }); }); + +// ── 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-tilemap/src/TileLayer.ts b/packages/exojs-tilemap/src/TileLayer.ts index 8e338bab..d5cf3742 100644 --- a/packages/exojs-tilemap/src/TileLayer.ts +++ b/packages/exojs-tilemap/src/TileLayer.ts @@ -43,6 +43,16 @@ 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 properties (copied and frozen). */ readonly properties?: TileProperties; } @@ -101,6 +111,16 @@ 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; /** Immutable layer properties. */ public readonly properties: TileProperties; @@ -154,6 +174,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 +193,8 @@ export class TileLayer { this.opacity = opacity; this.offsetX = offsetX; this.offsetY = offsetY; + this.parallaxX = parallaxX; + this.parallaxY = parallaxY; 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..594e24d8 100644 --- a/packages/exojs-tilemap/src/TileLayerNode.ts +++ b/packages/exojs-tilemap/src/TileLayerNode.ts @@ -46,12 +46,16 @@ export class TileLayerNode extends Container { private readonly _chunkNodes: TileChunkNode[] = []; private _syncedOpacity = -1; 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(); @@ -137,7 +141,23 @@ export class TileLayerNode extends Container { this._syncOpacity(); - 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 { 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); + }); }); // ═══════════════════════════════════════════════════════════════════════ From 274544e836e4239f32f8ba4e9a4919f7a43da79a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:29:53 +0200 Subject: [PATCH 04/68] feat(animation): add TweenSequencer for composing animations Adds TweenSequencer with sequential and parallel stage composition, wait() delays, repeat/yoyo, and lifecycle callbacks. Integrates with TweenManager via addTicker(). Factory: manager.createSequencer(). --- src/animation/TweenManager.ts | 75 ++- src/animation/TweenSequencer.ts | 350 +++++++++++++ src/animation/index.ts | 1 + test/animation/TweenSequencer.test.ts | 472 ++++++++++++++++++ .../root-index-snapshot.test.ts.snap | 2 + .../root-index-type-inventory.test.ts.snap | 2 + 6 files changed, 897 insertions(+), 5 deletions(-) create mode 100644 src/animation/TweenSequencer.ts create mode 100644 test/animation/TweenSequencer.test.ts 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/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/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index fc1b146a..2e200b79 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -174,6 +174,8 @@ exports[`root index export surface snapshot > sorted runtime export names match "Timer", "Tween", "TweenManager", + "TweenSequencer", + "TweenSequencerState", "TweenState", "UIRoot", "Vector", 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..54341885 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -363,6 +363,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "Tween: class", "TweenLifecycleCallback: type alias", "TweenManager: class", + "TweenSequencer: class", + "TweenSequencerState: enum", "TweenState: enum", "TweenUpdateCallback: type alias", "TypedArray: type alias", From 429dc79cd47629d7275f6ecb4327faf5d425a2b9 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:34:20 +0200 Subject: [PATCH 05/68] feat(ui): add Tooltip and ScrollContainer widgets Tooltip: hover-triggered text label with configurable delay and position. ScrollContainer: clipped widget with vertical/horizontal scroll via mouse wheel. Content added to .content child container. --- site/src/content/api/scroll-container.mdx | 147 ++++++++++++++ site/src/content/api/tooltip.mdx | 37 ++++ src/core/Stage.ts | 8 + src/input/InteractionManager.ts | 4 +- src/ui/ScrollContainer.ts | 136 +++++++++++++ src/ui/Tooltip.ts | 181 ++++++++++++++++++ src/ui/index.ts | 4 + .../root-index-snapshot.test.ts.snap | 2 + .../root-index-type-inventory.test.ts.snap | 5 + 9 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 site/src/content/api/scroll-container.mdx create mode 100644 site/src/content/api/tooltip.mdx create mode 100644 src/ui/ScrollContainer.ts create mode 100644 src/ui/Tooltip.ts diff --git a/site/src/content/api/scroll-container.mdx b/site/src/content/api/scroll-container.mdx new file mode 100644 index 00000000..ebbc8473 --- /dev/null +++ b/site/src/content/api/scroll-container.mdx @@ -0,0 +1,147 @@ +--- +title: "ScrollContainer" +description: "Clipped container that scrolls its content via the mouse wheel. Add child nodes to 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 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." +symbol: "ScrollContainer" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 104 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/ScrollContainer.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/ScrollContainer.ts#L41" +--- +## Import + +`import { ScrollContainer } from '@codexo/exojs'` + +Clipped container that scrolls its content via the mouse wheel. + +Add child nodes to 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 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. + +## Constructors + +- `new(options: ScrollContainerOptions): ScrollContainer` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `scrollBy(dx: number, dy: number): void` +- `scrollTo(x: number, y: number): void` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `content: Container` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `name: string | null` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `scrollX: number` +- `scrollY: number` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/ScrollContainer.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/ScrollContainer.ts#L41) diff --git a/site/src/content/api/tooltip.mdx b/site/src/content/api/tooltip.mdx new file mode 100644 index 00000000..888fe46f --- /dev/null +++ b/site/src/content/api/tooltip.mdx @@ -0,0 +1,37 @@ +--- +title: "Tooltip" +description: "Hover tooltip attached to a 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 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." +symbol: "Tooltip" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 2 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Source"] +sourcePath: "src/ui/Tooltip.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Tooltip.ts#L48" +--- +## Import + +`import { Tooltip } from '@codexo/exojs'` + +Hover tooltip attached to a 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 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. + +## Constructors + +- `new(target: RenderNode, options: TooltipOptions): Tooltip` + +## Methods + +- `destroy(): void` + +## Source + +[src/ui/Tooltip.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Tooltip.ts#L48) 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/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/ui/ScrollContainer.ts b/src/ui/ScrollContainer.ts new file mode 100644 index 00000000..f814d9e7 --- /dev/null +++ b/src/ui/ScrollContainer.ts @@ -0,0 +1,136 @@ +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/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index fc1b146a..f73c1d1f 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -137,6 +137,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Scene", "SceneManager", "SceneNode", + "ScrollContainer", "Segment", "SerializationRegistry", "Shader", @@ -172,6 +173,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "TextureRegion", "Time", "Timer", + "Tooltip", "Tween", "TweenManager", "TweenState", 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..702c3977 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -291,6 +291,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,6 +362,8 @@ 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", From c370888a1803776014897c03b34e38b27b44cb38 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:35:45 +0200 Subject: [PATCH 06/68] feat(rendering): wire W3C blend modes suite (all 18 modes) Integrates the advanced-blend-spike compositor code into the render plan. Modes Darken(5)..Luminosity(17) now use backdrop-aware compositing (GPU copy + blend shader) instead of broken fixed-function MIN/MAX. Modes 0-4 unchanged (fixed-function path). Adds isAdvancedBlendMode predicate and needsBackdropBlend barrier flag. Both WebGL2 and WebGPU backends implement composeWithBackdropBlend(). --- examples/sprites-textures/blendmodes.js | 12 + examples/sprites-textures/blendmodes.ts | 12 + site/src/content/api/blend-modes.mdx | 26 +- site/src/content/api/buffer-types.mdx | 4 +- site/src/content/api/buffer-usage.mdx | 4 +- site/src/content/api/rendering-primitives.mdx | 4 +- site/src/content/api/scale-modes.mdx | 4 +- site/src/content/api/shader-primitives.mdx | 4 +- site/src/content/api/wrap-modes.mdx | 4 +- src/rendering/RenderBackend.ts | 9 + src/rendering/RenderNode.ts | 4 +- src/rendering/plan/RenderEffectExecutor.ts | 8 +- src/rendering/plan/RenderPlanBuilder.ts | 8 +- src/rendering/plan/RenderScope.ts | 7 + src/rendering/public.ts | 2 +- src/rendering/types.ts | 49 +- .../webgl2/WebGl2BackdropBlendCompositor.ts | 287 +++++++++ src/rendering/webgl2/WebGl2Backend.ts | 57 +- src/rendering/webgl2/glsl/backdrop-blend.frag | 169 ++++++ src/rendering/webgl2/glsl/backdrop-blend.vert | 14 + .../webgpu/WebGpuBackdropBlendCompositor.ts | 570 ++++++++++++++++++ src/rendering/webgpu/WebGpuBackend.ts | 62 +- src/rendering/webgpu/WebGpuBlendState.ts | 34 +- .../root-index-snapshot.test.ts.snap | 1 + .../root-index-type-inventory.test.ts.snap | 1 + test/rendering/browser/_blendReference.ts | 147 +++++ .../browser/webgl2-backdrop-blend.test.ts | 312 ++++++++++ .../browser/webgpu-backdrop-blend.test.ts | 284 +++++++++ 28 files changed, 2022 insertions(+), 77 deletions(-) create mode 100644 src/rendering/webgl2/WebGl2BackdropBlendCompositor.ts create mode 100644 src/rendering/webgl2/glsl/backdrop-blend.frag create mode 100644 src/rendering/webgl2/glsl/backdrop-blend.vert create mode 100644 src/rendering/webgpu/WebGpuBackdropBlendCompositor.ts create mode 100644 test/rendering/browser/_blendReference.ts create mode 100644 test/rendering/browser/webgl2-backdrop-blend.test.ts create mode 100644 test/rendering/browser/webgpu-backdrop-blend.test.ts 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/site/src/content/api/blend-modes.mdx b/site/src/content/api/blend-modes.mdx index eadf7827..db1346fb 100644 --- a/site/src/content/api/blend-modes.mdx +++ b/site/src/content/api/blend-modes.mdx @@ -1,33 +1,49 @@ --- title: "BlendModes" -description: "Compositing blend modes applied when drawing a Drawable over the current render target. Values map to backend-specific blend-equation presets." +description: "Compositing blend modes applied when drawing a Drawable over the current render target. 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 isAdvancedBlendMode to test whether a mode requires the compositor path." symbol: "BlendModes" kind: "enum" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 7 +memberCount: 18 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L5" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L10" --- ## Import `import { BlendModes } from '@codexo/exojs'` Compositing blend modes applied when drawing a 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 isAdvancedBlendMode to test whether a +mode requires the compositor path. ## Members - `Additive` +- `Color` +- `ColorBurn` +- `ColorDodge` - `Darken` +- `Difference` +- `Exclusion` +- `HardLight` +- `Hue` - `Lighten` +- `Luminosity` - `Multiply` - `Normal` +- `Overlay` +- `Saturation` - `Screen` +- `SoftLight` - `Subtract` ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L5) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L10) diff --git a/site/src/content/api/buffer-types.mdx b/site/src/content/api/buffer-types.mdx index c781a5c2..e7a09704 100644 --- a/site/src/content/api/buffer-types.mdx +++ b/site/src/content/api/buffer-types.mdx @@ -9,7 +9,7 @@ memberCount: 8 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L68" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L93" --- ## Import @@ -31,4 +31,4 @@ Values are WebGL2 GLenum constants used when calling `gl.bindBuffer`. ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L68) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L93) diff --git a/site/src/content/api/buffer-usage.mdx b/site/src/content/api/buffer-usage.mdx index 09b5cda2..867d3697 100644 --- a/site/src/content/api/buffer-usage.mdx +++ b/site/src/content/api/buffer-usage.mdx @@ -9,7 +9,7 @@ memberCount: 9 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L83" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L108" --- ## Import @@ -32,4 +32,4 @@ Values are WebGL2 GLenum constants used when calling `gl.bufferData`. ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L83) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L108) diff --git a/site/src/content/api/rendering-primitives.mdx b/site/src/content/api/rendering-primitives.mdx index cfbef9bf..3006229a 100644 --- a/site/src/content/api/rendering-primitives.mdx +++ b/site/src/content/api/rendering-primitives.mdx @@ -9,7 +9,7 @@ memberCount: 7 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L54" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L79" --- ## Import @@ -30,4 +30,4 @@ Values are WebGL2 GLenum constants (e.g. `gl.TRIANGLES`). ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L54) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L79) diff --git a/site/src/content/api/scale-modes.mdx b/site/src/content/api/scale-modes.mdx index dda2c26b..34308810 100644 --- a/site/src/content/api/scale-modes.mdx +++ b/site/src/content/api/scale-modes.mdx @@ -9,7 +9,7 @@ memberCount: 6 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L31" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L56" --- ## Import @@ -30,4 +30,4 @@ Mipmap variants require Texture.generateMipMap to be enabled. ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L31) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L56) diff --git a/site/src/content/api/shader-primitives.mdx b/site/src/content/api/shader-primitives.mdx index d134fa17..5e65d104 100644 --- a/site/src/content/api/shader-primitives.mdx +++ b/site/src/content/api/shader-primitives.mdx @@ -9,7 +9,7 @@ memberCount: 20 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L100" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L125" --- ## Import @@ -43,4 +43,4 @@ Values are WebGL2 GLenum constants returned by `gl.getActiveAttrib` / `gl.getAct ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L100) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L125) diff --git a/site/src/content/api/wrap-modes.mdx b/site/src/content/api/wrap-modes.mdx index b79957ca..b32ffa8f 100644 --- a/site/src/content/api/wrap-modes.mdx +++ b/site/src/content/api/wrap-modes.mdx @@ -9,7 +9,7 @@ memberCount: 3 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/rendering/types.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L44" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L69" --- ## Import @@ -26,4 +26,4 @@ Values are WebGL2 GLenum constants passed directly to the GPU sampler. ## Source -[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L44) +[src/rendering/types.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/types.ts#L69) diff --git a/src/rendering/RenderBackend.ts b/src/rendering/RenderBackend.ts index 1ea5d1de..e759a8e5 100644 --- a/src/rendering/RenderBackend.ts +++ b/src/rendering/RenderBackend.ts @@ -85,6 +85,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/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/RenderPlanBuilder.ts b/src/rendering/plan/RenderPlanBuilder.ts index d403bf5a..db9cbe41 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'; @@ -130,7 +131,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 +402,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..e58d6a3a 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; @@ -647,6 +650,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`. @@ -796,17 +837,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 +925,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..5d91790e 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; @@ -458,6 +461,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; @@ -612,6 +657,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 +912,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 +1051,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 +1383,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/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index fc1b146a..10b8159f 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -199,6 +199,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "defineAssetManifest", "getAudioContext", "getOfflineAudioContext", + "isAdvancedBlendMode", "isAudioContextReady", "maxPointers", "onAudioContextReady", 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..dd41cec5 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -404,6 +404,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "defineAssetManifest: function", "getAudioContext: variable", "getOfflineAudioContext: variable", + "isAdvancedBlendMode: variable", "isAudioContextReady: variable", "maxPointers: variable", "onAudioContextReady: variable", 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(); + } + }); +}); From 616866907b9ea36f196e4ee8dadfca73c712328a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:49:40 +0200 Subject: [PATCH 07/68] feat(core): add Logger class and expand Signal hookpoints Adds Logger with LogSeverity/LogChannel/LogEntry, prod-stripped for non-error logs. Exports logger singleton with auto-attached console handler in dev mode. Adds new Signals: Application.onError, Scene.onLoad/onUnload, SystemRegistry.onAdd/onRemove for external system integration. --- site/src/content/api/application.mdx | 3 +- site/src/content/api/scene.mdx | 13 ++- site/src/content/api/scroll-container.mdx | 3 +- site/src/content/api/system-registry.mdx | 13 ++- site/src/content/api/tile-layer.mdx | 8 +- site/src/content/api/tween-manager.mdx | 14 ++- src/core/Application.ts | 4 + src/core/Scene.ts | 8 ++ src/core/SceneManager.ts | 3 + src/core/SystemRegistry.ts | 10 ++ src/core/index.ts | 2 + src/core/logging.ts | 110 ++++++++++++++++++ .../root-index-snapshot.test.ts.snap | 3 + .../root-index-type-inventory.test.ts.snap | 5 + 14 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 src/core/logging.ts diff --git a/site/src/content/api/application.mdx b/site/src/content/api/application.mdx index 3ab711fa..e5a89d28 100644 --- a/site/src/content/api/application.mdx +++ b/site/src/content/api/application.mdx @@ -5,7 +5,7 @@ symbol: "Application" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 42 +memberCount: 43 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/core/Application.ts" @@ -87,6 +87,7 @@ background-active simulations. - `onBackendLost: Signal<[]>` - `onBackendRestored: Signal<[]>` - `onCanvasFocusChange: Signal<[focused]>` +- `onError: Signal<[error]>` - `onFrame: Signal<[Time]>` - `onResize: Signal<[number, number, Application]>` - `onVisibilityChange: Signal<[visible]>` diff --git a/site/src/content/api/scene.mdx b/site/src/content/api/scene.mdx index cf0eb2ee..f3ff0a57 100644 --- a/site/src/content/api/scene.mdx +++ b/site/src/content/api/scene.mdx @@ -5,11 +5,11 @@ symbol: "Scene" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 19 +memberCount: 21 tier: "stable" -sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/core/Scene.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L108" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L109" --- ## Import @@ -60,6 +60,11 @@ For one-off scenes, an anonymous subclass works just as well: - `tweens: SceneTweens` - `ui: UIRoot` +## Events + +- `onLoad: Signal<[]>` +- `onUnload: Signal<[]>` + ## Source -[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L108) +[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L109) diff --git a/site/src/content/api/scroll-container.mdx b/site/src/content/api/scroll-container.mdx index ebbc8473..31968396 100644 --- a/site/src/content/api/scroll-container.mdx +++ b/site/src/content/api/scroll-container.mdx @@ -5,7 +5,7 @@ symbol: "ScrollContainer" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 104 +memberCount: 105 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/ui/ScrollContainer.ts" @@ -100,6 +100,7 @@ unsubscribes on detach. - `cacheAsBitmap: boolean` - `children: RenderNode[]` - `cullable: boolean` +- `cullArea: Rectangle | null` - `enabled: boolean` - `filters: readonly Filter[]` - `height: number` diff --git a/site/src/content/api/system-registry.mdx b/site/src/content/api/system-registry.mdx index d257c0aa..898c4d28 100644 --- a/site/src/content/api/system-registry.mdx +++ b/site/src/content/api/system-registry.mdx @@ -5,11 +5,11 @@ symbol: "SystemRegistry" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 6 +memberCount: 8 tier: "stable" -sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/core/SystemRegistry.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/SystemRegistry.ts#L15" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/SystemRegistry.ts#L16" --- ## Import @@ -39,6 +39,11 @@ iteration is never invalidated mid-frame. - `size: number` +## Events + +- `onAdd: Signal<[system]>` +- `onRemove: Signal<[system]>` + ## Source -[src/core/SystemRegistry.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/SystemRegistry.ts#L15) +[src/core/SystemRegistry.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/SystemRegistry.ts#L16) diff --git a/site/src/content/api/tile-layer.mdx b/site/src/content/api/tile-layer.mdx index e2a6caea..3e530678 100644 --- a/site/src/content/api/tile-layer.mdx +++ b/site/src/content/api/tile-layer.mdx @@ -5,11 +5,11 @@ symbol: "TileLayer" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 34 +memberCount: 36 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "packages/exojs-tilemap/src/TileLayer.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/TileLayer.ts#L70" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/TileLayer.ts#L80" --- ## Import @@ -62,6 +62,8 @@ renderer slice. - `offsetX: number` - `offsetY: number` - `opacity: number` +- `parallaxX: number` +- `parallaxY: number` - `properties: TileProperties` - `tileHeight: number` - `tilesets: readonly TileSet[]` @@ -75,4 +77,4 @@ renderer slice. ## Source -[packages/exojs-tilemap/src/TileLayer.ts](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/TileLayer.ts#L70) +[packages/exojs-tilemap/src/TileLayer.ts](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/TileLayer.ts#L80) diff --git a/site/src/content/api/tween-manager.mdx b/site/src/content/api/tween-manager.mdx index 8bc2a2bc..b1354e20 100644 --- a/site/src/content/api/tween-manager.mdx +++ b/site/src/content/api/tween-manager.mdx @@ -1,15 +1,15 @@ --- title: "TweenManager" -description: "Owns and advances a collection of Tween instances, driving them once per frame from Application.update. Created tweens are tracked automatically; manually constructed tweens can be opted in via TweenManager.add. 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." +description: "Owns and advances a collection of Tween instances, driving them once per frame from Application.update. Created tweens are tracked automatically; manually constructed tweens can be opted in via TweenManager.add. Custom updatables (such as TweenSequencer) can be registered via 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." symbol: "TweenManager" kind: "class" subsystem: "animation" importPath: "@codexo/exojs" -memberCount: 8 +memberCount: 11 tier: "stable" sections: ["Import", "Constructors", "Methods", "Source"] sourcePath: "src/animation/TweenManager.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenManager.ts#L17" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenManager.ts#L26" --- ## Import @@ -20,6 +20,9 @@ once per frame from Application.update. Created tweens are tracked automatically; manually constructed tweens can be opted in via TweenManager.add. +Custom updatables (such as TweenSequencer) can be registered via +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. @@ -31,13 +34,16 @@ stopped tweens are evicted automatically. ## Methods - `add(tween: Tween): this` +- `addTicker(ticker: Ticker): this` - `clear(): this` - `create(target: T): Tween` +- `createSequencer(): TweenSequencer` - `destroy(): void` - `remove(tween: Tween): this` +- `removeTicker(ticker: Ticker): this` - `sequence(tweens: readonly Tween[]): Tween` - `update(delta: Time): void` ## Source -[src/animation/TweenManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenManager.ts#L17) +[src/animation/TweenManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenManager.ts#L26) diff --git a/src/core/Application.ts b/src/core/Application.ts index 233f1abe..5c1715bd 100644 --- a/src/core/Application.ts +++ b/src/core/Application.ts @@ -246,6 +246,8 @@ export class Application { 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; @@ -606,6 +608,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(); @@ -799,6 +802,7 @@ export class Application { this.onVisibilityChange.destroy(); this.onBackendLost.destroy(); this.onBackendRestored.destroy(); + this.onError.destroy(); } private _onDocumentVisibilityChange(): void { diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 4a71809c..9b945a3b 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -8,6 +8,7 @@ import { UIRoot } from '#ui/UIRoot'; import type { Application } from './Application'; import { DisposalScope } from './DisposalScope'; +import { Signal } from './Signal'; import { deserializeInto, migrate, serializeTree } from './serialization/serialize'; import { SERIALIZATION_VERSION, type SerializedScene } from './serialization/types'; import { SystemRegistry } from './SystemRegistry'; @@ -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; @@ -368,6 +374,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..55dddde8 100644 --- a/src/core/SceneManager.ts +++ b/src/core/SceneManager.ts @@ -227,6 +227,8 @@ export class SceneManager { if (ui !== null) { this._app.interaction.attachUIRoot(ui); } + + scene.onLoad.dispatch(); } catch (error) { let cleanupError: unknown = null; @@ -258,6 +260,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/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..4e18fb58 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -28,6 +28,8 @@ export type { SceneNodeConstructor } from './serialization/SerializationRegistry export { registerSerializer, SerializationRegistry } from './serialization/SerializationRegistry'; export type { AssetRef, SerializedNode, SerializedScene } from './serialization/types'; export { SERIALIZATION_VERSION } from './serialization/types'; +export { Logger, LogSeverity, logger } from './logging'; +export type { LogChannel, LogEntry } from './logging'; export { Signal } from './Signal'; export type { System } from './System'; export { SystemRegistry } from './SystemRegistry'; diff --git a/src/core/logging.ts b/src/core/logging.ts new file mode 100644 index 00000000..142b2ecc --- /dev/null +++ b/src/core/logging.ts @@ -0,0 +1,110 @@ +declare const __DEV__: boolean; + +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/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index b5949d8d..d814df85 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -92,6 +92,8 @@ exports[`root index export surface snapshot > sorted runtime export names match "LinearGradient", "Loader", "LoadingQueue", + "LogSeverity", + "Logger", "LowpassFilter", "LutFilter", "Material", @@ -205,6 +207,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "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 01f00803..cd600bd1 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -204,6 +204,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", @@ -413,6 +417,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "getOfflineAudioContext: variable", "isAdvancedBlendMode: variable", "isAudioContextReady: variable", + "logger: variable", "maxPointers: variable", "onAudioContextReady: variable", "pointerSlotSize: variable", From 9e6b803ea2507cf58a801b19e40938eb1562ba5f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 03:57:57 +0200 Subject: [PATCH 08/68] style: fix lint/format errors in v0.15 batch (sort imports, remove duplicate __DEV__ decl, prettier) --- src/core/Scene.ts | 2 +- src/core/index.ts | 4 ++-- src/core/logging.ts | 30 ++++-------------------------- src/ui/ScrollContainer.ts | 5 +---- 4 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 9b945a3b..5f40607c 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -8,9 +8,9 @@ import { UIRoot } from '#ui/UIRoot'; import type { Application } from './Application'; import { DisposalScope } from './DisposalScope'; -import { Signal } from './Signal'; 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'; diff --git a/src/core/index.ts b/src/core/index.ts index 4e18fb58..a9a466dc 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -17,6 +17,8 @@ export { Capabilities } from './capabilities'; export { Clock } from './Clock'; 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'; @@ -28,8 +30,6 @@ export type { SceneNodeConstructor } from './serialization/SerializationRegistry export { registerSerializer, SerializationRegistry } from './serialization/SerializationRegistry'; export type { AssetRef, SerializedNode, SerializedScene } from './serialization/types'; export { SERIALIZATION_VERSION } from './serialization/types'; -export { Logger, LogSeverity, logger } from './logging'; -export type { LogChannel, LogEntry } from './logging'; export { Signal } from './Signal'; export type { System } from './System'; export { SystemRegistry } from './SystemRegistry'; diff --git a/src/core/logging.ts b/src/core/logging.ts index 142b2ecc..8563f8ff 100644 --- a/src/core/logging.ts +++ b/src/core/logging.ts @@ -1,5 +1,3 @@ -declare const __DEV__: boolean; - export enum LogSeverity { Debug = 0, Info = 1, @@ -7,17 +5,7 @@ export enum LogSeverity { Error = 3, } -export type LogChannel = - | 'core' - | 'rendering' - | 'audio' - | 'input' - | 'assets' - | 'physics' - | 'ui' - | 'animation' - | 'scene' - | (string & {}); +export type LogChannel = 'core' | 'rendering' | 'audio' | 'input' | 'assets' | 'physics' | 'ui' | 'animation' | 'scene' | (string & {}); export interface LogEntry { readonly severity: LogSeverity; @@ -33,12 +21,7 @@ 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 { + public log(severity: LogSeverity, channel: LogChannel, message: string, options?: { data?: Record; error?: Error }): void { if (!__DEV__ && severity < LogSeverity.Error) return; const entry: LogEntry = { severity, @@ -91,14 +74,9 @@ export class Logger { export const logger = new Logger(); if (__DEV__) { - logger.addHandler((entry) => { + logger.addHandler(entry => { const prefix = `[ExoJS:${entry.channel}]`; - const method = - entry.severity >= LogSeverity.Error - ? 'error' - : entry.severity >= LogSeverity.Warning - ? 'warn' - : 'log'; + 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) { diff --git a/src/ui/ScrollContainer.ts b/src/ui/ScrollContainer.ts index f814d9e7..12bf4020 100644 --- a/src/ui/ScrollContainer.ts +++ b/src/ui/ScrollContainer.ts @@ -59,10 +59,7 @@ export class ScrollContainer extends Widget { return; } - this.scrollBy( - this._direction !== 'vertical' ? delta.x : 0, - this._direction !== 'horizontal' ? delta.y : 0, - ); + this.scrollBy(this._direction !== 'vertical' ? delta.x : 0, this._direction !== 'horizontal' ? delta.y : 0); }; public constructor(options: ScrollContainerOptions) { From aa12ddf917529949e90f42f6250a3e2d1d809468 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 04:01:09 +0200 Subject: [PATCH 09/68] docs: generate API pages for Logger, LogSeverity, TweenSequencer (v0.15 additions) --- site/src/content/api/log-severity.mdx | 27 +++++++++ site/src/content/api/logger.mdx | 34 +++++++++++ .../src/content/api/tween-sequencer-state.mdx | 33 +++++++++++ site/src/content/api/tween-sequencer.mdx | 57 +++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 site/src/content/api/log-severity.mdx create mode 100644 site/src/content/api/logger.mdx create mode 100644 site/src/content/api/tween-sequencer-state.mdx create mode 100644 site/src/content/api/tween-sequencer.mdx diff --git a/site/src/content/api/log-severity.mdx b/site/src/content/api/log-severity.mdx new file mode 100644 index 00000000..3d343e69 --- /dev/null +++ b/site/src/content/api/log-severity.mdx @@ -0,0 +1,27 @@ +--- +title: "LogSeverity" +description: "" +symbol: "LogSeverity" +kind: "enum" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 4 +tier: "stable" +sections: ["Import", "Members", "Source"] +sourcePath: "src/core/logging.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/logging.ts#L1" +--- +## Import + +`import { LogSeverity } from '@codexo/exojs'` + +## Members + +- `Debug` +- `Error` +- `Info` +- `Warning` + +## Source + +[src/core/logging.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/logging.ts#L1) diff --git a/site/src/content/api/logger.mdx b/site/src/content/api/logger.mdx new file mode 100644 index 00000000..8c351480 --- /dev/null +++ b/site/src/content/api/logger.mdx @@ -0,0 +1,34 @@ +--- +title: "Logger" +description: "" +symbol: "Logger" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 8 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Source"] +sourcePath: "src/core/logging.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/logging.ts#L20" +--- +## Import + +`import { Logger } from '@codexo/exojs'` + +## Constructors + +- `new(): Logger` + +## Methods + +- `addHandler(handler: LogHandler): object` +- `debug(channel: LogChannel, message: string, data?: Record): void` +- `error(channel: LogChannel, message: string, error?: Error): void` +- `info(channel: LogChannel, message: string, data?: Record): void` +- `log(severity: LogSeverity, channel: LogChannel, message: string, options?: object): void` +- `warn(channel: LogChannel, message: string, data?: Record): void` +- `warnOnce(key: string, channel: LogChannel, message: string): void` + +## Source + +[src/core/logging.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/logging.ts#L20) diff --git a/site/src/content/api/tween-sequencer-state.mdx b/site/src/content/api/tween-sequencer-state.mdx new file mode 100644 index 00000000..8d0bf8ce --- /dev/null +++ b/site/src/content/api/tween-sequencer-state.mdx @@ -0,0 +1,33 @@ +--- +title: "TweenSequencerState" +description: "Lifecycle states of a TweenSequencer. Mirrors TweenState semantics: starts `Idle`, becomes `Active` on TweenSequencer.start, and ends in `Complete` (all stages exhausted) or `Stopped` (cancelled via TweenSequencer.stop). `Paused` is reachable from `Active` only." +symbol: "TweenSequencerState" +kind: "enum" +subsystem: "animation" +importPath: "@codexo/exojs" +memberCount: 5 +tier: "stable" +sections: ["Import", "Members", "Source"] +sourcePath: "src/animation/TweenSequencer.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenSequencer.ts#L23" +--- +## Import + +`import { TweenSequencerState } from '@codexo/exojs'` + +Lifecycle states of a TweenSequencer. Mirrors TweenState +semantics: starts `Idle`, becomes `Active` on TweenSequencer.start, +and ends in `Complete` (all stages exhausted) or `Stopped` (cancelled via +TweenSequencer.stop). `Paused` is reachable from `Active` only. + +## Members + +- `Active` +- `Complete` +- `Idle` +- `Paused` +- `Stopped` + +## Source + +[src/animation/TweenSequencer.ts](https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenSequencer.ts#L23) diff --git a/site/src/content/api/tween-sequencer.mdx b/site/src/content/api/tween-sequencer.mdx new file mode 100644 index 00000000..65b01f30 --- /dev/null +++ b/site/src/content/api/tween-sequencer.mdx @@ -0,0 +1,57 @@ +--- +title: "TweenSequencer" +description: "Composes multiple Tween instances into a multi-stage animation. Each stage added via 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 TweenSequencer.wait create a timed pause between stages without needing a dummy tween. The sequencer integrates with TweenManager via TweenManager.addTicker so it is driven automatically each frame. It can also be used stand-alone by calling TweenSequencer.update manually — in that mode the sequencer also advances its child tweens." +symbol: "TweenSequencer" +kind: "class" +subsystem: "animation" +importPath: "@codexo/exojs" +memberCount: 14 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sourcePath: "src/animation/TweenSequencer.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenSequencer.ts#L58" +--- +## Import + +`import { TweenSequencer } from '@codexo/exojs'` + +Composes multiple Tween instances into a multi-stage animation. + +Each stage added via 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 TweenSequencer.wait create a timed pause +between stages without needing a dummy tween. + +The sequencer integrates with TweenManager via +TweenManager.addTicker so it is driven automatically each frame. +It can also be used stand-alone by calling TweenSequencer.update +manually — in that mode the sequencer also advances its child tweens. + +## Constructors + +- `new(manager?: TweenManager): TweenSequencer` + +## Methods + +- `onComplete(cb: object): this` +- `onStart(cb: object): this` +- `pause(): this` +- `repeat(count: number): this` +- `resume(): this` +- `start(): this` +- `stop(): this` +- `then(tween: Tween | Tween[]): this` +- `update(deltaSeconds: number): void` +- `wait(seconds: number): this` +- `yoyo(enabled: boolean): this` + +## Properties + +- `progress: number` +- `state: TweenSequencerState` + +## Source + +[src/animation/TweenSequencer.ts](https://github.com/Exoridus/ExoJS/blob/main/src/animation/TweenSequencer.ts#L58) From 7e94aca67058d7d4589cf014f17bbbb7f477236e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:39:51 +0200 Subject: [PATCH 10/68] feat(material): add MeshMaterial.from() and SpriteMaterial.from() static factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX improvement — instead of manually constructing both ShaderSource and MeshMaterial/SpriteMaterial, a single call suffices: MeshMaterial.from(vertSrc, fragSrc, { uniforms, blendMode }) MeshMaterial.from(existingShaderSource, { uniforms }) Two TS overloads per class: 1. from(source: ShaderSource, options?: Omit) 2. from(glslVertex, glslFragment, options?: { wgsl?, uniforms?, blendMode?, sampler? }) Implementation uses exactOptionalPropertyTypes-safe conditional spreads (...(x !== undefined ? { x } : {})) and instanceof dispatch for the ShaderSource overload. SpriteMaterial.from() is a structural mirror. --- src/rendering/material/MeshMaterial.ts | 54 +++++++++++++++++++++++- src/rendering/material/SpriteMaterial.ts | 54 +++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) 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 } : {}), + }); + } } From b3ed3e377a449f7bc9350df547a8711d60f38020 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:39:51 +0200 Subject: [PATCH 11/68] feat(material): add MeshMaterial.from() and SpriteMaterial.from() static factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX improvement — instead of manually constructing both ShaderSource and MeshMaterial/SpriteMaterial, a single call suffices: MeshMaterial.from(vertSrc, fragSrc, { uniforms, blendMode }) MeshMaterial.from(existingShaderSource, { uniforms }) Two TS overloads per class: 1. from(source: ShaderSource, options?: Omit) 2. from(glslVertex, glslFragment, options?: { wgsl?, uniforms?, blendMode?, sampler? }) Implementation uses exactOptionalPropertyTypes-safe conditional spreads (...(x !== undefined ? { x } : {})) and instanceof dispatch for the ShaderSource overload. SpriteMaterial.from() is a structural mirror. --- src/rendering/material/MeshMaterial.ts | 54 +++++++++++++++++++++++- src/rendering/material/SpriteMaterial.ts | 54 +++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) 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 } : {}), + }); + } } From b40704b16e10dfc2106c836784052a0b2864aa0a Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:41:14 +0200 Subject: [PATCH 12/68] feat(resources): add onLoadStart/Progress/Complete/Error signals to Loader Adds four new public signals to Loader for tracking foreground load batches without polling: - onLoadStart [key, url] fires when the loader transitions idle to active - onLoadProgress [loaded, total, key] fires after each item settles - onLoadComplete [] fires when all batch items have settled - onLoadError [key, error] fires on failure, does not suppress onLoadComplete Batch lifecycle: the first load() call that finds the loader idle starts a new batch and resets counters; concurrent calls within the same tick join the current batch so total grows dynamically. When active count returns to zero, onLoadComplete fires and totals reset. All four code paths in load() (extension-based, single string, array, Record) plus _createLoadingQueue (Asset/Assets/config-map) are instrumented. All four signals are destroyed in destroy(). --- src/resources/Loader.ts | 75 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) 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 // ----------------------------------------------------------------------- From 307161ece86905c115abf77cf0cbc84a836aea5f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:45:51 +0200 Subject: [PATCH 13/68] feat(tilemap): add Wang autotile system (WangSet + autoTile) Adds blob (8-neighbor) and edge (4-neighbor) Wang autotiling support to @codexo/exojs-tilemap. The corner-dependency rule ensures diagonal bits are only set when both adjacent cardinal directions are present, collapsing the 256 raw blob combinations to the standard 47 valid states. - WangSet: maps a bitmask to a local tile ID within a named tileset. Accepts both ReadonlyMap and a plain Record. - autoTile(layer, wangSet, options?): two-pass (snapshot -> write) so reads always reflect pre-call state; supports matchFn to restrict the Wang group and wrapBorder to control border tile fill behaviour. - 7 unit tests covering corner dependency, matchFn scope, and edge mode. --- packages/exojs-tilemap/src/WangSet.ts | 80 ++++++ packages/exojs-tilemap/src/autoTile.ts | 171 ++++++++++++ packages/exojs-tilemap/src/public.ts | 5 + packages/exojs-tilemap/test/autoTile.test.ts | 276 +++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 packages/exojs-tilemap/src/WangSet.ts create mode 100644 packages/exojs-tilemap/src/autoTile.ts create mode 100644 packages/exojs-tilemap/test/autoTile.test.ts diff --git a/packages/exojs-tilemap/src/WangSet.ts b/packages/exojs-tilemap/src/WangSet.ts new file mode 100644 index 00000000..29ae714d --- /dev/null +++ b/packages/exojs-tilemap/src/WangSet.ts @@ -0,0 +1,80 @@ +/** + * 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'; +} + +/** + * 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; + + 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; + } + } + + /** + * 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); + } + + /** Read-only view of the full bitmask → tile-ID mapping. */ + public get blobMap(): ReadonlyMap { + return this._map; + } +} diff --git a/packages/exojs-tilemap/src/autoTile.ts b/packages/exojs-tilemap/src/autoTile.ts new file mode 100644 index 00000000..8c888b8a --- /dev/null +++ b/packages/exojs-tilemap/src/autoTile.ts @@ -0,0 +1,171 @@ +import type { TileLayer } from './TileLayer'; +import { TILE_TRANSFORM_IDENTITY, unpackTile } from './types'; +import type { WangSet } from './WangSet'; + +/** + * Options for {@link autoTile}. + */ +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, every non-empty cell is eligible for autotiling, and a + * neighbor is considered "in group" when its `tilesetIndex` and + * `localTileId` match those of the cell being evaluated. + */ + 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; +} + +// ── 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. + * + * **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); + } + } + + // ── Neighbor membership test ────────────────────────────────────────── + + function isInGroup(nx: number, ny: number, ctsi: number, ctid: 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 === ctsi && cell.localTileId === ctid; + } + + // ── 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; + + const { tilesetIndex: ctsi, localTileId: ctid } = cellInfo; + + // Skip cells excluded by matchFn (when provided). + if (matchFn && !matchFn(ctid, ctsi, tx, ty)) continue; + + const inGroup = (nx: number, ny: number): boolean => isInGroup(nx, ny, ctsi, ctid); + const mask = wangSet.type === 'edge' + ? computeEdgeMask(tx, ty, inGroup) + : computeBlobMask(tx, ty, inGroup); + + const newLocalTileId = wangSet.getTileId(mask); + if (newLocalTileId === undefined) continue; + + const tileset = layer.tilesets[wangSet.tilesetIndex]; + if (!tileset) continue; + + layer.setTileAt(tx, ty, { + localTileId: newLocalTileId, + tileset, + transform: TILE_TRANSFORM_IDENTITY, + }); + } + } +} diff --git a/packages/exojs-tilemap/src/public.ts b/packages/exojs-tilemap/src/public.ts index d1c8e35d..6cac4311 100644 --- a/packages/exojs-tilemap/src/public.ts +++ b/packages/exojs-tilemap/src/public.ts @@ -59,3 +59,8 @@ export { tileToChunkCoord, tileToLocalInChunk, } from './types'; +// Wang autotiling: automatic tile selection based on neighbor bitmasks. +export type { AutoTileOptions } from './autoTile'; +export { autoTile } from './autoTile'; +export type { WangSetOptions } from './WangSet'; +export { WangSet } from './WangSet'; diff --git a/packages/exojs-tilemap/test/autoTile.test.ts b/packages/exojs-tilemap/test/autoTile.test.ts new file mode 100644 index 00000000..f1439163 --- /dev/null +++ b/packages/exojs-tilemap/test/autoTile.test.ts @@ -0,0 +1,276 @@ +import { TextureRegion } from '@codexo/exojs'; +import { type Texture } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { autoTile } 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); + }); +}); From 177983599ffd16af654996ac00ff4739e5ae9ca2 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:45:55 +0200 Subject: [PATCH 14/68] feat(ldtk): add @codexo/exojs-ldtk package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new @codexo/exojs-ldtk extension package that parses the LDtk level-editor JSON format and converts each level to a runtime TileMap. - LdtkData.ts: TypeScript types for the LDtk JSON format (v1.5.x) - LdtkMap.ts: parsed source model with levels: TileMap[] and getLevelByName() - ldtkToTileMap.ts: pure converter (LdtkData + optional TileSet map → LdtkMap); Tiles/AutoLayer → TileLayer; IntGrid → TileLayer (auto-tiles when present); Entities → ObjectLayer with scalar field properties; no __DEV__ usage - loadLdtkMap.ts: async loader — fetches JSON, loads tileset textures, builds TileSets, then calls ldtkToTileMap - ldtkBinding.ts: AssetBinding claiming the .ldtk extension - ldtkExtension.ts: Extension descriptor depending on tilemapExtension - register.ts: side-effectful auto-registration entry - public.ts / index.ts: barrel + ExtensionTypeMap / AssetDefinitions augmentation - test/ldtkToTileMap.test.ts: 19 unit tests (no loader/texture required) - pnpm-workspace.yaml + vitest.config.ts: register the package --- packages/exojs-ldtk/package.json | 56 ++++ packages/exojs-ldtk/rollup.config.ts | 8 + packages/exojs-ldtk/src/LdtkData.ts | 199 ++++++++++++ packages/exojs-ldtk/src/LdtkMap.ts | 60 ++++ packages/exojs-ldtk/src/index.ts | 5 + packages/exojs-ldtk/src/ldtkBinding.ts | 30 ++ packages/exojs-ldtk/src/ldtkExtension.ts | 27 ++ packages/exojs-ldtk/src/ldtkToTileMap.ts | 280 ++++++++++++++++ packages/exojs-ldtk/src/loadLdtkMap.ts | 101 ++++++ packages/exojs-ldtk/src/public.ts | 88 +++++ packages/exojs-ldtk/src/register.ts | 13 + .../exojs-ldtk/test/ldtkToTileMap.test.ts | 307 ++++++++++++++++++ packages/exojs-ldtk/tsconfig.json | 13 + pnpm-lock.yaml | 13 + pnpm-workspace.yaml | 1 + vitest.config.ts | 6 + 16 files changed, 1207 insertions(+) create mode 100644 packages/exojs-ldtk/package.json create mode 100644 packages/exojs-ldtk/rollup.config.ts create mode 100644 packages/exojs-ldtk/src/LdtkData.ts create mode 100644 packages/exojs-ldtk/src/LdtkMap.ts create mode 100644 packages/exojs-ldtk/src/index.ts create mode 100644 packages/exojs-ldtk/src/ldtkBinding.ts create mode 100644 packages/exojs-ldtk/src/ldtkExtension.ts create mode 100644 packages/exojs-ldtk/src/ldtkToTileMap.ts create mode 100644 packages/exojs-ldtk/src/loadLdtkMap.ts create mode 100644 packages/exojs-ldtk/src/public.ts create mode 100644 packages/exojs-ldtk/src/register.ts create mode 100644 packages/exojs-ldtk/test/ldtkToTileMap.test.ts create mode 100644 packages/exojs-ldtk/tsconfig.json 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..fea75977 --- /dev/null +++ b/packages/exojs-ldtk/src/LdtkData.ts @@ -0,0 +1,199 @@ +/** + * 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/ + */ + +// ── Tile data ───────────────────────────────────────────────────────────────── + +/** Flip-bit constants for {@link LdtkTileData.f}. */ +export const LDTK_FLIP_NONE = 0; +export const LDTK_FLIP_X = 1; +export const LDTK_FLIP_Y = 2; +export const LDTK_FLIP_XY = 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..90cf6ae6 --- /dev/null +++ b/packages/exojs-ldtk/src/ldtkToTileMap.ts @@ -0,0 +1,280 @@ +import type { TileMapObject, TileProperties, TilePropertyValue } from '@codexo/exojs-tilemap'; +import { ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; + +import type { + LdtkData, + LdtkEntityInstance, + LdtkFieldInstance, + LdtkLayerInstance, + LdtkLevel, + LdtkTileData, +} from './LdtkData'; +import { LDTK_FLIP_X, LDTK_FLIP_Y } 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 ────────────────────────────────────────────────────────── + +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 & LDTK_FLIP_X) !== 0, + flipY: (f & LDTK_FLIP_Y) !== 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..789ffa83 --- /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 { 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); + + 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..66edab20 --- /dev/null +++ b/packages/exojs-ldtk/src/public.ts @@ -0,0 +1,88 @@ +// Side-effect-free public API for @codexo/exojs-ldtk. +// No registration is performed on import. + +// ── Extension wiring ────────────────────────────────────────────────────────── +export { ldtkExtension } from './ldtkExtension'; +export { ldtkMapBinding } from './ldtkBinding'; + +// ── 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 { + LDTK_FLIP_NONE, + LDTK_FLIP_X, + LDTK_FLIP_XY, + LDTK_FLIP_Y, +} 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/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/tsconfig.json b/packages/exojs-ldtk/tsconfig.json new file mode 100644 index 00000000..27525808 --- /dev/null +++ b/packages/exojs-ldtk/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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-tilemap": ["../exojs-tilemap/src/index.ts"] + } + }, + "include": ["src/**/*", "../../src/typings.d.ts"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e4afcea..ba562317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,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': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20a6ee99..3f2a16fe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/exojs-particles - packages/exojs-tilemap - packages/exojs-tiled + - packages/exojs-ldtk - packages/exojs-physics - packages/exojs-audio-fx - packages/create-exo-app diff --git a/vitest.config.ts b/vitest.config.ts index c30398fc..ef151119 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ 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-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 +101,11 @@ export default defineConfig({ alias: aliasConfig, include: ['packages/exojs-tiled/test/**/*.test.ts'], }), + createJsdomTestProject({ + name: 'exojs-ldtk', + alias: aliasConfig, + include: ['packages/exojs-ldtk/test/**/*.test.ts'], + }), createJsdomTestProject({ name: 'exojs-physics', alias: aliasConfig, From f87a7e89e87f00c83e949b0519e347229a44e713 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:47:20 +0200 Subject: [PATCH 15/68] feat(aseprite): add @codexo/exojs-aseprite extension package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package that integrates Aseprite JSON sprite sheet exports with the ExoJS AnimatedSprite system. Supports both array-mode and hash-mode Aseprite JSON exports, derives animation clips from frameTags with per-frame fps averaging, and exposes a simple one-liner API: const sheet = await loader.load(AsepriteSheet, 'hero.aseprite.json'); const sprite = sheet.createAnimatedSprite(); sprite.play('run'); Public API: - AsepriteSheet.parse(data, texture) — static factory - AsepriteSheet.createAnimatedSprite() — returns AnimatedSprite with all clips - AsepriteSheet.clips — ReadonlyMap - AsepriteSheet.spritesheet — underlying Spritesheet - AsepriteFormatError — thrown on invalid Aseprite JSON - isAsepriteArrayData() — discriminator for array/hash mode - asepriteExtension / asepriteBinding — for manual extension registration - /register entry for global auto-registration Note: pnpm-workspace.yaml registration left to a follow-up commit (per task constraints: shared config files not modified here). --- packages/exojs-aseprite/package.json | 53 +++++++ packages/exojs-aseprite/rollup.config.ts | 8 + packages/exojs-aseprite/src/AsepriteData.ts | 104 ++++++++++++ packages/exojs-aseprite/src/AsepriteSheet.ts | 150 ++++++++++++++++++ .../exojs-aseprite/src/asepriteBinding.ts | 125 +++++++++++++++ .../exojs-aseprite/src/asepriteExtension.ts | 21 +++ packages/exojs-aseprite/src/index.ts | 5 + packages/exojs-aseprite/src/public.ts | 33 ++++ packages/exojs-aseprite/src/register.ts | 13 ++ packages/exojs-aseprite/tsconfig.json | 12 ++ 10 files changed, 524 insertions(+) create mode 100644 packages/exojs-aseprite/package.json create mode 100644 packages/exojs-aseprite/rollup.config.ts create mode 100644 packages/exojs-aseprite/src/AsepriteData.ts create mode 100644 packages/exojs-aseprite/src/AsepriteSheet.ts create mode 100644 packages/exojs-aseprite/src/asepriteBinding.ts create mode 100644 packages/exojs-aseprite/src/asepriteExtension.ts create mode 100644 packages/exojs-aseprite/src/index.ts create mode 100644 packages/exojs-aseprite/src/public.ts create mode 100644 packages/exojs-aseprite/src/register.ts create mode 100644 packages/exojs-aseprite/tsconfig.json 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..28bd77c9 --- /dev/null +++ b/packages/exojs-aseprite/src/AsepriteSheet.ts @@ -0,0 +1,150 @@ +import { AnimatedSprite, type AnimatedSpriteClipDefinition, Rectangle, Spritesheet, type Texture } from '@codexo/exojs'; + +import { isAsepriteArrayData, type AsepriteData, type AsepriteFrameData } 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..d62b2634 --- /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 ABSOLUTE_REF = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/)/i; + +/** Matches a base that has an explicit scheme (absolute URL). */ +const ABSOLUTE_BASE = /^[a-z][a-z\d+.-]*:/i; + +/** Synthetic origin used to borrow `URL`'s `../`/`./` collapsing. */ +const SYNTHETIC_ORIGIN = '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 (ABSOLUTE_REF.test(ref)) { + return ref; + } + + if (ABSOLUTE_BASE.test(base)) { + return new URL(ref, base).href; + } + + const resolved = new URL(ref, SYNTHETIC_ORIGIN + base.replace(/^\/+/, '')); + + return resolved.href.slice(SYNTHETIC_ORIGIN.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..e7e37fe9 --- /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 type { + AsepriteArrayData, + AsepriteData, + AsepriteDirection, + AsepriteFrameData, + AsepriteFrameTag, + AsepriteHashData, + AsepriteLayer, + AsepriteMeta, + AsepriteRect, + AsepriteSize, + AsepriteSlice, + AsepriteSliceKey, +} from './AsepriteData'; +export { isAsepriteArrayData } from './AsepriteData'; +export { AsepriteSheet } from './AsepriteSheet'; +export { AsepriteFormatError, asepriteBinding } from './asepriteBinding'; +export { asepriteExtension } from './asepriteExtension'; + +// ── 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/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"] +} From 12ac94c1254a37631714b9bbf0fb19d92977ce83 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:47:45 +0200 Subject: [PATCH 16/68] feat(react): add @codexo/exojs-react package with ExoCanvas, useExoApp, useScene Provides React integration for the ExoJS game engine: - ExoCanvas: mounts canvas, creates/destroys Application, provides context - useExoApp: returns the Application from the nearest ExoCanvas ancestor - useScene: starts the engine on first mount, switches scenes on re-renders - ExoContext / useExoContext exported for advanced provider use Package ships TypeScript source (no Rollup build step); consumers supply the bundler. --- packages/exojs-react/package.json | 46 ++++++++++++++ packages/exojs-react/src/ExoCanvas.tsx | 83 ++++++++++++++++++++++++++ packages/exojs-react/src/ExoContext.ts | 23 +++++++ packages/exojs-react/src/index.ts | 5 ++ packages/exojs-react/src/useExoApp.ts | 28 +++++++++ packages/exojs-react/src/useScene.ts | 71 ++++++++++++++++++++++ packages/exojs-react/tsconfig.json | 12 ++++ 7 files changed, 268 insertions(+) create mode 100644 packages/exojs-react/package.json create mode 100644 packages/exojs-react/src/ExoCanvas.tsx create mode 100644 packages/exojs-react/src/ExoContext.ts create mode 100644 packages/exojs-react/src/index.ts create mode 100644 packages/exojs-react/src/useExoApp.ts create mode 100644 packages/exojs-react/src/useScene.ts create mode 100644 packages/exojs-react/tsconfig.json diff --git a/packages/exojs-react/package.json b/packages/exojs-react/package.json new file mode 100644 index 00000000..87250a0b --- /dev/null +++ b/packages/exojs-react/package.json @@ -0,0 +1,46 @@ +{ + "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", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "files": ["src/", "README.md", "LICENSE"], + "scripts": { + "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/src/ExoCanvas.tsx b/packages/exojs-react/src/ExoCanvas.tsx new file mode 100644 index 00000000..92e74e90 --- /dev/null +++ b/packages/exojs-react/src/ExoCanvas.tsx @@ -0,0 +1,83 @@ +import { type CSSProperties, type HTMLAttributes, type ReactElement, useEffect, useRef, useState } from 'react'; + +import { Application, type ApplicationOptions } from '@codexo/exojs'; + +import { ExoContext } from './ExoContext'; + +export interface ExoCanvasProps extends HTMLAttributes { + /** + * Options forwarded to the {@link Application} constructor, excluding + * `canvas` (which is managed by this component). Use the wrapping `
` + * props (e.g. `style`, `className`) to control layout; use + * `options.canvas.sizingMode` alternatives via CSS on the container. + */ + options?: Omit; + /** + * Called once after the {@link Application} instance is created and made + * available in the React tree. Note that the backend (WebGL2 / WebGPU) is + * not yet initialized at this point — backend initialization happens when + * the first {@link useScene} child calls `app.start()`. Subscribe to + * `app.onFrame` or check `app.status` to detect when the engine is running. + */ + onReady?: (app: Application) => void; +} + +/** + * Mounts a `` inside a container `
`, creates an ExoJS + * {@link Application}, and provides it to all descendant hooks via React + * context. Children are rendered only after the Application is ready. + * + * The Application is destroyed (and the canvas removed) when this component + * unmounts. Scene lifecycle is managed separately by {@link useScene}. + * + * @example + * ```tsx + * function App() { + * return ( + * console.log(app)}> + * + * + * ); + * } + * ``` + */ +export function ExoCanvas({ options, onReady, children, style, ...divProps }: ExoCanvasProps): ReactElement { + const containerRef = useRef(null); + const [app, setApp] = useState(null); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const canvas = document.createElement('canvas'); + container.appendChild(canvas); + + const application = new Application({ canvas: { element: canvas }, ...options }); + + setApp(application); + onReady?.(application); + + return () => { + application.destroy(); + canvas.remove(); + setApp(null); + }; + // options and onReady are intentionally captured only at mount time. + // Changing them after mount has no effect — destroy and remount ExoCanvas + // to apply new options. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const containerStyle: CSSProperties = { position: 'relative', ...style }; + + 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..e992e92e --- /dev/null +++ b/packages/exojs-react/src/ExoContext.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react'; + +import type { Application } from '@codexo/exojs'; + +/** + * 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/index.ts b/packages/exojs-react/src/index.ts new file mode 100644 index 00000000..afa9270a --- /dev/null +++ b/packages/exojs-react/src/index.ts @@ -0,0 +1,5 @@ +export type { ExoCanvasProps } from './ExoCanvas'; +export { ExoCanvas } from './ExoCanvas'; +export { ExoContext, useExoContext } from './ExoContext'; +export { useExoApp } from './useExoApp'; +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/useScene.ts b/packages/exojs-react/src/useScene.ts new file mode 100644 index 00000000..de0802f4 --- /dev/null +++ b/packages/exojs-react/src/useScene.ts @@ -0,0 +1,71 @@ +import { type DependencyList, useEffect, useState } from 'react'; + +import { ApplicationStatus, type Scene } from '@codexo/exojs'; + +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 ; + * } + * ``` + */ +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. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app, ...deps]); + + return scene; +} 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"] +} From bb19e3a30e57c718063e2e870308a65c4c5b8de4 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:57:32 +0200 Subject: [PATCH 17/68] chore: register aseprite/ldtk/react packages in workspace + fix lint errors across new packages --- package.json | 2 +- packages/exojs-aseprite/src/AsepriteSheet.ts | 4 +- .../exojs-aseprite/src/asepriteBinding.ts | 22 +++++----- packages/exojs-aseprite/src/public.ts | 4 +- packages/exojs-ldtk/src/LdtkData.ts | 10 +++-- packages/exojs-ldtk/src/ldtkToTileMap.ts | 11 ++--- packages/exojs-ldtk/src/loadLdtkMap.ts | 4 +- packages/exojs-ldtk/src/public.ts | 2 +- packages/exojs-react/src/ExoContext.ts | 3 +- packages/exojs-react/src/useScene.ts | 5 +-- pnpm-lock.yaml | 41 +++++++++++++++++++ pnpm-workspace.yaml | 2 + vitest.config.ts | 6 +++ 13 files changed, 83 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index ba261cc9..d089ad07 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "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}\"", diff --git a/packages/exojs-aseprite/src/AsepriteSheet.ts b/packages/exojs-aseprite/src/AsepriteSheet.ts index 28bd77c9..abf8f38e 100644 --- a/packages/exojs-aseprite/src/AsepriteSheet.ts +++ b/packages/exojs-aseprite/src/AsepriteSheet.ts @@ -1,6 +1,6 @@ -import { AnimatedSprite, type AnimatedSpriteClipDefinition, Rectangle, Spritesheet, type Texture } from '@codexo/exojs'; +import { AnimatedSprite, type AnimatedSpriteClipDefinition, type Rectangle, Spritesheet, type Texture } from '@codexo/exojs'; -import { isAsepriteArrayData, type AsepriteData, type AsepriteFrameData } from './AsepriteData'; +import { type AsepriteData, type AsepriteFrameData,isAsepriteArrayData } from './AsepriteData'; /** * Normalises an {@link AsepriteData} document into an ordered array of diff --git a/packages/exojs-aseprite/src/asepriteBinding.ts b/packages/exojs-aseprite/src/asepriteBinding.ts index d62b2634..66532cd1 100644 --- a/packages/exojs-aseprite/src/asepriteBinding.ts +++ b/packages/exojs-aseprite/src/asepriteBinding.ts @@ -12,13 +12,13 @@ import { AsepriteSheet } from './AsepriteSheet'; // ── URL resolution ─────────────────────────────────────────────────────────── /** Matches references that are already absolute: scheme, `//`, `/`, data/blob. */ -const ABSOLUTE_REF = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/)/i; +const absoluteRefPattern = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/)/i; /** Matches a base that has an explicit scheme (absolute URL). */ -const ABSOLUTE_BASE = /^[a-z][a-z\d+.-]*:/i; +const absoluteBasePattern = /^[a-z][a-z\d+.-]*:/i; /** Synthetic origin used to borrow `URL`'s `../`/`./` collapsing. */ -const SYNTHETIC_ORIGIN = 'https://exojs.invalid/'; +const syntheticOrigin = 'https://exojs.invalid/'; /** * Resolves `ref` (the image path read from an Aseprite JSON file) relative to @@ -30,17 +30,17 @@ const SYNTHETIC_ORIGIN = 'https://exojs.invalid/'; * then strips the origin from the result. */ function resolveAsepriteUrl(ref: string, base: string): string { - if (ABSOLUTE_REF.test(ref)) { + if (absoluteRefPattern.test(ref)) { return ref; } - if (ABSOLUTE_BASE.test(base)) { + if (absoluteBasePattern.test(base)) { return new URL(ref, base).href; } - const resolved = new URL(ref, SYNTHETIC_ORIGIN + base.replace(/^\/+/, '')); + const resolved = new URL(ref, syntheticOrigin + base.replace(/^\/+/, '')); - return resolved.href.slice(SYNTHETIC_ORIGIN.length); + return resolved.href.slice(syntheticOrigin.length); } // ── Validation ─────────────────────────────────────────────────────────────── @@ -75,17 +75,17 @@ function validateAsepriteData(raw: unknown, source: string): AsepriteData { throw new AsepriteFormatError(source, 'missing required field "frames"'); } - if (!('meta' in doc) || typeof doc['meta'] !== 'object' || doc['meta'] === null) { + 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; + const meta = doc.meta as Record; - if (typeof meta['image'] !== 'string' || meta['image'].length === 0) { + 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']; + 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'); diff --git a/packages/exojs-aseprite/src/public.ts b/packages/exojs-aseprite/src/public.ts index e7e37fe9..194b3ddf 100644 --- a/packages/exojs-aseprite/src/public.ts +++ b/packages/exojs-aseprite/src/public.ts @@ -1,6 +1,7 @@ // Side-effect-free public API for @codexo/exojs-aseprite. // No registration is performed on import. +export { asepriteBinding,AsepriteFormatError } from './asepriteBinding'; export type { AsepriteArrayData, AsepriteData, @@ -16,9 +17,8 @@ export type { AsepriteSliceKey, } from './AsepriteData'; export { isAsepriteArrayData } from './AsepriteData'; -export { AsepriteSheet } from './AsepriteSheet'; -export { AsepriteFormatError, asepriteBinding } from './asepriteBinding'; export { asepriteExtension } from './asepriteExtension'; +export { AsepriteSheet } from './AsepriteSheet'; // ── Module augmentation — typed load calls ──────────────────────────────────── import type { AsepriteSheet } from './AsepriteSheet'; diff --git a/packages/exojs-ldtk/src/LdtkData.ts b/packages/exojs-ldtk/src/LdtkData.ts index fea75977..dbafc0ca 100644 --- a/packages/exojs-ldtk/src/LdtkData.ts +++ b/packages/exojs-ldtk/src/LdtkData.ts @@ -8,13 +8,15 @@ * @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 LDTK_FLIP_NONE = 0; -export const LDTK_FLIP_X = 1; -export const LDTK_FLIP_Y = 2; -export const LDTK_FLIP_XY = 3; +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 { diff --git a/packages/exojs-ldtk/src/ldtkToTileMap.ts b/packages/exojs-ldtk/src/ldtkToTileMap.ts index 90cf6ae6..c5353031 100644 --- a/packages/exojs-ldtk/src/ldtkToTileMap.ts +++ b/packages/exojs-ldtk/src/ldtkToTileMap.ts @@ -1,5 +1,5 @@ -import type { TileMapObject, TileProperties, TilePropertyValue } from '@codexo/exojs-tilemap'; -import { ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; +import type { TileMapObject, TileProperties, TilePropertyValue, TileSet } from '@codexo/exojs-tilemap'; +import { ObjectLayer, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap } from '@codexo/exojs-tilemap'; import type { LdtkData, @@ -9,7 +9,7 @@ import type { LdtkLevel, LdtkTileData, } from './LdtkData'; -import { LDTK_FLIP_X, LDTK_FLIP_Y } from './LdtkData'; +import { ldtkFlipX, ldtkFlipY } from './LdtkData'; import { LdtkMap } from './LdtkMap'; // ── Public API ──────────────────────────────────────────────────────────────── @@ -62,6 +62,7 @@ export function ldtkToTileMap(data: LdtkData, options?: LdtkToTileMapOptions): L // ── Level conversion ────────────────────────────────────────────────────────── +// eslint-disable-next-line complexity function convertLevel( level: LdtkLevel, levelIndex: number, @@ -201,8 +202,8 @@ function populateTileLayer( tileset, localTileId, transform: { - flipX: (f & LDTK_FLIP_X) !== 0, - flipY: (f & LDTK_FLIP_Y) !== 0, + flipX: (f & ldtkFlipX) !== 0, + flipY: (f & ldtkFlipY) !== 0, diagonal: false, }, }); diff --git a/packages/exojs-ldtk/src/loadLdtkMap.ts b/packages/exojs-ldtk/src/loadLdtkMap.ts index 789ffa83..5953124e 100644 --- a/packages/exojs-ldtk/src/loadLdtkMap.ts +++ b/packages/exojs-ldtk/src/loadLdtkMap.ts @@ -2,7 +2,7 @@ import { type AssetLoaderContext, Texture, TextureRegion } from '@codexo/exojs'; import { TileSet } from '@codexo/exojs-tilemap'; import type { LdtkData, LdtkTilesetDef } from './LdtkData'; -import { LdtkMap } from './LdtkMap'; +import type { LdtkMap } from './LdtkMap'; import { ldtkToTileMap } from './ldtkToTileMap'; // ── URL resolution ──────────────────────────────────────────────────────────── @@ -30,7 +30,7 @@ async function loadLdtkTileset( if (!def.relPath) return null; const imageUrl = resolveLdtkUrl(def.relPath, ldtkSource); - const texture = await context.loader.load(Texture, imageUrl); + const texture = (await context.loader.load(Texture, imageUrl)) as Texture; const tileSize = def.tileGridSize; const spacing = def.spacing ?? 0; diff --git a/packages/exojs-ldtk/src/public.ts b/packages/exojs-ldtk/src/public.ts index 66edab20..34b6072a 100644 --- a/packages/exojs-ldtk/src/public.ts +++ b/packages/exojs-ldtk/src/public.ts @@ -2,8 +2,8 @@ // No registration is performed on import. // ── Extension wiring ────────────────────────────────────────────────────────── -export { ldtkExtension } from './ldtkExtension'; export { ldtkMapBinding } from './ldtkBinding'; +export { ldtkExtension } from './ldtkExtension'; // ── Parsed source model ─────────────────────────────────────────────────────── export { LdtkMap } from './LdtkMap'; diff --git a/packages/exojs-react/src/ExoContext.ts b/packages/exojs-react/src/ExoContext.ts index e992e92e..e2df9680 100644 --- a/packages/exojs-react/src/ExoContext.ts +++ b/packages/exojs-react/src/ExoContext.ts @@ -1,6 +1,5 @@ -import { createContext, useContext } from 'react'; - import type { Application } from '@codexo/exojs'; +import { createContext, useContext } from 'react'; /** * Internal React context that carries the active {@link Application} instance diff --git a/packages/exojs-react/src/useScene.ts b/packages/exojs-react/src/useScene.ts index de0802f4..9d96f353 100644 --- a/packages/exojs-react/src/useScene.ts +++ b/packages/exojs-react/src/useScene.ts @@ -1,6 +1,5 @@ -import { type DependencyList, useEffect, useState } from 'react'; - import { ApplicationStatus, type Scene } from '@codexo/exojs'; +import { type DependencyList, useEffect, useState } from 'react'; import { useExoApp } from './useExoApp'; @@ -30,6 +29,7 @@ import { useExoApp } from './useExoApp'; * } * ``` */ +// 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); @@ -64,7 +64,6 @@ export function useScene(SceneClass: new () => T, deps: Depende // 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. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [app, ...deps]); return scene; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959b98ba..48acf43b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,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': @@ -193,6 +202,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': @@ -1739,11 +1767,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==} @@ -5849,10 +5883,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f2a16fe..8d3037d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,8 +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/vitest.config.ts b/vitest.config.ts index ef151119..4f736bab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ 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; @@ -101,6 +102,11 @@ 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, From 8fadcf598e2c713392f2c01340431140635833ef Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 18:58:19 +0200 Subject: [PATCH 18/68] fix(ldtk): update public.ts exports to renamed flip constants --- packages/exojs-ldtk/src/public.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/exojs-ldtk/src/public.ts b/packages/exojs-ldtk/src/public.ts index 34b6072a..30ec8a8a 100644 --- a/packages/exojs-ldtk/src/public.ts +++ b/packages/exojs-ldtk/src/public.ts @@ -24,12 +24,7 @@ export type { LdtkTileData, LdtkTilesetDef, } from './LdtkData'; -export { - LDTK_FLIP_NONE, - LDTK_FLIP_X, - LDTK_FLIP_XY, - LDTK_FLIP_Y, -} 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 From b909965d403e227398e42963b750c9a5caa1afaa Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 19:00:27 +0200 Subject: [PATCH 19/68] docs: regenerate API pages for Loader signals, MeshMaterial.from, SpriteMaterial.from, WangSet --- site/src/content/api/loader.mdx | 6 ++- site/src/content/api/mesh-material.mdx | 8 ++-- site/src/content/api/sprite-material.mdx | 8 ++-- site/src/content/api/wang-set.mdx | 56 ++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 site/src/content/api/wang-set.mdx diff --git a/site/src/content/api/loader.mdx b/site/src/content/api/loader.mdx index f509d84b..d1bd1cb2 100644 --- a/site/src/content/api/loader.mdx +++ b/site/src/content/api/loader.mdx @@ -5,7 +5,7 @@ symbol: "Loader" kind: "class" subsystem: "resources" importPath: "@codexo/exojs" -memberCount: 29 +memberCount: 33 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/resources/Loader.ts" @@ -89,7 +89,11 @@ subsequent `load` or get calls without re-fetching. - `onBundleProgress: Signal<[name, loaded, total]>` - `onError: Signal<[type, alias, error]>` +- `onLoadComplete: Signal<[]>` - `onLoaded: Signal<[type, alias, resource]>` +- `onLoadError: Signal<[key, error]>` +- `onLoadProgress: Signal<[loaded, total, key]>` +- `onLoadStart: Signal<[key, url]>` - `onProgress: Signal<[loaded, total]>` ## Source diff --git a/site/src/content/api/mesh-material.mdx b/site/src/content/api/mesh-material.mdx index 0a7242e0..e8cc93ee 100644 --- a/site/src/content/api/mesh-material.mdx +++ b/site/src/content/api/mesh-material.mdx @@ -5,11 +5,11 @@ symbol: "MeshMaterial" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 12 +memberCount: 13 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/rendering/material/MeshMaterial.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/MeshMaterial.ts#L14" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/MeshMaterial.ts#L18" --- ## Import @@ -32,6 +32,8 @@ Renderer wiring is added in a later phase. - `destroy(): void` - `setTexture(name: string, texture: Texture | RenderTexture): this` - `setUniform(name: string, value: UniformValue): this` +- `from(source: ShaderSource, options?: Omit): MeshMaterial` +- `from(glslVertex: string, glslFragment: string, options?: object): MeshMaterial` ## Properties @@ -46,4 +48,4 @@ Renderer wiring is added in a later phase. ## Source -[src/rendering/material/MeshMaterial.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/MeshMaterial.ts#L14) +[src/rendering/material/MeshMaterial.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/MeshMaterial.ts#L18) diff --git a/site/src/content/api/sprite-material.mdx b/site/src/content/api/sprite-material.mdx index 7a18553f..96044247 100644 --- a/site/src/content/api/sprite-material.mdx +++ b/site/src/content/api/sprite-material.mdx @@ -5,11 +5,11 @@ symbol: "SpriteMaterial" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 12 +memberCount: 13 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/rendering/material/SpriteMaterial.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/SpriteMaterial.ts#L13" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/SpriteMaterial.ts#L17" --- ## Import @@ -31,6 +31,8 @@ uniforms, and additional texture bindings. - `destroy(): void` - `setTexture(name: string, texture: Texture | RenderTexture): this` - `setUniform(name: string, value: UniformValue): this` +- `from(source: ShaderSource, options?: Omit): SpriteMaterial` +- `from(glslVertex: string, glslFragment: string, options?: object): SpriteMaterial` ## Properties @@ -45,4 +47,4 @@ uniforms, and additional texture bindings. ## Source -[src/rendering/material/SpriteMaterial.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/SpriteMaterial.ts#L13) +[src/rendering/material/SpriteMaterial.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/material/SpriteMaterial.ts#L17) diff --git a/site/src/content/api/wang-set.mdx b/site/src/content/api/wang-set.mdx new file mode 100644 index 00000000..d73166e6 --- /dev/null +++ b/site/src/content/api/wang-set.mdx @@ -0,0 +1,56 @@ +--- +title: "WangSet" +description: "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" +symbol: "WangSet" +kind: "class" +subsystem: "tilemap" +importPath: "@codexo/exojs-tilemap" +memberCount: 5 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sourcePath: "packages/exojs-tilemap/src/WangSet.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/WangSet.ts#L44" +--- +## Import + +`import { WangSet } from '@codexo/exojs-tilemap'` + +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 + +## Constructors + +- `new(options: WangSetOptions): WangSet` + +## Methods + +- `getTileId(mask: number): number | undefined` + +## Properties + +- `tilesetIndex: number` +- `type: "blob" | "edge"` +- `blobMap: ReadonlyMap` + +## Source + +[packages/exojs-tilemap/src/WangSet.ts](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/WangSet.ts#L44) From a2897be3362432299f5afe50736141ec305325be Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 19:21:26 +0200 Subject: [PATCH 20/68] fix(build): rename IIFE global from ExoJS to Exo --- rollup.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rollup.config.ts b/rollup.config.ts index bcd21e5c..79e36381 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -63,7 +63,7 @@ const iife: RollupOptions = { output: { file: 'dist/exo.iife.js', format: 'iife', - name: 'ExoJS', + name: 'Exo', sourcemap: true, }, plugins: [ @@ -83,7 +83,7 @@ const iifeMin: RollupOptions = { output: { file: 'dist/exo.iife.min.js', format: 'iife', - name: 'ExoJS', + name: 'Exo', sourcemap: true, }, plugins: [ From 3619f5aa244d96e370f4e5574d5be3a24d9964f7 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 26 Jun 2026 19:30:50 +0200 Subject: [PATCH 21/68] feat(build): add full IIFE bundle (exo.full.iife.js) with all extension packages --- .size-limit.cjs | 5 +++ rollup.config.ts | 71 +++++++++++++++++++++++++++++++++++++-- scripts/exo-full.entry.ts | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 scripts/exo-full.entry.ts diff --git a/.size-limit.cjs b/.size-limit.cjs index 3444d55f..1af5ccd7 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -9,4 +9,9 @@ module.exports = [ limit: '250 KB', gzip: true, }, + { + path: 'dist/exo.full.iife.min.js', + limit: '2 MB', + gzip: true, + }, ]; diff --git a/rollup.config.ts b/rollup.config.ts index 79e36381..8e7bbe6f 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,28 @@ 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'], }); @@ -147,6 +169,49 @@ const modules: RollupOptions = { ], }; -const productionOnlyConfigs = buildMode === 'production' ? [iifeMin] : []; +// 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, + }), + ], +}; + +// 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, + }), + terser({ compress: { pure_funcs: ['assert', 'assertDefined', 'invariant', 'warnOnce'] } }), + ], +}; + +const productionOnlyConfigs = buildMode === 'production' ? [iifeMin, iifeFullMin] : []; -export default [bundled, debugBundled, modules, iife, ...productionOnlyConfigs]; +export default [bundled, debugBundled, modules, iife, iifeFull, ...productionOnlyConfigs]; diff --git a/scripts/exo-full.entry.ts b/scripts/exo-full.entry.ts new file mode 100644 index 00000000..693e3bdc --- /dev/null +++ b/scripts/exo-full.entry.ts @@ -0,0 +1,60 @@ +// Full-bundle entry — bundles core + all extension packages into a single IIFE. +// Global name: Exo (i.e. window.Exo after a From 0976e820b1927fc999b29ea7b334de3020d7abed Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:11:45 +0200 Subject: [PATCH 57/68] fix(site): type the Monaco onChange value param (clears ts7006) (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onChange={value => ...}` left `value` implicitly `any` — the only error `astro check` reported on the site. Annotate it `string | undefined` (the @monaco-editor/react OnChange signature). No behaviour change; `astro check` is now clean. Co-authored-by: Exoridus --- site/src/components/EditorCode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/EditorCode.tsx b/site/src/components/EditorCode.tsx index 13511db4..43722a5d 100644 --- a/site/src/components/EditorCode.tsx +++ b/site/src/components/EditorCode.tsx @@ -349,7 +349,7 @@ export const EditorCode = forwardRef(function height="100%" language={language} loading={null} - onChange={value => setEditorValue(value ?? '')} + onChange={(value: string | undefined) => setEditorValue(value ?? '')} onMount={onMount} options={{ automaticLayout: true, From 66a1e323acc5d2e13a1c78b7f595af319b40c567 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:35:16 +0200 Subject: [PATCH 58/68] feat(site): land the physics, aseprite, ldtk, and React guides (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands five package guides into the guide IA, each with API-verified code blocks: - assets/aseprite, assets/ldtk — new chapters in the Assets part - physics/physics-basics, physics/joints-and-dynamics — new Physics part - integrations/react — new Integrations part guide-structure.ts gains the two new parts and the two Assets chapters with levels, learning goals, prerequisites, and API links (all resolving to existing API pages). tsconfig.guides.json gains source paths for @codexo/exojs-{physics, aseprite,ldtk} (+/register for the two extensions) so the extracted snippets type-check against engine source. Verified: typecheck:guides green (82 snippets), guide-structure reconciliation test 19/19, and the new pages render (HTTP 200, sidebar shows Physics + Integrations, no console errors). Co-authored-by: Exoridus --- site/src/content/guide/assets/aseprite.mdx | 170 +++++++++++ site/src/content/guide/assets/ldtk.mdx | 169 +++++++++++ site/src/content/guide/integrations/react.mdx | 249 +++++++++++++++++ .../guide/physics/joints-and-dynamics.mdx | 263 ++++++++++++++++++ .../content/guide/physics/physics-basics.mdx | 257 +++++++++++++++++ site/src/lib/guide-structure.ts | 69 +++++ tsconfig.guides.json | 7 +- 7 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 site/src/content/guide/assets/aseprite.mdx create mode 100644 site/src/content/guide/assets/ldtk.mdx create mode 100644 site/src/content/guide/integrations/react.mdx create mode 100644 site/src/content/guide/physics/joints-and-dynamics.mdx create mode 100644 site/src/content/guide/physics/physics-basics.mdx diff --git a/site/src/content/guide/assets/aseprite.mdx b/site/src/content/guide/assets/aseprite.mdx new file mode 100644 index 00000000..303f7298 --- /dev/null +++ b/site/src/content/guide/assets/aseprite.mdx @@ -0,0 +1,170 @@ +--- +title: 'Aseprite sprite sheets' +description: 'Load Aseprite JSON sprite sheet exports as AsepriteSheet assets through the official @codexo/exojs-aseprite extension.' +--- + +# Aseprite sprite sheets + +[Aseprite](https://www.aseprite.org/) is a popular pixel-art and animation editor. The official +`@codexo/exojs-aseprite` extension adds an `asepriteSheet` asset type that loads Aseprite's JSON +sheet export through the normal [`Loader`](/ExoJS/en/api/loader/) pipeline — fetching the JSON, +resolving and loading the packed image as a `Texture`, and returning an +[`AsepriteSheet`](/ExoJS/en/api/aseprite-sheet/) with a frame `Spritesheet` and one playable clip +per Aseprite tag. + +> **Note:** `AsepriteSheet` ships as an official ExoJS extension package, separate from the core. +> Install `@codexo/exojs-aseprite` alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-aseprite +> ``` + +Export your sprite from Aseprite as a JSON sheet (**File → Export Sprite Sheet**, with *Output → JSON +Data* enabled). Either *Array* or *Hash* frame layout works, and tags become animation clips. + +Like all extensions, the core ships nothing Aseprite-specific — an `Application` only understands +the `asepriteSheet` type once you activate the extension. There are two ways to do that. + +## Activation + +### Explicit (recommended) + +Pass `asepriteExtension` to `ApplicationOptions.extensions`. This is explicit, tree-shakeable, and +order-independent — the extension is bound to exactly this `Application`: + +```js +import { Application } from '@codexo/exojs'; +import { asepriteExtension } from '@codexo/exojs-aseprite'; + +const app = new Application({ extensions: [asepriteExtension] }); +``` + +The package root (`@codexo/exojs-aseprite`) is side-effect-free: importing it registers nothing +globally. You decide which `Application` gets the extension. + +### `/register` convenience + +For an app that always wants Aseprite support, import the `/register` entry once at startup. It +registers `asepriteExtension` in the global `ExtensionRegistry` and re-exports the same public API: + +```js +import '@codexo/exojs-aseprite/register'; +import { Application } from '@codexo/exojs'; + +const app = new Application(); // picks up asepriteExtension automatically +``` + +The `/register` import must run **before** you construct the `Application` whose extensions are +read from the global registry. The explicit `extensions: [...]` form works regardless of import +order. + +### Core-only + +An `Application` constructed with an explicit empty list takes **no** extensions — not even +globally registered ones — and will not recognise the `asepriteSheet` type: + +```js +import { Application } from '@codexo/exojs'; + +const app = new Application({ extensions: [] }); // core only; no AsepriteSheet +``` + +This is the same add-only model the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) and +[Particles](/ExoJS/en/guide/effects/particles/) extensions use. + +## Loading a sheet + +Once the extension is active, load the JSON export by the `AsepriteSheet` type token or by the +`'asepriteSheet'` config-map type name: + +```ts no-check +import { AsepriteSheet } from '@codexo/exojs-aseprite'; + +// By type token (most explicit) +const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); + +// By config-map type name — the public `'asepriteSheet'` lookup +const fromConfig = await loader.load({ + hero: { type: 'asepriteSheet', source: 'sprites/hero.json' }, +}); +``` + +Unlike the Tiled adapter, the Aseprite binding does **not** claim a file extension — a bare +`loader.load('sprites/hero.json')` will not route here. Always name the `AsepriteSheet` token or the +`'asepriteSheet'` type. The handler fetches the JSON, validates it (throwing `AsepriteFormatError` +on a malformed document), resolves `meta.image` relative to the JSON source, sub-loads it as a +`Texture` through the same loader, and returns a fully-parsed `AsepriteSheet`. + +## Playing tag animations + +`AsepriteSheet.createAnimatedSprite()` returns an +[`AnimatedSprite`](/ExoJS/en/api/animated-sprite/) with every Aseprite tag pre-defined as a named, +playable clip. Call `play(tag)` with a tag name to start it: + +```js +async load(loader) { + this.sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); +} + +init() { + this.player = this.sheet.createAnimatedSprite(); + this.player.play('walk'); + this.root.addChild(this.player); +} + +update(delta) { + this.player.update(delta); // advance the active clip +} +``` + +Like any `AnimatedSprite`, you must call `player.update(delta)` each frame to advance playback — see +the [Animation](/ExoJS/en/guide/rendering/animation/) guide for the full clip/playback API. + +The parsed clips are also exposed directly on `sheet.clips` — a read-only `Map` keyed by tag name — +so you can inspect what was imported or build a sprite by hand: + +```js no-check +sheet.clips.size; // number of tags +sheet.clips.has('walk'); // true when the 'walk' tag exists +sheet.clips.keys(); // iterate tag names + +sheet.spritesheet; // the underlying Spritesheet (frames keyed by index string) +``` + +## Frame timing (fps) + +Each clip's `fps` is derived from the per-frame `duration` values Aseprite exports (milliseconds per +frame), averaged across the tag's `from`→`to` range: + +- A tag whose frames are all 100 ms produces **10 fps**; mixed durations are averaged (100/200/300 ms + → 200 ms → **5 fps**). +- When every frame duration in the tag is zero, the clip falls back to **12 fps**. + +Every clip is created with `loop: true`. Aseprite's ping-pong directions are recorded as looping but +are **not** expanded into a reversed segment — a ping-pong tag plays only its declared `from`→`to` +range. Frame indices in a tag that fall outside the frame array are skipped, and a tag whose entire +range is out of bounds produces no clip. + +## Not yet supported + +- **Slices** — slice data (`AsepriteSlice` / `AsepriteSliceKey`) is parsed into the raw document + types but is **not** yet surfaced as a runtime API; there is no slice accessor on `AsepriteSheet`. +- **Layers / nested tags** — Aseprite layer metadata is preserved in the parsed data but is not + interpreted into clips. + +## Texture ownership + +The packed image an `AsepriteSheet` uses is loaded through the `Loader` and owned by the **loader +cache**. `AsepriteSheet.destroy()` releases the underlying `Spritesheet` frames; release the shared +texture through the loader's own unload path when nothing else needs it. + +## Where to look next + +- **Package README:** [`@codexo/exojs-aseprite`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-aseprite/README.md) + — install, entry points, and the supported-format matrix. +- **API reference:** [`AsepriteSheet`](/ExoJS/en/api/aseprite-sheet/) documents `clips`, + `spritesheet`, and `createAnimatedSprite()`; [`AnimatedSprite`](/ExoJS/en/api/animated-sprite/) + documents clip definition and playback; [`Loader`](/ExoJS/en/api/loader/) documents `load(...)` + and the `'asepriteSheet'` config-map type name. +- **Guides:** [Sprites](/ExoJS/en/guide/rendering/sprites/) and + [Loading & resources](/ExoJS/en/guide/assets/loading-and-resources/) cover the rendering and asset + model the sheet plugs into. diff --git a/site/src/content/guide/assets/ldtk.mdx b/site/src/content/guide/assets/ldtk.mdx new file mode 100644 index 00000000..e0305f16 --- /dev/null +++ b/site/src/content/guide/assets/ldtk.mdx @@ -0,0 +1,169 @@ +--- +title: 'LDtk levels' +description: 'Load LDtk (.ldtk) level files and convert each level to a renderable TileMap through the official @codexo/exojs-ldtk extension.' +--- + +# LDtk levels + +[LDtk](https://ldtk.io/) is a modern, open-source level editor. The official `@codexo/exojs-ldtk` +extension adds an `ldtkMap` asset type that loads an LDtk project (`.ldtk`) through the normal +[`Loader`](/ExoJS/en/api/loader/) pipeline — fetching the JSON, loading every referenced tileset +image, and converting **each LDtk level** into a runtime [`TileMap`](/ExoJS/en/api/tile-map/) you +can drop straight into the scene graph. + +> **Note:** `LdtkMap` ships as an official ExoJS extension package, separate from the core. It is +> built on `@codexo/exojs-tilemap` (a regular dependency, installed transitively — you do not need +> to add it yourself). Install `@codexo/exojs-ldtk` alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-ldtk +> ``` + +Because each LDtk level becomes a generic `TileMap`, you render LDtk worlds with the same tilemap +node used everywhere else in the engine — there is no LDtk-specific renderer. + +Like all extensions, the core ships nothing LDtk-specific — an `Application` only understands +`.ldtk` files once you activate the extension. There are two ways to do that. + +## Activation + +### Explicit (recommended) + +Pass `ldtkExtension` to `ApplicationOptions.extensions`. This is explicit, tree-shakeable, and +order-independent — the extension is bound to exactly this `Application`: + +```js +import { Application } from '@codexo/exojs'; +import { ldtkExtension } from '@codexo/exojs-ldtk'; + +const app = new Application({ extensions: [ldtkExtension] }); +``` + +`ldtkExtension` depends on `tilemapExtension`, so activating it enables **both** loading and tilemap +rendering — the tile-chunk renderer bindings are materialised automatically. The package root +(`@codexo/exojs-ldtk`) is side-effect-free: importing it registers nothing globally. + +### `/register` convenience + +For an app that always wants LDtk support, import the `/register` entry once at startup. It +registers `ldtkExtension` (and its `tilemapExtension` dependency) in the global `ExtensionRegistry` +and re-exports the same public API: + +```js +import '@codexo/exojs-ldtk/register'; +import { Application } from '@codexo/exojs'; + +const app = new Application(); // picks up ldtkExtension automatically +``` + +The `/register` import must run **before** you construct the `Application` whose extensions are read +from the global registry. The explicit `extensions: [...]` form works regardless of import order. + +### Core-only + +An `Application` constructed with an explicit empty list takes **no** extensions — not even +globally registered ones — and will not recognise `.ldtk` files: + +```js +import { Application } from '@codexo/exojs'; + +const app = new Application({ extensions: [] }); // core only; no LdtkMap +``` + +This is the same add-only model the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) extension uses. + +## Loading a world + +Once the extension is active, load a `.ldtk` file by type token, by path (the `.ldtk` extension is +registered), or by the `'ldtkMap'` config-map type name: + +```ts no-check +import { LdtkMap } from '@codexo/exojs-ldtk'; + +// By type token (most explicit) +const world = await loader.load(LdtkMap, 'https://example.com/levels/world.ldtk'); + +// By path — the `.ldtk` extension is registered, so the type is inferred +const sameWorld = await loader.load('https://example.com/levels/world.ldtk'); + +// By config-map type name — the public `'ldtkMap'` lookup +const fromConfig = await loader.load({ + world: { type: 'ldtkMap', source: 'https://example.com/levels/world.ldtk' }, +}); +``` + +> **Note:** the LDtk loader resolves tileset image paths against the map's own URL with the `URL` +> constructor, which requires that URL to be **absolute (origin-qualified)**. Load `.ldtk` files by +> an absolute `https://…` URL, or set `loader.basePath` to an absolute origin so relative map paths +> resolve to absolute URLs. A bare relative or root-relative path will fail tileset resolution. + +## The LdtkMap result + +A loaded `LdtkMap` exposes one runtime `TileMap` per LDtk level, plus the raw parsed document: + +```js no-check +world.levels; // readonly TileMap[] — one per level, in document order +world.getLevelByName('Level_0'); // TileMap | undefined — lookup by LDtk identifier +world.data; // the raw parsed LdtkData document +``` + +Each level's `TileMap` carries the LDtk tile layers (as `TileLayer`s) and entity layers (as data-only +`ObjectLayer`s, with each entity's position, size, and scalar fields preserved as object +properties). World-space placement is stored in `TileMap.properties` (`worldX` / `worldY`). + +## Rendering a level + +Wrap a level's `TileMap` in a `TileMapNode` and add it to the scene. `TileMapNode` is the generic +tilemap node from `@codexo/exojs-tilemap` (the package LDtk builds on): + +```ts no-check +import { TileMapNode } from '@codexo/exojs-tilemap'; + +const level = world.getLevelByName('Level_0') ?? world.levels[0]; +scene.root.addChild(new TileMapNode(level)); +``` + +To stream a multi-level world, add a node per level — each level is its own `TileMap`: + +```js no-check +for (const level of world.levels) { + scene.root.addChild(new TileMapNode(level)); +} +``` + +`TileMapNode` handles per-chunk culling and renders the level's layers in document order. For +interleaving actors between tile layers, use `TileMap.createView()` to obtain independently +placeable layer nodes — see the [`TileMapView`](/ExoJS/en/api/tile-map-view/) API. + +## Converting manually (advanced) + +The loader does the fetch-and-convert for you, but the conversion step is also available directly. +`ldtkToTileMap` turns a raw `LdtkData` document into an `LdtkMap` of runtime `TileMap`s — useful for +custom pipelines or for converting data you already hold in memory: + +```ts no-check +import { ldtkToTileMap } from '@codexo/exojs-ldtk'; + +const converted = ldtkToTileMap(world.data, { source: 'https://example.com/levels/world.ldtk' }); +``` + +Without an `options.tilesets` map, tile layers are created with correct dimensions but no tile data — +the asset-loading path supplies the runtime tilesets for you. Tile flip flags are exposed as the +constants `ldtkFlipNone`, `ldtkFlipX`, `ldtkFlipY`, and `ldtkFlipXy` for reading raw `LdtkTileData`. + +## Texture ownership + +Tileset textures are loaded through the `Loader` and owned by the **loader cache** — not by the +`LdtkMap`. `LdtkMap.destroy()` destroys the owned runtime `TileMap`s but deliberately does **not** +unload tileset textures (they may be shared) or remove any scene nodes — the application owns those. + +## Where to look next + +- **Package README:** [`@codexo/exojs-ldtk`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-ldtk/README.md) + — install, entry points, and the supported-feature matrix. +- **API reference:** [`LdtkMap`](/ExoJS/en/api/ldtk-map/) documents `levels`, `getLevelByName`, and + `data`; [`TileMap`](/ExoJS/en/api/tile-map/) and [`TileMapNode`](/ExoJS/en/api/tile-map-node/) + document the runtime tilemap and its renderer. +- **Sibling extension:** the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) chapter covers the + same explicit-vs-`/register` activation for the other map-editor adapter. +- **Loading model:** [Loading & resources](/ExoJS/en/guide/assets/loading-and-resources/) covers the + asset pipeline the loader plugs into. diff --git a/site/src/content/guide/integrations/react.mdx b/site/src/content/guide/integrations/react.mdx new file mode 100644 index 00000000..ee001378 --- /dev/null +++ b/site/src/content/guide/integrations/react.mdx @@ -0,0 +1,249 @@ +--- +title: 'React integration' +description: 'Mount an ExoJS Application inside a React tree and drive scenes declaratively with the @codexo/exojs-react bindings.' +--- + +# React integration + +The official `@codexo/exojs-react` package lets you host an ExoJS +[`Application`](/ExoJS/en/api/application/) inside a React component tree: it owns the +canvas, manages the app lifecycle for you, switches [`Scene`](/ExoJS/en/api/scene/)s +declaratively, and lets React HUD overlays read the running app through context. + +It is a plain **React binding**, not an engine extension — there is no `/register` entry and +nothing to wire into `ApplicationOptions`. You use it from `.tsx` files alongside the rest of +your React UI. + +> **Note:** This package sits next to the core engine and needs React as a peer. Install all +> three: +> ```sh +> npm install @codexo/exojs @codexo/exojs-react react +> ``` +> `@codexo/exojs` and `react` (>= 18) are peer dependencies; `react-dom` is the usual host +> renderer. The package ships pre-built ESM with type declarations and type-checks against both +> `@types/react` 18 and 19. + +## Two layers + +The package is intentionally split into two layers — pick the one that matches how much DOM +control you want: + +- **`useExoApplication` — headless.** A hook that creates and owns the `Application` and binds + it to a `` **you** render. It produces no DOM of its own, so you keep full control over + the canvas element, its container, and its styling. +- **`` — batteries-included.** A component that renders a positioned wrapper `
` + plus a React-managed ``, and provides the app to descendants via context. HUD overlays + and the declarative scene API work out of the box. + +`` is built on top of `useExoApplication`; everything below the scene API applies to +both. + +## The headless hook + +`useExoApplication(options?, onReady?)` returns the app and a ref to attach to your own canvas: + +```tsx no-check +import { useExoApplication } from '@codexo/exojs-react'; + +function Game() { + const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } }); + // `app` is null until the canvas is mounted; render it however you like. + return ; +} +``` + +The hook returns `{ app, canvasRef }`: `app` is the `Application` (or `null` until the canvas +mounts and the app is created), and `canvasRef` is a stable ref you attach to the `` the +app should bind to. The optional `onReady` callback fires once each time an app is created. + +### Lifecycle and reactivity + +`options` is an `ExoApplicationOptions` — the same shape as `ApplicationOptions`, but the +`canvas.element` / `canvas.mount` fields are managed for you (the hook binds the app to the +canvas it references). You still pass `canvas.width`, `canvas.height`, `canvas.sizingMode`, +`clearColor`, `backend`, and so on. + +The hook treats most options as captured-at-creation, but live-syncs a few without tearing the +app down: + +- The app is **recreated** only when the render `backend` changes — WebGL2 ↔ WebGPU cannot be + hot-swapped, so this is the one identity option. +- `canvas.width` / `canvas.height` are applied **live** via `app.resize(...)`. +- `canvas.sizingMode` is applied **live** via the `app.sizingMode` setter. +- `clearColor` is applied **live** via the `app.clearColor` setter (keyed on its channel values, + so a fresh `Color` with identical channels does not re-assign). +- Options without a live setter (`canvas.pixelRatio`, `seed`, `extensions`, …) are captured at + creation. Change the `backend` or remount to apply them. + +On unmount the hook calls `app.destroy()`. The engine never removes a canvas it did not create, +so React stays the sole owner of the `` element. + +> **Note:** With the default `'fixed'` sizing mode the engine never touches the canvas CSS, so +> you may style it freely. The `'fill'`, `'fit'`, `'shrink'`, and `'letterbox'` modes manage +> `canvas.style` themselves — size the container and let them observe it, rather than fighting +> them with an inline `style`. + +## The batteries-included canvas + +`` renders a `position: relative` wrapper `
` containing the canvas, and provides +the app via context so descendants (HUD, the scene API) can reach it. Because the wrapper is +positioned, absolutely-positioned children sit over the canvas with no extra setup. + +```tsx no-check +import { ExoCanvas } from '@codexo/exojs-react'; + +function Game() { + return ( + +
HUD overlay
+
+ ); +} +``` + +Props: + +- `options` — the `ExoApplicationOptions` forwarded to the app (same reactivity as above). +- `onReady` — called once each time the app is (re)created. +- Layout props (`style`, `className`, and any other `
` attributes) apply to the **wrapper**. + Size the wrapper to drive the `'fill'` / `'letterbox'` sizing modes. +- `canvasProps` — forwarded to the inner `` (its own `style`/`className`); `ref`, + `width`, and `height` are managed by the engine and cannot be set here. +- `children` — rendered as an overlay once the app exists, with the app available via context. + +For full control with no wrapper element, drop down to `useExoApplication`. + +## Declarative scenes + +`` switches the one active scene by name. Declare each scene with `` and select +the active one through the `active` prop. The first activation calls `app.start(scene)` (which +initializes the backend and starts the frame loop); later switches call +`app.scene.setScene(scene, …)` with the optional `transition`. + +The scene classes you reference are ordinary ExoJS scenes — the React layer only decides which +one is active: + +```ts +import { Scene } from '@codexo/exojs'; + +export class TitleScene extends Scene {} +export class GameScene extends Scene {} +``` + +```tsx no-check +import { ExoCanvas, Scenes, Scene } from '@codexo/exojs-react'; +import { TitleScene, GameScene } from './scenes'; + +function Game({ screen }: { screen: 'title' | 'game' }) { + return ( + + + + + + + + + ); +} +``` + +Each `` takes a unique `name`, the scene `component` class to instantiate, and optional +`children` that render as the active scene's overlay. The `transition` (a core `SceneTransition`, +e.g. a fade whose `duration` is in milliseconds) only applies to switches, not the first start. +If `active` matches no ``, the active scene is cleared. + +`useActiveScene()` reads the live scene instance from the nearest ``, so an overlay can +react to scene state: + +```tsx no-check +import { useActiveScene } from '@codexo/exojs-react'; +import type { GameScene } from './scenes'; + +function Hud() { + const scene = useActiveScene(); + if (scene === null) return null; + return
Score: {scene.score}
; +} +``` + +For the simplest case — a single scene with no switching — use `useScene(SceneClass, deps?)` +instead. It instantiates and activates one scene, returning the instance once it is live (or +`null` while loading), and clears it on unmount or when `deps` change. + +## Reaching the app from descendants + +Any component rendered inside `` can read the running app: + +- `useExoApp()` returns the `Application` and **throws** an actionable error if there is no + `` ancestor — use it in components that require the app. +- `useExoContext()` returns `Application | null` (no throw) for optional access. +- `ExoContext` is the underlying context object, exported for advanced use (testing, custom + providers). + +```tsx no-check +import { useExoApp } from '@codexo/exojs-react'; + +function FrameCounter() { + const app = useExoApp(); // throws if rendered outside + return Frame: {app.frameCount}; +} +``` + +## End-to-end + +A small app that hosts the canvas, switches between two scenes with a fade, and overlays React +HUD on the active scene: + +```tsx no-check +import { useState } from 'react'; +import { ExoCanvas, Scenes, Scene, useActiveScene } from '@codexo/exojs-react'; +import { TitleScene, GameScene } from './scenes'; + +function Hud() { + const scene = useActiveScene(); + return ( +
+ {scene?.constructor.name} +
+ ); +} + +export function App() { + const [screen, setScreen] = useState<'title' | 'game'>('title'); + + return ( + + + + + + + + + + + ); +} +``` + +React owns the screen state and the HUD; ExoJS owns the canvas, the renderer, and the per-frame +loop. The two stay cleanly separated. + +## Where to look next + +- **Package README:** [`@codexo/exojs-react`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-react/README.md) + — install, the export table, and the reactivity model. +- **Scenes & lifecycle:** the [Scenes & lifecycle](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) + chapter explains `app.start`, `app.scene.setScene`, transitions, and the hooks the React layer + drives for you. +- **API reference:** the [`Application`](/ExoJS/en/api/application/) and + [`Scene`](/ExoJS/en/api/scene/) pages document the underlying engine surface the bindings wrap. diff --git a/site/src/content/guide/physics/joints-and-dynamics.mdx b/site/src/content/guide/physics/joints-and-dynamics.mdx new file mode 100644 index 00000000..e025cef4 --- /dev/null +++ b/site/src/content/guide/physics/joints-and-dynamics.mdx @@ -0,0 +1,263 @@ +--- +title: 'Joints, sleeping & CCD' +description: 'Connect physics bodies with distance, revolute, weld, prismatic, wheel and mouse joints, let resting bodies sleep to save CPU, and stop fast projectiles tunnelling with continuous collision.' +--- + +# Joints, sleeping & CCD + +This chapter builds on [Physics basics](/ExoJS/en/guide/physics/physics-basics/) — a +[`PhysicsWorld`](/ExoJS/en/api/physics-world/) stepped from `Scene.fixedUpdate`, with +static and dynamic bodies. Here we connect those bodies with **joints**, let bodies at +rest **sleep**, and stop fast bodies from **tunnelling** with continuous collision. + +## Joints + +A [`Joint`](/ExoJS/en/api/joint/) is a constraint between two bodies, solved alongside +contacts in the sub-step loop. The pattern is always the same: construct the joint, +then register it with `world.addJoint(...)`. Remove it with `world.removeJoint(joint)`, +which wakes both bodies so they respond to the lost constraint. + +```ts no-check +const joint = world.addJoint(new RevoluteJoint({ bodyA, bodyB, anchor })); +// ...later... +world.removeJoint(joint); +``` + +Many joints take a `hertz` (and `dampingRatio`) pair: leaving `hertz` at `0` makes the +constraint **rigid**, while `hertz > 0` turns it into a **damped spring** at that +frequency. Anchors are given in **world space at construction** and stored body-locally, +so they travel with the bodies afterwards. + +### Distance joints + +A [`DistanceJoint`](/ExoJS/en/api/distance-joint/) holds two anchor points a fixed +`length` apart — a rigid rod by default, or a spring with `hertz > 0`: + +```ts +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +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: 150 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + +// Rigid rod: holds the bob exactly 100px from the anchor. +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); + +// Or a soft spring that sags and bobs under gravity: +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100, hertz: 2.5, dampingRatio: 1 })); +``` + +Specifying `minLength` and/or `maxLength` turns it into a **rope/limit**: the bodies move +freely while the separation is within the band and the joint only engages (rigidly) at the +bounds: + +```ts +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +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: 50 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + +// A rope: the bob falls freely until it reaches 100px, then the rope holds. +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, maxLength: 100 })); +``` + +### Revolute joints + +A [`RevoluteJoint`](/ExoJS/en/api/revolute-joint/) pins a shared `anchor` point on two +bodies — a hinge they rotate freely about. Enable a **motor** to drive the relative +angular velocity, or a **limit** to clamp the swing: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); +const arm = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 70, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] })); + +// A free hinge at the origin — the arm swings under gravity. +world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 } })); + +// A powered hinge — a motor driving it toward 5 rad/s, capped torque: +world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 }), +); + +// A limited hinge — the relative angle is clamped to ±45°: +world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -Math.PI / 4, upperAngle: Math.PI / 4 }), +); +``` + +### Weld joints + +A [`WeldJoint`](/ExoJS/en/api/weld-joint/) rigidly locks the relative position **and** +orientation of two bodies, so they move as one rigid body. Both locks default to rigid; +set `linearHertz` / `angularHertz` for a springy weld: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, WeldJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const a = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); +const b = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 24, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + +world.addJoint(new WeldJoint({ bodyA: a, bodyB: b })); +``` + +### Prismatic joints + +A [`PrismaticJoint`](/ExoJS/en/api/prismatic-joint/) constrains a body to **slide along +one axis** relative to another — perpendicular translation and rotation are locked. It +takes an `axis` (normalised internally) and supports a linear motor (`maxMotorForce`) and +translation limits: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, PrismaticJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const rail = 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: rail, + bodyB: slider, + anchor: { x: 0, y: 0 }, + axis: { x: 1, y: 0 }, // slide horizontally + enableMotor: true, + motorSpeed: 100, + maxMotorForce: 1e8, + enableLimit: true, + lowerTranslation: 0, + upperTranslation: 200, + }), +); +``` + +### Wheel joints + +A [`WheelJoint`](/ExoJS/en/api/wheel-joint/) is the vehicle primitive: the wheel is free to +**spin**, sprung along a **suspension axis** (a soft spring via `hertz` / `dampingRatio`), +and locked **laterally**. A rotation motor drives it: + +```ts +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, WheelJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const chassis = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(120, 20) }] })); +const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); + +world.addJoint( + new WheelJoint({ + bodyA: chassis, + bodyB: wheel, + anchor: { x: 0, y: 30 }, + axis: { x: 0, y: 1 }, // suspension travels vertically + hertz: 5, + dampingRatio: 0.7, + enableMotor: true, + motorSpeed: 20, + maxMotorTorque: 1e6, + }), +); +``` + +### Mouse joints + +A [`MouseJoint`](/ExoJS/en/api/mouse-joint/) softly pulls a single body's grab point toward +a movable `target` — the cursor-drag primitive. It is a single-body constraint; reassign +`target` each frame to drag, and bound the pull with `maxForce` (so heavy bodies lag): + +```ts +import { BoxShape, MouseJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + +const drag = world.addJoint(new MouseJoint({ body, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 0.7, maxForce: 10000 })); +drag.target = { x: 50, y: -30 }; // update from the pointer position each frame +``` + +Both jointed bodies share one **sleep island**, so a jointed pair sleeps and wakes +together — which brings us to sleeping. + +## Sleeping + +A body that has stayed below the velocity thresholds long enough is put to **sleep**: it +stops integrating and is skipped by the solver until something wakes it. In a scene with +many resting bodies — a settled stack, scattered debris — this is a large CPU saving, and +it removes the last traces of resting jitter. + +Sleeping is **island-aware**: connected bodies (touching contacts and joints form an +island) sleep and wake as a unit, so a tower never half-sleeps. A body wakes the instant it +is touched by an awake body, hit by an `applyImpulse`/`applyForce`, or moved with +`setTransform`. + +It is on by default; tune it through `PhysicsWorld` options: + +```ts +import { PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ + gravity: { x: 0, y: 1000 }, + enableSleeping: true, // default; set false to never sleep + sleepLinearVelocity: 5, // px/s — at or below this a body is a sleep candidate + sleepAngularVelocity: 0.06, // rad/s + timeToSleep: 0.5, // seconds below the thresholds before sleeping +}); +``` + +Per body, opt a single body out with `allowSleep = false` (it, and its whole island, stay +awake), read `isSleeping`, or force it awake with `wake()`: + +```ts no-check +body.allowSleep = false; // this body — and its island — never sleeps +const resting = body.isSleeping; // true once it has come to rest +body.wake(); // force it awake (e.g. before applying a scripted impulse) +``` + +## Continuous collision (bullet mode) + +The solver runs detection **once per fixed step**, so a body that travels farther than an +obstacle is thick in a single step can pass straight through it — tunnelling. For fast +projectiles, flag the body as a **bullet** (`isBullet`) and it is swept along its motion +each step against every other body; if the sweep would cross a surface, the body is clamped +just short of it and its velocity is resolved about the surface normal: + +```ts +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + +// A thin wall the projectile would otherwise skip over. +world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] })); + +// A fast bullet — swept each step so it stops at the wall instead of tunnelling. +const bullet = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new CircleShape(6) }] }), +); +bullet.linearVelocityX = 6000; // ~100px per fixed step — far more than the 4px wall is thick +``` + +`isBullet` is a plain flag you can toggle at runtime (`bullet.isBullet = true`). The impact +response is a **velocity reflect** about the true surface normal: a non-bouncy body slides +along the surface (keeping its tangential velocity), while a bouncy one (`restitution` near +`1`) rebounds elastically. The swept test runs against static, kinematic **and** dynamic +bodies; sensors never block. + +> **Documented limit.** CCD sweeps the body's **centre point**, which is ideal for small, +> point-like projectiles (bullets, pellets). A **large** fast body can still clip a corner, +> because only its centre is swept — for those, raise +> [`subStepCount`](/ExoJS/en/api/physics-world/) (or the step rate) so each step covers less +> distance, or thicken the geometry it must not pass. A full swept-shape time-of-impact for +> large bodies is a future enhancement. + +## Where to go next + +- **API reference:** [`DistanceJoint`](/ExoJS/en/api/distance-joint/), + [`RevoluteJoint`](/ExoJS/en/api/revolute-joint/), [`WeldJoint`](/ExoJS/en/api/weld-joint/), + [`PrismaticJoint`](/ExoJS/en/api/prismatic-joint/), [`WheelJoint`](/ExoJS/en/api/wheel-joint/), + [`MouseJoint`](/ExoJS/en/api/mouse-joint/) and [`PhysicsWorld`](/ExoJS/en/api/physics-world/). +- **Start here:** [Physics basics](/ExoJS/en/guide/physics/physics-basics/) covers worlds, + bodies, colliders, stepping and sprite binding. diff --git a/site/src/content/guide/physics/physics-basics.mdx b/site/src/content/guide/physics/physics-basics.mdx new file mode 100644 index 00000000..8e4c4ef4 --- /dev/null +++ b/site/src/content/guide/physics/physics-basics.mdx @@ -0,0 +1,257 @@ +--- +title: 'Physics basics' +description: 'Add 2D rigid-body physics with the @codexo/exojs-physics library: build a PhysicsWorld, drop bodies onto colliders, and step it from Scene.fixedUpdate.' +--- + +# Physics basics + +`@codexo/exojs-physics` is the official 2D rigid-body engine for ExoJS — a native, +warm-started **TGS-Soft** solver (the Box2D-v3 "soft step") with shapes, colliders, +bodies, contacts, sensors, queries, joints, sleeping and continuous collision. It +turns a static scene graph into one where boxes fall, stack, bounce and collide. + +> **Note:** physics ships as a separate package. Install `@codexo/exojs-physics` +> alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-physics +> ``` + +## A library, not an extension + +Unlike [Tiled](/ExoJS/en/guide/assets/tiled-maps/) or +[Particles](/ExoJS/en/guide/effects/particles/), physics is **not** an +`Application` extension. It contributes no renderer and no asset type, so there is +**no `/register` entry and no `extensions: [...]` activation**. `@codexo/exojs` is a +peer dependency; you construct a [`PhysicsWorld`](/ExoJS/en/api/physics-world/) +directly and step it yourself: + +```ts +import { PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +``` + +The world holds **no module-level state**, so any number of worlds run in complete +isolation — one per scene, or several side by side. Gravity is in px/s² with **+Y +pointing down** (the ExoJS screen convention), so "down" is a positive `y`. + +## Bodies and colliders + +A [`PhysicsBody`](/ExoJS/en/api/physics-body/) is a transform plus a mass model; a +[`Collider`](/ExoJS/en/api/collider/) is the geometry attached to it. A body owns one +or more colliders, and its mass, centre of mass and rotational inertia are computed +from their shape and density. Every body has a **type**: + +- `'static'` — never moves (floors, walls). Infinite mass. +- `'dynamic'` — integrates under gravity, forces and contacts. This is the default. +- `'kinematic'` — moves only by the velocity you set; immovable under contacts (moving platforms). + +Construct a body freely, then hand it to `world.add(...)`. Colliders can be passed +as plain option objects in `colliders: [...]` — you don't have to build a `Collider` +instance yourself: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + +// A static floor: an immovable body with a single box collider. +world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 400 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), +); + +// A dynamic crate that falls onto the floor. +const crate = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: 0 }, + colliders: [{ shape: new BoxShape(32, 32), density: 1, friction: 0.5, restitution: 0.1 }], + }), +); +``` + +`world.add(...)` returns the body, so you can keep a reference (`crate` above) to read +its position or drive it later. Collider material is set per collider: + +- `density` (default `1`) — mass per px²; feeds the body's mass model. +- `friction` (default `0.2`) — Coulomb friction coefficient. +- `restitution` (default `0`) — bounciness in `[0, 1]`. + +The two built-in shapes are [`BoxShape(width, height)`](/ExoJS/en/api/box-shape/) and +[`CircleShape(radius)`](/ExoJS/en/api/circle-shape/) (there is also a general +`PolygonShape` for convex outlines): + +```ts +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + +const ball = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: -200 }, + colliders: [{ shape: new CircleShape(12), density: 1, restitution: 0.6 }], + }), +); +``` + +## Stepping the world + +Physics is **caller-driven**: nothing moves until you call `world.step(seconds)`. The +world accumulates elapsed time into a fixed timestep (default `1 / 60 s`) and runs as +many fixed sub-steps as needed, so the simulation is **frame-rate independent** and +**deterministic** — the same inputs replay identically. + +That makes [`Scene.fixedUpdate`](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) the +right hook to step it. `fixedUpdate(delta)` runs zero or more times per frame with a +constant `delta` — exactly what a stable simulation wants. Leave camera, UI and purely +visual work in `update`; put `world.step(...)` in `fixedUpdate`: + +```ts +import { Scene, type Time } from '@codexo/exojs'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +class GameScene extends Scene { + private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + + public override init(): void { + this.world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 400 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), + ); + } + + public override fixedUpdate(delta: Time): void { + this.world.step(delta.seconds); + } +} +``` + +The fixed-step size is the application's `fixedTimeStep` (seconds, default `1 / 60`): + +```ts +import { Application } from '@codexo/exojs'; + +const app = new Application({ fixedTimeStep: 1 / 60 }); +``` + +> Stepping inside `update` works too — pass `delta.seconds` the same way — but +> `fixedUpdate` is preferred because its constant delta keeps the simulation +> deterministic regardless of display refresh rate. + +## Binding bodies to sprites + +A body is pure simulation — it has no visual. To draw it, link it to a `Drawable` +(such as a `Sprite`) with a [`PhysicsBinding`](/ExoJS/en/api/physics-binding/). After +each `step`, the binding writes the body's **position and rotation** onto the node +(the body's angle is radians; the node's rotation is set in degrees for you): + +```ts +import { Sprite } from '@codexo/exojs'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const sprite = new Sprite(null); + +const body = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(12) }] }), +); + +world.bind(body, sprite); // sprite now tracks the body every step +``` + +`world.attach(node, def)` is the one-call shortcut for the common case — it creates a +body with a single collider, adds it and binds it in one go: + +```ts +import { Sprite } from '@codexo/exojs'; +import { CircleShape, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const sprite = new Sprite(null); + +world.attach(sprite, { type: 'dynamic', position: { x: 0, y: 0 }, shape: new CircleShape(12), restitution: 0.5 }); +``` + +If you'd rather position things yourself, skip the binding and read the body's +transform after each step — `body.x`, `body.y` (world position) and `body.angle` +(radians): + +```ts no-check +sprite.setPosition(body.x, body.y); +sprite.setRotation(MathUtils.radiansToDegrees(body.angle)); +``` + +## Worked example: a ball drops onto a floor + +Putting it together — a static floor, a dynamic ball bound to a sprite, stepped from +`fixedUpdate`: + +```ts +import { Loader, type RenderingContext, Scene, Sprite, Texture, type Time } from '@codexo/exojs'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +class DropScene extends Scene { + private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + private ball!: Sprite; + + public override async load(loader: Loader): Promise { + await loader.load(Texture, { ball: 'image/ball.png' }); + } + + public override init(loader: Loader): void { + // Static floor — an immovable body, no sprite needed. + this.world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 360 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), + ); + + // Dynamic ball: a sprite plus a body + circle collider, linked by `attach`. + this.ball = new Sprite(loader.get(Texture, 'ball')); + this.addChild(this.ball); + + this.world.attach(this.ball, { + type: 'dynamic', + position: { x: 0, y: -200 }, + shape: new CircleShape(12), + restitution: 0.5, + }); + } + + public override fixedUpdate(delta: Time): void { + this.world.step(delta.seconds); + } + + public override draw(context: RenderingContext): void { + context.backend.clear(); + context.render(this.root); + } +} +``` + +The ball falls under gravity, hits the floor, bounces a few times (restitution `0.5`) +and settles. Because the body is bound to the sprite, the sprite follows automatically — +you never touch its position in `update`. + +## Where to go next + +- **Joints, sleeping & CCD:** the next chapter, + [Joints, sleeping & CCD](/ExoJS/en/guide/physics/joints-and-dynamics/), connects bodies + with hinges, ropes, motors and springs, explains how resting bodies sleep to save CPU, + and shows how to stop fast projectiles from tunnelling. +- **API reference:** [`PhysicsWorld`](/ExoJS/en/api/physics-world/), + [`PhysicsBody`](/ExoJS/en/api/physics-body/), [`Collider`](/ExoJS/en/api/collider/), + [`BoxShape`](/ExoJS/en/api/box-shape/), [`CircleShape`](/ExoJS/en/api/circle-shape/) and + [`PhysicsBinding`](/ExoJS/en/api/physics-binding/). +- **The frame loop:** [Scenes & lifecycle](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) + covers `fixedUpdate`, `update` and `draw` and the order they run in. diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 3334883a..1ec0a7e3 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -257,6 +257,28 @@ const RAW_PARTS: ReadonlyArray = [ prerequisites: ['assets/loading-and-resources'], apiLinks: ['loader'], }, + { + slug: 'aseprite', + level: 'intermediate', + learningGoals: [ + 'activate the Aseprite extension explicitly or via /register', + 'load an Aseprite JSON sheet as an AsepriteSheet', + 'play tag animations with createAnimatedSprite', + ], + prerequisites: ['assets/loading-and-resources'], + apiLinks: ['aseprite-sheet', 'animated-sprite', 'loader'], + }, + { + slug: 'ldtk', + level: 'intermediate', + learningGoals: [ + 'activate the LDtk extension explicitly or via /register', + 'load a .ldtk world and render each level as a TileMap', + 'understand tileset texture ownership and absolute-URL resolution', + ], + prerequisites: ['assets/loading-and-resources'], + apiLinks: ['ldtk-map', 'tile-map', 'tile-map-node', 'loader'], + }, ], }, { @@ -536,6 +558,35 @@ const RAW_PARTS: ReadonlyArray = [ }, ], }, + { + slug: 'physics', + title: 'Physics', + description: 'Add 2D rigid-body physics with the @codexo/exojs-physics library: worlds, bodies, colliders, joints, sleeping, and continuous collision.', + chapters: [ + { + slug: 'physics-basics', + level: 'intermediate', + learningGoals: [ + 'build a PhysicsWorld and add static and dynamic bodies', + 'step the simulation from Scene.fixedUpdate', + 'bind bodies to sprites so visuals follow the simulation', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + apiLinks: ['physics-world', 'physics-body', 'collider', 'box-shape', 'circle-shape', 'physics-binding'], + }, + { + slug: 'joints-and-dynamics', + level: 'advanced', + learningGoals: [ + 'connect bodies with distance, revolute, weld, prismatic, wheel, and mouse joints', + 'let resting bodies sleep to save CPU', + 'stop fast projectiles tunnelling with continuous collision', + ], + prerequisites: ['physics/physics-basics'], + apiLinks: ['joint', 'distance-joint', 'revolute-joint', 'weld-joint', 'prismatic-joint', 'wheel-joint', 'mouse-joint', 'physics-world'], + }, + ], + }, { slug: 'recipes', title: 'Recipes', @@ -649,6 +700,24 @@ const RAW_PARTS: ReadonlyArray = [ }, ], }, + { + slug: 'integrations', + title: 'Integrations', + description: 'Embed ExoJS in other ecosystems — starting with hosting an Application inside a React component tree.', + chapters: [ + { + slug: 'react', + level: 'intermediate', + learningGoals: [ + 'mount an ExoJS Application in a React tree with useExoApplication or ExoCanvas', + 'switch scenes declaratively with ', + 'read the running app and active scene from React overlays', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + apiLinks: ['application', 'scene'], + }, + ], + }, { slug: 'shipping', title: 'Shipping', 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"] From f0a341c86fa2cb7c9ebe2ab4c52e9990ca9488ef Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:07:07 +0200 Subject: [PATCH 59/68] feat(site): companion 8-state callouts (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the guide Callout into the eight Pocket Relic Bot states from the companion design brief: info, hint, warning, pitfall, success, debug, invalid, broken. Each callout now pairs the painterly companion sprite (a trailing-edge badge with a state-coloured screen-glow) with a state-coloured glyph + label — glyph and label carry the meaning, so the callout still reads via shape and text, not colour alone. - 8 transparent companion sprites keyed + optimised to ~6 KB WebP each in site/public/brand/companion/. - Colour exclusivity per the brief: amber=warning, mint=success, gray=invalid, red=broken; info/hint/pitfall/debug share the companion cyan and are told apart by glyph. State colours are fixed, so they stay stable across the user-selectable accent themes. - Migrates the existing usages to the new states: common-mistake→pitfall, tip→hint, and the two browser callouts → info / warning by meaning. Verified all eight states render cleanly in light and dark (Playwright), with no sprite halo on either theme; astro check 0 errors. The right-rail "Need a hand?" helper card is a deliberate follow-up. Co-authored-by: Exoridus --- site/public/brand/companion/broken.webp | Bin 0 -> 6220 bytes site/public/brand/companion/debug.webp | Bin 0 -> 5826 bytes site/public/brand/companion/hint.webp | Bin 0 -> 6874 bytes site/public/brand/companion/info.webp | Bin 0 -> 6414 bytes site/public/brand/companion/invalid.webp | Bin 0 -> 6416 bytes site/public/brand/companion/pitfall.webp | Bin 0 -> 6210 bytes site/public/brand/companion/success.webp | Bin 0 -> 6276 bytes site/public/brand/companion/warning.webp | Bin 0 -> 5814 bytes site/src/components/Callout.astro | 134 ++++++++++++------ site/src/content/guide/audio/audio-basics.mdx | 2 +- .../content/guide/getting-started/setup.mdx | 2 +- .../guide/getting-started/what-is-exojs.mdx | 2 +- .../getting-started/your-first-scene.mdx | 2 +- 13 files changed, 96 insertions(+), 46 deletions(-) create mode 100644 site/public/brand/companion/broken.webp create mode 100644 site/public/brand/companion/debug.webp create mode 100644 site/public/brand/companion/hint.webp create mode 100644 site/public/brand/companion/info.webp create mode 100644 site/public/brand/companion/invalid.webp create mode 100644 site/public/brand/companion/pitfall.webp create mode 100644 site/public/brand/companion/success.webp create mode 100644 site/public/brand/companion/warning.webp diff --git a/site/public/brand/companion/broken.webp b/site/public/brand/companion/broken.webp new file mode 100644 index 0000000000000000000000000000000000000000..5f540734ec7640b2962b59ae3247f740f8f1630b GIT binary patch literal 6220 zcmV-S7_;Y6Nk&FQ7ytlQMM6+kP&il$0000G0002T0074T06|PpNPGqW00Hoa?f>~k z{{P*dNrO(YwcQ@mv2EM7ZF_CoyJOon%VW1#)wVm0ll#72uQ>O|V~*Ef6A=>tFa7_2 zNr}@7g6zZfF9@r&RfAp#29zQ`@P7dai$_HKK;S<4Ui{zM5!|<2Z3N>2Ij>V5n*2!2 zb)J{ebi;U_6W%hIfpLKxH?kJZ{y^Ziz<4yjst>nC=2)Pbz-{oh1?OG(E#gnJs`>)Q zuhX0JslanSab{#h1J}<{o6xNVzIVND;*JOB!Kiv8HA8{(MO_k%ocH56sYgiL&O6F< zMjD^=k&C!2aPsF@ld>!8b!a;8!I4)d#yay>@87j(Rc*=b?a^E%KZiCB!>TQ9a zaFl2-+BC_FmqczuD{Rx)kB20YqqiSUC`TK=!9C&@tIp-A(GJUxuk9e?7?)88i_m^6 z&(9C$6z56T6lmZ2^zGq1(&dyA9WZU73vak?DniH9oNmt-VMlf7usL&`IHJu%bmXQF z+_<4_B09F=M-NVjc#IDJy$ct3oO^I04)rk(`MYRf$TI3)6@oOL^q zO4V$}#bl9zaXxH&73tJ1Vp?D-A$Z4?WizytH1ubf=pd5%F~fp0Xi4^F*H|?z4ZYd* z9um8rS>g-S{K6`6hrg-0CudXhUkeS?6b}_x6!i=VuG%57tmR@Eagtw6?zRo~TE?_5Mg zRr=ngtpv(~vgli0_dox8TuNFdT~+OkId@!ny8eGsx&h^Z8FUrJ7z2RjIf(eMEf{a* zN0LVpU1`2%yorc+qrv$2e@2rezeCqGnW@-Z#5+-7R9I5;$crS{OV>wW`UAu}QD9WG zqCqzcXx>bgc9N+Ud3ZAlj7oPj;uTk-hO4N`3^nyWo(%@0(zi5NKNN@#{+p_=1rsgu zPWgjT*Y`Dud!49)?};kz047KO=nF=@e%2$ZSIEg4)x|lJGv_oi;#ya55oPJ;BPos|*K%fc`PnbbKbVlKYjW zS6zUGs})qFM1YmY{y9?wOmy0+prK+ESUac%uu{CZfP&h^zKgy8`>AKOuoPVu1tOR#Tu#ULa+gtn;k7A805*RP7eAEaXs)0m;mZV3>3-Qd-lF zVJ%8Y`8$YVgOsHF8OyMa)uiCV7R-_kE};bhMD`BP2)3 zYn;dk7*InUCWium^MI|NzqhERK$+-OEp_;@0KLyOA|e*O>q8jG_Eb@q6E(LOO`h-| z3`9LYJX}p$+O7sPa~EX*ut7uG^9D3GJBqZ<#mG*>63V29k)?Yn>tDx|CvJ2JKOp9t z;E)W~9FhXj!sm=B=m=!@xzuDmom!=)M!OCO_xUBKy53-os*}4`E?@WA&0?KhDJqNd zs}%<8{n(5$kcYJEby9_>u1LLc;+uEljr{fX5ElRtWRX*T$kf||rY1Y??6=!Sb!-vc zW%#0xFQpVWS>sX=knb~i!J<)ZedJ<*$Zf}-o?jIXX1aHOGgWB=MRmsAQ(tVD*e%>c z0%pp5TMplFG^I>smiBQEfj$dGt`e|N2%q$8q0UMrvpR&iIDwTV{%yx?{PNVTCrOD< zuKfDy#83e^P||kM>4HXsWx5?;d$HWr!^1@i9O>Nd%_7TgdfNeyiie&k(_3Kp#*53K zS?j5v#g{2`M)b09f2^nb@7h0hzubL6|GEE$>nijB{l)a*{nz+T{?q?6_aoYO z|Hg9X^1sDj4gbgZN8)qVUgC1C>Hif!gMVrL!_gl9{xIqV`8V`0F7K%BFxUUZcRkcN zv_uCs(GVMcc!2fLl0-lyng`RS?Fi?k{33pno%%l^OiKq-*3c|v(mA$=NBTZ|i{2cw zq`d@JO%(Jsx9Ul(20v5KP=1v%EmNqc-9$ih6RbQ0El+cou63k#Wfk53?(xzk*Ft_2 zP?4t>|B~JQRw};JQ{3V9lTQl%TY%Z*?aP8|+KYa)1SS+|EEP+ORP!L?4@e*faS9dw-dO z2}FlJgn(45`5QC-c3^^no>f&-EP0}fAEo3&G~BJZ$~BNX=&fvodeO)ognAS41?bQc zoWk;d3?TFvP%1pXus>HR)_l_w6+BAM!S4;zC4FHs7M1Jn6!D;S?gXcX(y~HQo*_IVvmO{LQKuU#06qkyl0j zb*A6NvQQQ=tGh(%_Rf_&2RiljMu_1_Cj(NHLtnd_9t)sJZd9KeOsoeknq_IWJVMA3 zlo;!p5WC`==tVon+~E^z&&RZKaqJUQ%HIR)ut1i$)3n=nLR@XkHsfXt>jzDHK2}`S zVTpr~c24j*7pzF@u4(lHd9NhS`aRwQ!UVfe0ER$3fOjIZK8~Hbz2WnFkn5!K-?4Q` zjcVyGl{}V~SEFW6gOW&C(4oSMDkNaR$PbXsvfJ=DW1AOYpFCOY2E9Gzw#HgH6{?H& zE^ETv0fqf`ak1X#l~n|x#6eH3dr{S+1j6uBxK82T%6h6N{UP$N=QfNR=AM7dh72*OAW>95^H3%N%4 z*WwE&B3^&gzg$S&Iez97bwBf@I{UlbqTBX~Zzkiv{*mXsOkLTBr$|F0N~!C_9MPJK z6O0BlKiuun8;{8*QTz-me)H}f8Oz%wG-@mvax9s@2hLECS64k<$!Qj+O7DSnD3)f#sUvqjHE9GT5@G;>z zI=QEKM3y&aM|H2xyzXePz0&-8=Xdsia#`yEbl{eS#6ZWLpk*&&leK5tp-F z0XAhf0@8)YRB|?g9Q1@>dRZb^#DRUL;xnPxZyCeGUev_o z>_rNGitR^GS)jn_##>Dm7n7G*Fl=8M#Ub{)c%2Ao55wpmvFf;Pj1`+Z41)#CST-yy z*YF={?wBgrZ4(SN{u9lKq~-6xoi_P7gV*!t2?+jrr(=3Z4p^Gmmg zve&thMLnu{IcN4*OXCH>>9jftqRGrGT$th8gdlOPN$ao~^Lgc>(i;%^J_{p3uEA`O@Z?|0^jN2PVX4v1_Y079XHURphadZy{DcQ9=4!3C>gvQ2^o>KSte_kP9M&BWG zSxU+Y-LES0T^hSokxE5-3COx8XN?>#OH!oUhUY?y22{_v>Y}G ztRebP=bdU=rslJq%^S1{`Qg~>rPzv1LjWjEbqc2wANJ#)RfI(tNBxF(ITAo}FDbk| zeV`vC;|a^APGZKA3HJfrB{&^=Z7q;O?d2=oUyhsPyu-NVRo3_Krft?xPw_}%)rpjG zsA8+Z)!?Gi+Kcj(Ce9S@1f7|6xfo<7s-b7cm3W7=^XDY@os0RX&rmWFABvYlFq?B9ub@={)>`X0k zJhlz#UPFjd2GFz8QbBD_&cRz<_xB?fK=l(65tjjkd^o$%zVZJZsdb|6b(Q3< z@~@~o(s`^{Ncia=k7GY9!uR_V8ewLHjvx2%RSyuf9U`8%BT4E-brRZ~jYWO!b$Ru|YTJJBL=GEItj zo!Pt^{&aYrkeYrPI$6OhdeX;wp*x#K=5E;C08b~%#87>mXb8^kE6l0bUx@jEa?{m1 z$%f9-U-Goy5pjLe4$5??O0sJq-1fMj$EG)0b&sV09u*4hd;P2pLJwNO@>;-;T@@F`{u>akM;1;KfjHCkLRJZezNd_K8DUjN_#xb@?*Rs1|st{w!X-LZl*6Y>$R z&|uLr{pK4~z|-9EN*d6|VScUKxhuagOvFBF9T0Yj5!LxM<}#yb;Z>l~;VK50qA;9H zaH><06ul`^<%z%NR7J8Eql^*nqX3tr1g@UNmePN8PiOEH@4`{hlk zd}DRQtzw+^%oK`LvrrXF%7O7l&>gkO^7waYI;2ULy(dlWX(3umPZ=tIw;6+&S5jKntNK{wtB1(pZKdSuK*ro3A9ZTd(Btxvr29`+G9$L%ca@mV?q2W)fd&VuNeqis zkVSDas+T}Yz&IDQeJ2tqZkarXga%>7SWQ|s-8dvOEbNASu)!SOR@2QGqflZul#a!P z*QK4(9Y1`3u4Tx+5B~WacIs=1k7CoIq1v;I6jK004JUwiII29(k>_Bvpml%zBDUUN zE8B$VOKMekAY*EE!PsTi5}!=kcg;{fnfRh4*4LGNB@KZ5qt-+uh;JLIU2VLaKP%b7 z{+G5e~yMHQJ_BX#~rtcS)=P{gK1#4Mz_j!YY=#;B?bmo2SGBKeuVWF7n= zTd8mu#TomzxOMK516(mP=Jp3E6D-=2(644x$ee2@$@Y$@Q|{&9Gg(@E_)MiDV8-7S zr3syU{QQ4a5LBrtBL?~Y*#EO2h{%LHQTttWr5)ttV4bH4$F&T{296D;k3AGAACmP= zP+uXl_0c}-Tqu94V55Fx@A1gV#HR_&zZ3RvEe8D>>3$!RB>9Q=IK(Qz@9z`DhM0m~ zHfD)kB~G@hir5uqNH=ivdh6c^YBIk=11DHu2rOtZJ`L*Z8qGZBGoB(&zu5R-_&kSB zpc`@0#Q;e%6xwyILc>d?rD$Oi;)TM2IcVR2LaT4-dxS|*!kk(=)-_g$TWsg>$uhAB z&`%gyfzwguXFVj;nPpEH8kzUHsR)||`@94Y*YH+2rPD_jmP((U2%_9&ghOhmmG!xP zRl+l25W*@|{=BFvb4QuulZN2D;THhbeS$y2jm$R4GW{IVAUNjodh+iJT`_ybK}Ptj qo4^1F7A6{!tAfKRVLx7BvqS&Q49xthof9rfP#oTX000000002*2vYX| literal 0 HcmV?d00001 diff --git a/site/public/brand/companion/debug.webp b/site/public/brand/companion/debug.webp new file mode 100644 index 0000000000000000000000000000000000000000..fec154eb91b14ef408a6c296ce0c02862ba95b85 GIT binary patch literal 5826 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 Mpc?=H000000R2x?b^rhX literal 0 HcmV?d00001 diff --git a/site/public/brand/companion/hint.webp b/site/public/brand/companion/hint.webp new file mode 100644 index 0000000000000000000000000000000000000000..3f56f6801ff8537c92a6dbb0c0b7e2cffc48f25c GIT binary patch literal 6874 zcmV<08YSgYNk&G}8UO%SMM6+kP&il$0000G0002T0074T06|PpNTvq>00FQ@TiY=? z+FfW{v2EKn)~wiR#I|kQwv+KpY&&0P*-6{(uGCxik9yT_-S?Y+-$cX&z<>S+0HfQ_ zLg-=tpM%DcxWMo;P~Z+?V*8%}r%i~64-4zRL0NXV!&&cD{eYs`gLIzzX_S0lMc+-Z zNZ~0DjhXdDlNgj~ouIAyXM^P#%)A9%McI+{H8l&L2FP)mb-XldlTqO@vzA(=C)biw zD-k=g)-6-PV$ib31q_w593SOl&E>ef*CjLsLvXi2D$fgB2=HDvT8?!TXuI&+*ZHJ+ z3734BgB5bF`f0=6SppV(EGuC=ZtV)(mwF?W(G@gV{IA_X68=@q5-?=G8+$O~hr}Ug zZ8Xlr!+~OY)BB=WbS==P%dk&)EKrv~$5fFu28{qz{nd6hP>It1I1!w@k-(=U|K%@;3QTVu` zw2YE-Ki~#dJSW|c%|R6{aZfh2Fz24V%ajDPOwdllzOZ<*PMAJ9yOV8 zZDuXET-boB4r0W=-`P0SaKhgMg48k`RcAIE0YmQmK#f};kMLLNm^w?CaO!+RKnJwm z>|{~{cBnIwhax-D(Lqx-6i{QFDw!==X!-yh_(mNS>LsZWo{xhLU(uo8w^Qc!s~XFI z!Cn#Q;NLnZf))wN-j!vbbtF3cm&S?=^KD8p2Lk;S!;!!zWt4#LuL`nenCT1n2T45V ztpqB6mK8nR4D`=I8ilEfOy}f$Y6lF~i6jEQS5pGpWn}gO2AvNhnfsg+IPH@0$d`jw zX>=qvRnRU)#;ZmQnD!!}yN&X?S!E>jWS~L>B@q?nwaAq5moEcTk<_X3#-ns*f1uy< zDlOMMW%aN~eJRR7*Vo6)kZ;fPwx3EjUCLz!Z^`^DThEO-Src1tsqxV#-h# zV9@;rHMczFLD7l3H@7zdhxH<;`Bq*5n}yeZu~8%3>0x9j@83vqN;ih)Bh7dG*(vsp zsGP3sgxZM0P28AIBGvc2nJN69tmq2NG|eC@t2Hx&Y-HK`GBX3|9w-&qw3Z74n+Y2w z(e!p^(n=TdOeYB&Ayy(B5i{D7=lmC81H|T|{W$VWC+HG-i{yPFGJ-tVMORi!kzaD` zGF#HOlCGEvBAe0vO+osG)AhvP&i|3f2@`#-v&cGXv}=|oG79J~`iZR3V80aYQ|mI| zdY-D-s&;NyM3QGUb|VjFQFYJ^0LJRyMG(`sc#u~+k*0)30LHe*-v|P)k8$azxP1Yq2Yisw)V&Li%|2((NC00;_K zcHf&i0PpgLNqJksuFywinFHwqsQR6fS)dkP_hbNO+UR(YPo(f?6+RW_!1oLpsa?U| zX`V%er~Vv3vAaZAW*J5LW}=Ea4J<(AdnDv6aRK{I5vXE9V_>r6uLLB|a0dIjA5qPX zMlQf_LA-+rkGdGZzQlc0^HDM2(|1g)V_mEVqJF=knx7j2lkT<09=Q9>;#g^~_qH$q z7?-1}6M;wP9_JI0#Q45`C5EP{GnR?bSx9Bf^Y7WH?o1v^zDM${-Fax8L*MiKJp5uI zFJl03=-KTNlE1-Bn|JAQRU36%cr_Ere^mlV?|3yTIzHWs3}z1o(i=u0n_HY|+k|Xh z&IhEsU1sA|RiNGVA{QT<0rk6?c-IohAHu`4#z23G4-8n(l>-jvk!O23$_rS`dyO{X z<@kcYWtE#m36|{mXZr>e0!9nWeoa!u@YYoV4Zy4G36g9FJb>MiIV7QNjWaiOz9G@s z6Airh{EcL%^y6iyl`i~VpOd_gkn(e`jEu687FUH2@0&<^Rtr8FrV`hZj}W9iq8uN= z#2q#BQ7@UaxTe6T=Vhv_KM<3L7#M+p7MfCL&U|sIlL0vOI8M_(gE7Di%oh2}&T1uQ zGjN=D;p?PVqk_NEGM*)Xg4GKE4EjRJlOjxsw@RktWkX=y?+F3%9|g3<>@E+?uXnu2 z-o^S)=vT!I+_y-RA>=qjW>3(GPOEKFDU*J;W(%5&ETtE~YgLK_?kdoAXp-i_gLDI! zo1e6a#a4v8Z!~w)Lq7m`FN+oVS_)kLRprZz@c-FA`lQBTgPs6W_297cY0={h0Nd3_ zYwsHr7!VK;;5pQ$!W__@KJ_XY0bryWkNrWiW}>nm-SmfK-DDNkfVB5*Ww8}?OW+G- zS?y%NQf2pibc#CwPI;K^Yl8B13pkKC<(K^QWyaLN? zgFNSvALLywpktuysdPE7LR>*!vm`%XS{QV#TdQT~U&kgUMTegqT^!VO zF50+9uU@@6*9-#v>zcPhwQA*p%U#zpI4_*?iMaCh^(llnjU&G`F8|4Y;_-0!q_7V8b*A7DS$ zJ~KZ;_5%Kc{j>hhvlsY3wcd>Wp#RzK9sIldC;e{5Ke-;sAFyR^Eu+J?STk>_iz*s< zR5wY$pQjc!O{S<8(bi~ct+~D76RIG(IsoyD!vkk%qBDwB}oq|n1 zhqVNf3*8>O>jIFP*G_hwQw_5-Zk;pBTf4$-)-D}L9rny<7hs44F0S`z>`ulad;N$} zjMJX_*WKmOcl*EPXVRZ>`Pj#nyK5!~BWwtu zi(IeEZOjDCXGP{!;l;^MqsZ0E5jb@EVG@ZJ?Zr}yWmtRECE-885IfKmYJ!57Q5PFx=mp3@R;{FR<_-8b7D^!Oy|V?;OLh zP`O&IZC?N%jsNGf1k^vCThEEjcBYO1lnr3ierFsE>mwQA3u1o`4YkmHoOadgJQ#zp z+YL?bq{B{+YeTDC|EAgN>EqeT?I>_clv}bhSZ&_fZZOQQMaL3HLh6J;$6VqOCDEQ) z2k{v!-pz-&%ccNMk0NK4GU1<0s1YiRQgbboZ9vLQJiQ)U&XUytPRAEI{Jtkq82h0s z?9~Ccc8&JV$Zdx!E$3so0kIlr>AlwSvp5bnU+IR$QcMdOBakY_W5Ttx9e!qGr^zXL z6%?fT(aB6~$Vj1kg`BLYFSmMtx&HLonb?n~)Se8%`+?Li+6x(!NIsTGi=D`ulujO!>;~l!Xj(Yg&xBZ8s{r8%wnrK)0mQJ<6cHGnSVyt z0MLFN9Dx0%%HhLXMeuE=(!AkCx76C=M6NCAbUT3#CnEBb=kBN{z?|z3iAp)a>sZMj zL<&tuhY?1+#S`y*VwNF;lC7P|PzK&kJE1)nQ$tcm1w+*|WkeI~{ahG+?E4#(8LPh( z>nE@HPw$ZBkv@sMVPpfYB{pdv{_7@@-z$uGiZ{WS;DX&uQc}N5+R7$L{fEF_t`d&((nT`|o8vZ!GyPxqd=Q`Cnik?f%!lu`s#aG} z>fkumT295#bce3l+5^FKHbhoL)1u@zBz2cX9ex(`zk%tzx5J!~n*q%t;J(3h*o$H$ z$Ik}XsgiRkkZZLF&sLH2?o=bL_y4a0g1Sp6hG1n1>fC{k{Bl8_`L2-VIl)|_c4+Xd zTd{oqZ^syyvYV(%KJ<_kTDa2y^Ic&Oic%sh|-S>t@{(vpop zz3g8`{+#J}dl1#AY*U=QiP%QZb}7ME?oGp`;J^@m;4dIx}6#}3eBE%(I6~2xu)BY<6y#DcmQuw|CwYVzv|{F2$f3Nkb~t%`M7no zWaX_HS_Atj$q(`9$-Pd^a-zckEbWM#N;2(-hk-x@-0EoRz!Ysu<`1Ij% z7~1)aIX-6DLR#OpPCp(nI>c~Zz&=17nLQY^@BT=8Tw7G@GyY|tly&X&3XJjHH5u5C zg=EVu0D$kglCv9AeQ#7c*Y<|Xun=zzF~sCk8^y2LJlXo_|2poqDDlqA8f0}adcPy_ zZH&D3Bo4)hfYkP#6y#^!60*X5`Bz2))b%s;fkr{Q*v@Oke$!E ziJ7(dv>*eeYg6|IP|qeUidnxd2{eeS4WR3j6GZQ+G*-M)Rzy~Cj7y%mDz@4#klp5191Jx(e@ukUC7-B2Vn+xxi9s}uoiCszfPnlYPxLvf^@Eb` zH$ZD~DQHr&Vm0+yp%ZJPe%>6dDv~B5Bn?)f7b3db66XLHT#LnY)?U0+lrKcW8o5E$JM!Zru;yYeXz3Pe_O=^N(kN#IaaE&ZN_Mg%*-x^_ zQ87S>q?h6Vf(913;s|{8tlyqs!#mbJukEHwiP~`rHuUm2Te~kmHCE3kaFSX5HVMR) z2A3A^?AP_ueRM3c;HQ0nfyRGMvq7AxrL^3?1RhDLs|V2)KGq!n$ChjIf}DwT1{?*(#AXEUh@$Lk5E&s z;`*JRSsU<(;gd@u$ew&so1K100R8`9ew#iYG-WX|!suj14W$Wu>Zn_t!V302i-pV1 zPQw9$GuO*Y51?()!MU1kgdTgFl>7SBQys46poP59kj?1APwhJ}m|7NJROR^cj{S{I z*EN!2#6L98)94)^;N4ooRA^Hh6XZE=lx9l#nCmpyBR2R`jdLzqfrgCP$i1jxp2GS( z8AxwfV(W^PhSXDWBskl^)LLl1J%Mr5Ge={W8vt)m;5;m9SU?AKu?CW3g?h19u1=6 z*LITQQnt-);{*#_NADNR*~L%ODG5;nH@T4^i(IMv;@9*t+(lZ5|2#FsX4&n5&wm|w zvX;9l(}CDrgVycHZwIB;x60y|>{WfZn5s8e*ff*3Ize!UgMU=Q~6M)}PIUeq}ru zo387l=?zT&rxbo12f0#o3{`vl{!`{tNm}9Zk98El7~F{e&E@!)vY`(P(GyXzI0+M8 zNtO9~M1kDtVoJ({|0BfJ(Dm`DElx?U{p!7y!O7H~T&X^UEf zn(Bga@D=xshOJiJxH@(*KH^##LQ+9o;sFGO8h*W(Ae_@=T@|dv4Icx97f7S3l?J~? z2QqK0ozqAcmXMI|&zb$+uwAN2T};Q4;o0Mt^Sp{AkrS&<3rExRGfQUm8gQTAO~&&w z*0F0ElO)&ByXE-B{soB#$@>JMoO2Q7l=YtM^C0#f*3NZJM8r7!q$_f-YRIbLKQ>Lh zk7JH?omm5_Hk@#Cqj|J1T3djdw8@|twcKuobPe3vAkFHIxMNg|_s6?bE6U$r%kp+b}3K{E75Hv3)_bfnMdni7jIyQ#Us@ zJ%5H?VGwC!pohxj>(3$mkXdDXn?jc+C}5}qN_3yHqKElVN5*Z13@NAf*9>L-t7-B= zF{uJW$}a!$0S1-leyxC4O^xioj4mTw{M}m=N)}$jsPrXR7G&!6A`M7Wh4S&sV-;-Z zb*)Lb)8_!~7^KFNsS(LLf1;0ju)e{`Bm5*q%ueb(^NkVN16ps!SG*6QxGR9fujOIn z47AJYFW$H|CGDkDjOCaxFOYk5bpZo3bpuLTBAx|46oApuoJ zfe(3O@BVw>CEv$`t!bXP-z;8IU4@}92IjnJuMAzd0;sEombfA!@dJQD)5Ci*%(R-G zn>UHpH}|lG5l?j9YW!E0ZyZUtB82f|v7GiNw?@9fqrK)+O4L9s-MF32K?iLm{l@6G zwy)4L5C8u^TiU&Y=trP&($Aa|N7TWu|k+SI|?6Sbjcg|O|^ z$&Q|q<4JmqY->5>bkQGLFGTwDsdr?CYjvANkV^|uVg79a@D(Kn3DC0--NP4SD3)1| znxvBUQtci~k*j>SFYI2LTaPB<$1e_eErHxdufQ>C9C6;=Di%*z2B@+lI(E%?|F`oj z?Pyty5@7%`J0j5fgmW*Q?-4fLf&b?AZe*?)swaqvsdm_|;hfEg5T5q1E5G7(wc{BO zSG)dClcA77%|RNWq)-yyVV}a>;n&i)Cn1T%4g;D>+mKn1!iOE67H~Il4BXQ3dC)To zv146aFIsz!>bX#F=fn(W4xRO4$EA_TfCkl&w2gt zVrpq^`S3p{^bc-gZK=SwQilMv`O&@5h6$=(=uu-CJC0?yP`4H-Apx?l(_A7Qd>Db2 z*40Jo$I?E+So!_g3rHDr@=8d~_jptP{m?4^wp_~$0b4t<9)w+TVqdTUoI=%LNz`QK zSV4N-Lle%3dmxa*d4y(8j`C8FMIg-J@-B=m-N(TA;<6Evjh#uRd`Q&6$Hcj9LA4UV zo+|x5l5rJc(+NPH6+)av+FzbGrT*OQ{wIJ4)9D;_)K{#aF)`}h=;rMp?cbe3cZeM8 zf=SRhb?4N7yDtkY_e9B|Yg+k!@3-isViKM}Iy+FaL%HcpVaqTt&CE4+mf)rb-^2eS zifw=_$^M~Q7CK{1xOZ|AIwX)eHvOy{lcL_fv^a}dqqjMcp%7pC7O!y?p*K!xeZ_4y5CvruSmqp39g z<$w;gc62_R#_t7pg9AIl4f=0f75&l2Hz@#!oQGL<0=Cl9DSnSM33n?UVcoL?!mWTP zD|gG@vsUtm$1|m5sXf|Nl16|FWC!EzT1tUpR&_(1EV|8KMeTJ^Ecm2IVGPWalQ^Ws zAoBlB-1J!q;J|*j*1tov>HqdqNr2KkMj9eOyZCBJEE*_|prUiUP7gbF=?f~lRU%z`;^$S1Ii#mZ0Z83tUeg>W9brHL zK>eOmS3UCFO>}<#>1_)KH4gR?D|L@2&g7Ka(REY)*$^To;gLv-)i zFkxz4;3m^BVcL!EenFHXQr(ssAx8T7R=1f(iI1+~N;Q-%a2_i}BX`j*loE#>KXBzV6M zM^PCR{nnAA5oU@oj-!W>34*-H?@4gLOW%LYXPHS zr|G)7y9Tg2>^@cH!+}}VxSx$Q9rEJ_AiU4gbb_oiKw%AK0I<+T&+#B-IzFEmA3$2r zUUDkNfuhs05pg4F(CH>MuV|D|FDgVtJZvBhrWVt3vN|Xj`A7v;-KVEbwXB?ytajjf z+lPLY(BVpO=vzI6CQvWFPsiDA;G)_;d}A&;eLJv9qpe4;om;CX31CDP6$wp1e)OEr zemrYK$0d&ifT7{or>`O+TBZ>NjXx|WK)Sgh2xNy$IcrC`_dk33i32%SbbKO!Bz>)} zE7Axsz1-GEPW9NI&FIdoNP2<>?A(97OhO9ng@@tNjM4z5m`rAC#iozX%0#qc4;5U}m=mdK=_$ zWLyB`>Uw=$$xunGnhvjsdUfk}9J(f<82PO{HQEcvZ24AtPIgYQ1vx%6F$Cyrd>+}( z-W3UyK7mY|(jIC7r23xV=~DcFC?GaGlO^+Ppx6HtN2VQGg1kQA$8>C(1}I)6adU5( zAJAMglbK%z1KF$XoRqy>3+N8~jE~BL!@Ys>sCO(pm=4bj1mX=-Q_SR9uDm_M9~hir zr0s6vo{_b@fWr>gkoGHGfXS$%Nd1|5JgA>W`ctA<7+uByzNTj&>=bfnna98yi3R*M zoV*(8$fIcxd5a__a3OV?Z;(l88g-!;xF~E-UGOo1!BSf#B1*HMcZD)iNr~BFF&hi-l`50pSQH?j+t{(67nOb(0u6JODIC*j zz>vWM`?YDj#Hz^oP+_QQXxG^nAo#2@zXXEQ7WWiM)Ch%RZHh8)f%C9bMP3IHeMOn& zGQ7W3WSx-2C{o;625eKpHbF>6T9x=USk4%$0>zz#q{Uq&-creFlCQw6IzkeWp@1|G z5)PL^KC8}r-6L0@u{xZ;z3M( zo;0~|f2GC+fSSt_3+&D+GgcY|1$5(+?n%zd_6-IlbuFep^6^(EPbYlv$fW8Zu!dH% zp84YF(W6PP#P;$7#Z_uwKaC2+S8XsDwD7s~}Z^(S6W&^eC;eU_+lk8jc=dds8S4;0P zf3=^pzU@AYKgIfqe^mdU){p1``*rEN`*rZO`>p$*?DzlIIn(*i;Gc)z-#jJpsp~05 zn7{BoKKX|7oBSV8kI8?c|Fz&b0KZsxrTZ1jFXH9*A5c%vzKFm0dBXhj{Xej0^N;L* z^}8ef+4~%R#2BNmG1UF(3nG=~wVlqprbhXkA@4Etq1R%c(mOxryI&4f&%8Qa zPtIX+LyoB%drg`kA6vK~ve}(dnb(i32W(#8qX3L75bNQ2NANi2^z~WF^0+ahJY*Hl zWSD@q6sm0tK)vL^Y{K|Z!vgI#!zaIKUbCh&&TN;}sg`z5GV=%_K-Zvi9ju>wAP5dK zU@c2S((r;c-hCeT6Ua9$()oWVlZ7O2iNx-zbd~ZNW-iFXF8h!!VzsiTY3mcJ%wiy5 zY)V=6cA2ITp0ZbPds=<)jw3_uz(~kl@%88kneUsA?OuPGAq%vgA?#O|DGpb!Sh6hl zH|pKof#{c+mBWZV4nRn(2WbXgrGV;3k-BS{i3%k*hIdFw& zsSsbiU9m!x^Xsb%)Nj6Y?~^jLD3<10W!<`=g$i$?Idq%o$ziGRE*c)7s3UtAc&^!( zNt%M=b58)A|8_6j#;F8Ksp|wRidUM}dBxK30092_9l+0wf#rz+TX4MN#zM8q0{>9} z?q2`$k5w)nOSw*mDiY%*Eh&FveZN+|v8sVCcsj1-ssX;9S9i7hc32EY){Z4|my{^wAbH~#| z)uO<1po}s$oU?QuXlkn1NTCSEsPSPSoG>Ad-`%~t# zQ@oNDnP&X^&dxN*}YNa|9nYZ_iV+o#f)lI?mc`zYOm25zQ!(O{2w?A zrVlFR+_~ir1Hd!b3!ic)t>nbA)4El)_gr0$ZG1+5LapEb%b&2@u+2Cb%@+s-Ti#y4 z$EpHz@Pn&|GP@hE<_{bf!lBVvP_D_G=|!zm@x3R-_IQHR_ZBw(YC+orX-^Mh$g-RN z_ixwx;F{3rA@$|4~{$Bn2<=c3Akd;m>3e@h`R19G*=GjaZ5yjVK!TFez?;8j2IVfBDa<#_x zi)$P}w*C0*H{IWt6SwN61fO<7muu#7{rRdDW^~2e(|)J)UoyAVo*+M$3YA_id19XL z?OiR%5oG5-JDa@tO00o?kg^j}-dliuQi1R&AF~MEr4o%Dg%$g}HD?e+-H965zmi%S zoe+4l;0y4Y)3V7~|RvB^{LvxI!QW5c8g*pk5(yrF;W!L;WP!aM%(% zH}1gy7UtogwB-fYf9^79o@&w>2MZbHVRT_Rh42uw!qBJTbZ9GN%V)Rl1@q9e&|CJ> zu&=#=#$~X-J zHm@4<34tqXHI)6$VEIrTm>gXnT*a43e!M{DiJY~S#4A@Hb9)%Xd!?TZ0GPxImeJS< zbF-Z4qy!u1NM6l3^;eu6b^7P+T^#;8G)>MOJ+a}3DC0_ngcOq2Ugjg%=5+mV{PzC@ z%u-W2*5=)O@{*Ni>M_bAjpbMQmdTfzCgAMtqd%ulj%s|ekpH*EZ1|34Pfg~( z6lsbv^qVj=>N9IIr%?AF7}8v0nc9xlJA<47S7B<&@x3l93i9l#CQYoggBIh5Q7(kW zC~ON=ZSRIW!e4Fw;Oty5onb^BU&)_+o;GUgY%THguhiN-?HQ+b{-UaG3#WHwxzHWl z)j>V++2hxG?6H-~Uxn!Ko{`WGC+x>EI{jH-{X2Vl}F9AWe0xy-042W#=WqP|* zcXsX~n&a-?G3BO2{BXNuzmp)IC``Q{fx1IWN+e2$ zsh4qs&j5fu+tJjI@(==ntaMZmiW`HlS}N_w+w6PmtlXeMkIa&Z!wLdF3h3`yGtP9w>{J*n=);N7F)wcv5o$=MFokHapRsZN>~;n^L6yTpeWSgn z$ft6ZDx)NodH}i5%8m(FlNR(67U7OOY@GLuhLZ=(*XX~`MB&B+jNy6En);p}w_W~$ zbO$=B8Ez-_bmR$oQ^q%7_>jZ;s-#pmBLFd*K)_|hNRX{ zhQSy>1ZscYhW2jxAU{H&9`yH)PWZ%PzqBeJ#XO7dZ#ww>_iNinYn%x|&jNcdZ+WKX zgd))$#C?`JqLcqYc`wW;{#X;>IY=el1QwInDt4&K=?@Qhf8_%Nzo!{fsswi&N?mjcTB$SAN8A>}ewI%o@#%w_Y-Z{zB@_huAa0N(0n_Msx$yjw(JKY1`J9q9tNzZ!I=%OW7!Zpf z+k}I(#2|3Ryf84mZ(Q4oHpz=pM>m2zTh*a2`McR$TLe#>gXnRMreb?>)V;mZbo->6 zM!0&z(82QbYs=yvLW8l!v1eLc@Lls`)7SE%EPan`)cD*})BNPM)Zh2*00+Lvcu=s6 z^mu?+Occ1cpA%)I%*x3j)=iN2ymi17(z5&uHjm%lW#s_pV?br=?~f-mX0Kh_M5wz>?T=`*7353U?(xIV06kVO9qWW}4&qVLu!8^Ar)eWpsjbZ{PYV z$kr8eU#u$0{xU={F4~p6`;6A0()Mb(PY2~Y(=`fl5h_c(mv4~F_M$eT-=K8(JD08z zC2%t4a59IMzPNI7w^91424}Z)j+N%WmP*?#j-Tt!ldB_GyhTeSBFVyk$rLe#bP5#M z)4*Y?Af#hCe?CwU=1uodVi^)TdjvmUPD)kc$)eg=O1!>pMakNg^a!eRbxF=34gQn+ zBvEvSx-X3`4qRfmSs+oCzk}MSyy`AJ>p~{Qd7wDGDbZR@I4W)ya^oL&_Y|6*x_*5h zI|2Op<76EW!#1Gj-7YK~ZdD13zH;71R0rzH^tWrvp7~6?!{yT%$9DKrcMPTytZ#O{ z(G2VTT5=+1^IH221rCO_%IeunFcx6o_1{W9>am(+&<1G}&mF&F(CnKLZ&>0b8QM0V zK>6E(Kkm~{#Rm3Tz+W3cD3SlW(bx$uF}dkhe`UJT)+BBA7Z) z+BE$Z3yMy)a8(IxX3R8SuZ*mP-DHmQBBxK9lg#sK1(AS%NNRaaThzd9^tU7n7c%H* zq81ejnaOHA-I2q-#FjAnvBU&2LZd42n>QJg6yUglVDRH{-daM(=kq7-5|JdoP8#YC zMoNwM`~cWBjF6FA5HwPbTyz4q61+0QIBmeaCK9~Km`zcUy}ccE3MbeYz)u+!piB1| zElj=7?3Wpeug8&gIy&LzNS*ADI$zTaCZKEj<`?|57TQAi?W^B-e!}4y&i1%9VH7ssim$a5jO#6WD^C5)p9M)V=+i!B;e$0R zH=oUb`*8I_4O4jj@}+9-$4df?zo`!DJ>OW;CsZ;C)Xrc z6#fgM1mqK|@|#)%d?EHf^;hK82JRuIDfb*lf!g@;Mx09P)LY<}y)GYI!X5XM${%s0 zk`c{^IE0#jh83~EHH69Fmq_5oa2#N-ioKmo(zWuJA0)@!k?BsKghFOg~_~%+ivivKaaE&UKq=E^L7Xo&D);xof1?*n|m_`u}Dt`(+k`zgjQv;U!GluS?AZba9G1U(3Fw37LLO5tME%FU$cW_-9(69b4acNGWkTGVP!%FB;A6l@+Ed9-F~t zCx(Y;|L6s2|MRCoNd-F~01~d89-}_GXs!j7%AvCmh-yhu_60$|IJO2;;DqQ}{H|A*BW+i==9m>cXfW4; zfhW@vbWTllzgmu#sF2_83;;FWTUa>OV;$mbrFc4tI}uuZUZ4U?6!=N8!{6!Ht~G6wg-U;qFB0000002@t^{r~^~ literal 0 HcmV?d00001 diff --git a/site/public/brand/companion/invalid.webp b/site/public/brand/companion/invalid.webp new file mode 100644 index 0000000000000000000000000000000000000000..28937a9e3c74ddeb1107bde53170e4995728252f GIT binary patch literal 6416 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 literal 0 HcmV?d00001 diff --git a/site/public/brand/companion/pitfall.webp b/site/public/brand/companion/pitfall.webp new file mode 100644 index 0000000000000000000000000000000000000000..1698d950a0078fd8f690c16c1b17978d9838b7aa GIT binary patch literal 6210 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 A2mk;8 literal 0 HcmV?d00001 diff --git a/site/src/components/Callout.astro b/site/src/components/Callout.astro index 5ae173fb..e009af5d 100644 --- a/site/src/components/Callout.astro +++ b/site/src/components/Callout.astro @@ -1,58 +1,85 @@ --- /** - * Callout — a labelled aside for notes, tips, warnings, and platform hints. + * Callout — a labelled aside for the eight Guide Companion states. + * + * Each state pairs the painterly companion sprite (a trailing-edge badge) with a + * state-coloured glyph + label. The glyph and label carry the meaning, so the + * callout reads for colour-blind readers and in high-contrast modes; the sprite + * and its state-coloured screen-glow are enrichment. * * Usage in MDX: * import Callout from '../../../components/Callout.astro'; * - * - * Start each frame with `context.backend.clear()`. + * + * Pulling in several assets? `loadMany()` batches the requests for you. * * - * + * * A sprite's default anchor is the top-left corner, not its center. * * - * The type is conveyed by a text label and a glyph, never colour alone, so the - * meaning survives for colour-blind readers and in high-contrast modes. + * Colour exclusivity: amber=warning, mint=success, gray=invalid, red=broken; + * info/hint/pitfall/debug share the companion cyan and are told apart by glyph. */ -type CalloutType = 'note' | 'tip' | 'important' | 'common-mistake' | 'webgpu' | 'browser'; +type CalloutType = 'info' | 'hint' | 'warning' | 'pitfall' | 'success' | 'debug' | 'invalid' | 'broken'; interface Props { type?: CalloutType; - /** Optional heading. Falls back to the type's default label. */ + /** Optional heading. Falls back to the state's default label. */ title?: string; } const META: Record = { - 'note': { label: 'Note', glyph: 'i' }, - 'tip': { label: 'Tip', glyph: '+' }, - 'important': { label: 'Important', glyph: '!' }, - 'common-mistake': { label: 'Common mistake', glyph: '×' }, - 'webgpu': { label: 'WebGPU', glyph: '◆' }, - 'browser': { label: 'Browser behavior', glyph: '◐' }, + info: { label: 'Good to know', glyph: 'i' }, + hint: { label: 'Tip', glyph: '✦' }, + warning: { label: 'Heads up', glyph: '!' }, + pitfall: { label: 'Common pitfall', glyph: '?' }, + success: { label: "You're set", glyph: '✓' }, + debug: { label: 'Debug tip', glyph: '[]' }, + invalid: { label: 'Not supported', glyph: 'Ø' }, + broken: { label: 'Runtime error', glyph: '✕' }, }; -const { type = 'note', title } = Astro.props; +const { type = 'info', title } = Astro.props; const meta = META[type]; const heading = title ?? meta.label; +const base = import.meta.env.BASE_URL; --- diff --git a/site/src/content/guide/audio/audio-basics.mdx b/site/src/content/guide/audio/audio-basics.mdx index d07bd85a..1ea87828 100644 --- a/site/src/content/guide/audio/audio-basics.mdx +++ b/site/src/content/guide/audio/audio-basics.mdx @@ -60,7 +60,7 @@ if (this.app.audio.locked) { } ``` - + Browsers won't start audio until the user interacts with the page. Trigger the first sound or music from a click, tap, or key press — the embedded examples below ask for a click for exactly this reason. diff --git a/site/src/content/guide/getting-started/setup.mdx b/site/src/content/guide/getting-started/setup.mdx index 7cee9e7a..d7410927 100644 --- a/site/src/content/guide/getting-started/setup.mdx +++ b/site/src/content/guide/getting-started/setup.mdx @@ -75,7 +75,7 @@ const app = new Application({ canvas: { element: canvas } }); // ...or let the application create one and mount app.canvas yourself. ``` - + `app.canvas` is the active `HTMLCanvasElement` the runtime renders into. Append it wherever your layout needs it. diff --git a/site/src/content/guide/getting-started/what-is-exojs.mdx b/site/src/content/guide/getting-started/what-is-exojs.mdx index a0758281..46a75796 100644 --- a/site/src/content/guide/getting-started/what-is-exojs.mdx +++ b/site/src/content/guide/getting-started/what-is-exojs.mdx @@ -47,7 +47,7 @@ ExoJS ships as ESM with TypeScript declarations. JavaScript projects can use the ExoJS targets evergreen browsers: current Chrome, Firefox, Edge, and Safari. Some examples in this guide need browser features beyond rendering — audio playback, gamepad APIs, or multi-touch input. Capability badges above each example list what it expects; when a feature is missing, the embedded preview shows a clear overlay instead of failing silently. - + On startup the runtime auto-selects the strongest available backend — WebGPU when present, WebGL2 otherwise. You write the same scene code either way. diff --git a/site/src/content/guide/getting-started/your-first-scene.mdx b/site/src/content/guide/getting-started/your-first-scene.mdx index 702f2262..17becef6 100644 --- a/site/src/content/guide/getting-started/your-first-scene.mdx +++ b/site/src/content/guide/getting-started/your-first-scene.mdx @@ -59,7 +59,7 @@ A few things worth pointing out: - `loader.load(Texture, { bunny: 'image/bunny.png' })` declares one asset under the name `bunny`. Inside `init`, `loader.get(Texture, 'bunny')` returns the loaded texture instance. Names are stable; paths can change later without touching the rest of the code. - The default sprite anchor is `(0, 0)` — the top-left. With `setAnchor(0.5)`, the sprite's center becomes its pivot point, so `setPosition(width / 2, height / 2)` places the sprite at the canvas center rather than offset to one corner. - + A sprite's default anchor is its top-left corner. `setPosition(width / 2, height / 2)` then places the corner — not the center — at the middle of the canvas. Call `setAnchor(0.5)` first. From fc0c949c9b5f83864116ea872cbce896ef8e097d Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:54:59 +0200 Subject: [PATCH 60/68] fix(site): rework companion callout badges to match the design mockup (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the 8-state callouts (#210). The first pass used the wrong, stale sprite source and rendered the companion too small. - Source: slice the 8 states from the production 8-state sheet (exojs-companion-12), which carries the finished faces + state glyphs — not the raw faceless bodies. - Crop to the upper body (head + torso, where the expression lives) and seat it large on the callout's bottom edge, legs clipped — matching the mockup scale. - Flip the companion to face inward toward the text; pitfall and debug keep their native orientation so their ?/[ ] face glyphs don't mirror. - Swap the weak text-in-thin-circle glyph for proper Lucide-style SVG icons in a soft state-tinted chip; sentence-case titles. Verified all eight states light + dark and side-by-side against the canonical mockup (scale / upper-body crop / inward orientation now match); astro check 0. Co-authored-by: Exoridus --- site/public/brand/companion/broken.webp | Bin 6220 -> 15914 bytes site/public/brand/companion/debug.webp | Bin 5826 -> 20282 bytes site/public/brand/companion/hint.webp | Bin 6874 -> 22538 bytes site/public/brand/companion/info.webp | Bin 6414 -> 18464 bytes site/public/brand/companion/invalid.webp | Bin 6416 -> 18808 bytes site/public/brand/companion/pitfall.webp | Bin 6210 -> 18548 bytes site/public/brand/companion/success.webp | Bin 6276 -> 21844 bytes site/public/brand/companion/warning.webp | Bin 5814 -> 21310 bytes site/src/components/Callout.astro | 115 ++++++++++++++--------- 9 files changed, 72 insertions(+), 43 deletions(-) diff --git a/site/public/brand/companion/broken.webp b/site/public/brand/companion/broken.webp index 5f540734ec7640b2962b59ae3247f740f8f1630b..515a28980648e778a9d073e5e74360d4fe5bd3b8 100644 GIT binary patch literal 15914 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 diff --git a/site/public/brand/companion/debug.webp b/site/public/brand/companion/debug.webp index fec154eb91b14ef408a6c296ce0c02862ba95b85..ab4940b51c57bf00365ea713b591be773a8eb02f 100644 GIT binary patch literal 20282 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 Mpc?=H000000R2x?b^rhX diff --git a/site/public/brand/companion/hint.webp b/site/public/brand/companion/hint.webp index 3f56f6801ff8537c92a6dbb0c0b7e2cffc48f25c..eea7579c2c913b9fca52a8b2d6e88f936c6eb7bf 100644 GIT binary patch literal 22538 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 literal 6874 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 TG(UE&sPzSWQU15sI|r};0@vcG literal 6414 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 diff --git a/site/public/brand/companion/invalid.webp b/site/public/brand/companion/invalid.webp index 28937a9e3c74ddeb1107bde53170e4995728252f..7c00c2ac6dc73288d519eb6b31228d11eb639360 100644 GIT binary patch literal 18808 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

`U5`oWzt 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 diff --git a/site/public/brand/companion/pitfall.webp b/site/public/brand/companion/pitfall.webp index 1698d950a0078fd8f690c16c1b17978d9838b7aa..4609d21dd894b878cec704d2cc81404f68fe2055 100644 GIT binary patch literal 18548 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 literal 6210 zcmV-I7`^9GNk&FG7ytlQMM6+kP&il$0000G0002T0074T06|PpNHqok00E$f{rBlc zI#DO7-AqQs*tTukwr$Va-W}VvZJWhdX{Xp!H_2JFx%W2b{N}HHHX|+G3d@Q>BqLR4%j52=~w}E!or2gXOf5f#zzTyT=lU+$^4g#-V;>rqx z$U*7n*}bj+Vm~vxg&_Fm%URvdFM2Rg`j=x8h`on*$19F39()?27c-5eZog@N?7J9` z*-F=TEuGoDa+p@I%sp^}codn=pjglZwG_@RYJ6?muyuFwk9P^T)<;o{_V6jgQf z5T_flPP5cyXhvo01@PFOsoypQ1cH2C^b66Q$jsd-h5;u1dgWD4fsgxx)Z#3W+g zL(J*Yr9lDW;XUXcx}*I>Go4_R-JXeVn)tm%Bs86)(Tz`65eMDKM0D$zW}={-XhS!D z)>IG%r=Z(MR~CY%?~%X@5du)~CX%??#Gc1eq%m_Cdu?q*U~Ea|0*)h@zv+1EYp3IV z71n%?Afdl#IBSwhNo*u%KO&{hp^TaDkb--1@zp4mmY0S3nuWAFy0hi6lNkJgt-R6H zobcqTb`mwQ;XGMp-gZ(W?XJs_%cBUiMpK#-SlB{E% z%#?mdR$MqUT^(d4S7l}@k}b7i<|n$GJ$W(grVEGhVme9KAZAX`?48sL8(-%^X5)JGlPb`7GU2nC5eI^c|ia*?eDl<%6Fk4f}Rl*Ke*Z5wjhA{ zI{tL>d79JB9(`5U#9iq8M-q~lvNDJ_(@G@M*@d^psdV6Q-lij+nH_kWO9-AV%9yFm zbxQDT4IM)UFJE(+etT?4>-!bWJ((#yclW7F_pMH(H+5ZZR*GLo!soN{4N`u|pAoM! zq&*5_#P14e@9Q$+d6G1|9LWbPrVW4B^U+13ExsBb!RKkiUo>n$r&QV=Soko^&mhfK zhY!d#_+BPq&TfnVgdZXdN3hb&O4(3OB5okz87&ww{DzdjF3L!oKS?`a;v?`RQtliC zd=9dcb}E<;|FcMWd}H9VMH*$&Bw+LlBs}wL6C>Jdc>F^Y@Eb7yC1U(%8`9_)4Z#*IaI7gYXnZ}5X<53H zh}Tts`G97RNpRd~2TB`40yR7U%(YW+CkhnSw%Bs_`r=;Yf;1c$r&+U0NOzkZI)zMA z6A=*|@z<9RuNtJG4m7paz0KlG_11^nWf~}JUM!)DiPx7;Z5(bQ4Zu)(;MYrk8Wj#u z_^Nwc30J1#F_fJecX(mD!rp|rTzrEq=Q&Q62SwXQqq5S=)4#SbFaRFk=e#qyK}qAV zcvLa5F$d5;=luu@28=tDimJ|Ip{^~*O;EtJ4AtJC;~?s}A_;F3-o3NFjeF@(z|mQO z=4q;(^X95tARX_PeCO?GRa}nKso9 zMgohYl-Q`t27XjvhhC1~_Y$hT&I~-(N>N}GWZy}WHy>o?+vG%*rNzO(r1_paYk3#) zJ(eZ4k>R-kRsYEZTx!?KJL=_vW{Iq{dNN8_)td!;PLekfTy6M4&YKc4EcetqWaOht zyqw2{T+;Qt>2OI}ppyY#tG8Xxhi=$w*RC}InU>y5uf9#Sqdh$_ZhwCh$gP{8Rz`2& zw9>bitr8+S(-W^Q2nPiNi`VNspmU>2;YN_JYrpT6L#gM>qixaSjjR9!lx^3mSMQ!p zD+WQ%Hx}*j%cTSb(!VI9kEjt2g6&^n=-T_KuBA;ifS_y5jppuuE@hKCSqCDYt5B!! z&c1gd#thbj@cWkTJ!?Q=_}~BkK??v@P&gpo5dZ+tNdTPzD#!rH06s|~jzuD(Arp!$ z=qLn)vA1x$0EmkXu#ne6UpKQKO!-RmU(CF3+=)?Ta zs6Y1)@t&1l#~y<}w0@la%s<*c>3-^aPJi>x9sU9MVc?JV-^o8We;xV%@jsYcoL-Oc zx9oqvdI{g3#GOHXY5uo?Uefvydw=#%`TyX&c(q)}55CjG)6G4AzncGl^=JOu|9|<< zLw}wBoBze`7yPsPhy0$#AGsdJ-?7CrCR03jDV{r&YT9C5hL0&C+i^Ke@!X>!QPkE) zzf36j58OK*3QW=pIqfumxku_S21*lMoS&I^vf*h$a$?WFpi$W5^6jPs(#TL(=wb1D zewpL3NilGz6{;5isNdXAGXE+g>=)3iI@v8SpwjZmxf9v@>z*>46xl}cR0s{5|4Vb) z=w_W>cZ0Orb;Y45sOrlHJUWGev)UX&du@Sy*7$>~c|yREcYTxSOYPZ|q1#rxs{0KT zBTh^FxNlVR9Pi*u?WXTe=0xAJ5Q$fE!!uqD(A-Iv0_;;_3M z;e-KL%JSeMFY`l`%JmCqAHY0Q{Q@9hP0%6?MP{b6zL)(l?8+5WGH}ZVfWCP&&c|C$ z@@15W9$&7m;{n!KoJMV@ea?)zh7_t zl##sDB9@BVfh$!Sn++~WE6LxbDs+cOr5=E0S9iu=6qsXXY{U^kS=5FGM)+eSnC=jo z{M9r4zG=`3{C&uvSkASm;#C-`1v@6%&`0II<>kY5@Zk7O$ai<|_mVsxb)Wq6G~Q}P z=AQyT^Y<~?7muU|e{15v$>Mr+u0_^80I1sKudLaV5+7rERS?ukJ~7UQ{kIrho-mES zf*Bb_qmMmbhgtot>}6!ahP1j29ys{@L{MBBKq>;jX~aWJm-d1Z=ovx^O=A=_UB%F) z5Q7?m#U@*i%^tz=N`zN*f9lhJ-+vgxki*yz4?P>e20SxzlW6&7$-Ei5z3}sgv$Y+j z*p%mgK%#@g0-$cX00Rc+o}6(O?Kkm9GVZVS3Ej}k55?7uB({Y&*fZ7rkqv%+H2;om zOZ6OT)X|J^R+CBJ2{qOv5Qgj=gv^Kmii+5M$Y;Q$p9ZaP6<5G8T>faIHU_F!G6;qL zb+M}>_mO~utK_GU|2P%SiwZJH`1PCR9Z?(Rv#V9U9$AGw zk@Z!6^hPiDO$LpQ0T=&j_X7n@^u+9f5MZ<&KEp8EpN4c;s=-{2s8jb96P_?gWO^m- z0kx*PadWx&ui=tvCl+62kVJt&o1iS#S=@~(F;uOeb{7FAM-aCV8}c@9OuF$lUs- zF0*M=+7QUL`9bN0R4oU_F3btBrG|^OcQbBM%fkn!c~up~2IHsbHCR^^(VgznrtzuJ zyiF@RPVy$?y^xT=GcJGT0=IRdHJikur;D43agOyYYPe<&3#6KY&-lTbG zijR~trW}L*zL&bS!LofSA7{(~Ed0Oz7y3ajhF)cE625op+Z;2WQw1Dk+m!@3cxWgO z;=gZX#2EJa}%P>xnD+$*2Ad!CX7KI-2 zJRY43NA0vp_a4aBGUMs#hYdA-)^f6&^cFX+Lf1dZF4==@mxtINi8^$%DX9xlh663q zBE$R0uE*X4z>o8NXV+(w3wGzuU`Y5ORp7;HX+p>XqS*eZs>dt#*(Fzul!B0wQ6rx#KVxl80zvP)Xz?+o$`@tg_yz!&Qw5+Qe}L^5FE# z1&0Xn(f)b&$+@uH(_#rl;|c4OE4t{bg5LV=s_|iBbG4Ugri=zy6UAXbErJ(C<0 zQ3+;F#8hn;TUxAxzrM9_wD@BFKPkS$3I`9yrLl8MYJ%Z{f78+)8m*4gaDPdDHhR{u z$imeUVFXhoDK3}DVI~HA2EGvxhM)MIUD)}6p&J6njJJk+n2}?a4KBx@*8$105)IK_ zp>zbs6OAfzKjGnH6MSV|jC*TN8}c%G;aPah&yO63&AE!|&o2uLV(p)MQUKCb-@_)8 zzs{hfp|vZv{ZV{bC7=kQ`Vd$f*$z0!5x~sfiZPv6T#0li-ABnLtll7L4Sa@O-IDHr zJ9A{8#zjVTG6sFKh<1ekE`6DjNfZVn&69z8Hao>1C2wZ#xYZC$FtX2ESaY5sFh-i? zJzkM6G%>trk4&qQP+73jy+y{sv9t4Xz&Sz%EQPl2J-P13XYrnxO)lPheb@}ImU*Zp5y{BuSy?h&x6KewMiE)*< zFtp7D4r|1+kSwp{ntbCTy^M48XeFi=j2oC# zABW%Hn)$bc@C?3gr^Rf_-csAyK|M0Co_Ei?nXygoO$o_Z#C>u(`zhzq?OG zRQ%bk!F!p8dGN3z#CYGXp8n<8SmT!ZL4vnF(U4tD{fB=+Y(UR#L1Z@>0(>bAg6KS5 zW(84kt37Bf=)kByXN+E-A1?B8D~G0!nBOk+6Btkm%ga-~;41+i91hR_N@}*UUqPIj zcU?;?!R_TFf9bLI+Dg{Dr!+nbb=A*qn8!>4bg&H!P!HWyf+rY2<@2-ZNfv4q{H&Fx zwLr&yPSX5(WylV)9NbXX3cYnqmq5`H0K2p{=`+K%ro!C&!5C~yCo=x61U>i{SBnR| zuwC7rjw~ypkJdl$cm6fpu4vPQVPXu~Us9e!{S1P@mSz2j$h(j0V{<3+d)j=a3!Noy z2KY=_#J}gg4W_$|z|GM zY*qO}Z_qj|@f2A=Ow|NY>Szjmz5)kCDjgHNOhnug4o%5L8yBE{@3a!Ou$v<2@wE0 zzGtfa0D;`=j!dQ)=h7V4YZSpPFr<-mCTFMb8>T~<@1KBU47)~E^WEHK$Skb20cYg_`JK=FsokofXyEcdYg|M~r&eI6I=%fvUSQs2 z;E~TALji;%l*5hPxmvk*pB9oadQDH+QP3a%zC1mgx4>u?XuTOimQ8U}O7BfUK!@pt zVrhEo+Gp@mSd2W6j#Ycz6l)FHV#IFe7_2DY2%MFjnD|@h3dtCTcwqzTk!p21VXIeH z=_&-eakhmA=aq0Q#~{2q@$;|)pzE&e7%9qxo7{((A|58wZwud{C7w3F4~JIJPiGzS zj$Wif@GFgVfnj(cG5J#tr8P%tN9*xc(*#D5PP=fh*y=Cxcu3jB z@^AYt6cIWeOS3Rx@?quWi^Yr8;v+Qo)KJdeY1`jw(Q%)(Kws%UjjBI zJeYSJizqQDF^i{xP)Z1iRlQ3*_pkS4%-C4IFRF5*fJK^JT8ttBQ|B&^zR}ioHPFfz zX`Zo#ViWr@-;>r%hd4`5PS=~Fc9h+5(76u;YH*$fToAcHO&5oB_-nDnfn%$J?q9+< zG=EqYd-zF6t2zi^gZr(r1Zsfc$BbUhVaqTfq=rVD|KR6!B6xBGby*GPL4pKDUpMxN zg{A6i2Q@oQW+2d&=zV9-wWJg<8D*^0g{Cg!fNA z0G7cY9FyO_NZ8+M4oc7Tpx%8qRKn)(S%CO{xu;%&6%K=El}tL(iDydp|F6ws&F=YZ zKY>h?3Q{%pV+v>BGBd`2q$2pWyn~2IAHxr>opwt}sK?Zx{64AMS z)9Zunoi1L}*N4K5ZK$dXRiof7-8o-LJy_!j$OAV!(0??MX@;x5c|WRRC;NsUflXId zM?CShVZ~na5C{y#DX+77TP5G)jmiEIakkR53O7djmu{2k?dwKuoNsk}9#3z*0nbH8 zEM%RPMpjgKEPjm+uJCDL>XT`s5A6Zy^L0AEJqdnA0M62uw^?S`&o~gBDYvN55*1}t z80%|`n!vJZ=iRFhsN{-j#`Rl(vkUY#0=qGrvqj4y;0RJ(KMM%-LPiKPnJsAQoDs%q zw>n@6Er_6*(Awc4Sp{%wOcD@euP<3=|K7pJH>>I6#xhq^pdWvn26JVwsDgR8^LCck+H$@W~QG{owDC;C^B2k+pyH7=PWTV6&@%exuI% zJpi(bp|mQ)LiI$ZTeLP<;g9MbKC}5)^<1Wifc@(mGr4zXC*ld)bK`T(tJk)igR3#E zpSW1*5aHs{alh0o>7*JrzJV+f@q>(E_Xrs!fqWi=R=EzQJPIcA-{<@5j5(Bl9xwee zrww2=3mE3Gfm7k)Ii;w56z@n(cZOZ??m+{_=!SAG{Z*`#@H2k5%Jv{oFm7E0RylT) z+$M*i9?EmBCbzzEQ{Wiz1^WE-jBRwHBb z1Q0unQyE)60#|P~)N?7*>1>zM_j>XuP5Bq#dQ@o*hpP6kO$OVOaQ;+oHOOlF*_S&F zcqhtpt`2Xib`a!VU6d;c`Hq16+l>N83OJpu!1KW#{K&KEXHYQbtdSp;+LxgOB#aB- zMFFE7pscl%o)b-SfWo$;b@))J&7icDHGmJzj8_ccaX$`Z>=g|-UM{EOJwil0VEO(x zPu^2>P6{3nRf6}_{ougFdbc9qX?_8)6}#%W&aZ&4<^R07&V(F{+6r8!>0cr?sdW+8 zHU1*u@@Z&(zJr3Nr(p87B;vgRAMl7@;>~>uoHt{!7x7oV;=!59ZI>x$OGU(+Ti$WVV(+NuPJv@=a%3DUh4^!W?{Yl# zM;toxx?0qp2{=hgD3ylR;)F3*crQ zw-df5zKbuTw8;iV|E-{PGgHC(un|KDf}qi8e?;M{9m+W3sv?a>a38`vfSfMu;2Ykk z`}Ea^&Yn4Q=JbttNg04!d)7>;p>rEyI{=_70~8%KbJna`b5`s*ee-n+U@Lupd1T?t zS+i!%n$yjbcEYsR05IO9?sSFgHJOzLgg@%rs(Fig#b|a00N`{pb>~0%m1LJl6KyuD z`0Ce?f+m4hCw{QbQ+FXTKude-+YeK{D5W|111wi{wJzGI@#BZ3Wpp$$Hn_t8-GQ=6 z%lv5+ur1lip5lUfE3-_Z416q1qu}@8GmT7H!#6x>c9F{uf4Y8-^zt#dT{NRhO~GV;YIus!>Al2nftytjjxaRkT_^*EI!rtE*D?Z8U=HiIm9K z^>$oz><5QS03g1Jy*fd!s!FB;o##`)GnYi-;rNbQ>g_Rl$uSRSMwOa z@^KG?O6mc$UhPX<*;Uziz)c?&BPUkzy$cYRII!N3}~&{G8&>YRxFKrL~< zK$=I{R;`ai=%*36wyuFDMe8)X?L0oCjsG5kC_n*AY26vnm z``>0QNeKCke=X%{R(_;ng<0FqegZe1ykpDY;sl{Wb0Cj7a|b~6x8)Y{FWLCp1;))q!CK$-b*Z-LaD*Rqk@&wZ!0Q2Ag%$k`2;Ib!D-d2tUTO_QuVBa5|{_tP5>C$?@v;+DY6f ztfo70M2BLg``HF+-}+xW4^s54zk&0Ug#~;fcRPWkNX)uFkW?So5(kl?L0w)raea-AJm@M;Glsw|HaA@`h1V$GHG3FNTk<#dBc9Z@UZOWGFZMx(($1cc?C1csOmuZW0yBQ>;fGP7xnDtk#};XTq9=P`(&D4Y4q|+Lj7n`u1xX%Bf|E^@ z>G%Zl?zmA^z@qs%V9LjB@$sy=@pQB-(#Oj~!+n(qAAA#BP1`rIBnHA1!Iu z?nwG@e?>iiogm0l{GOdb-i2$hLdeiAJ(uGJ1^G_Dwuqp(`1#Jcvr{X`6PXMQYQ7ML z87jQSjez>?YLa}|o!tbX0(QLAlm%U8N=qJT85j-zU zvtbz$06Z$ARmS5VVB`2un$K--o4HE6t07NWEA7`3?#W~2 z7uIsT|H%#M0>c_T2j&+0>D-P!bU{*zjlHBx9dt&)t!URh{RB*&5q~f62qw%vdqi}vbKfOuFrRg4*>L}-C*E`-6*FS z%MZF!uJ1#BFwU25>wq8L>Pok*907i(+ZLX%f^J(mnXb-7PUgKXk(2pF0WW*}|Js&w zv)#o6dYSmbvk`iF6?n=Oq|+YM^6m15E5J2+6W~MnOC)0etm|jCn!8u>K!U|a`ZTu{ObL)dCWr)#NiRK+eXC^>iUyaUB z3i5f(6;{d7?9Ozj;*Sr>fa0%|^=Ro64u9u~|3 zMDc`9$n|{BWcWHJhz#HJc8<_p2GbK6tWj0T@E9M%4Wx{YG6YA-0G0-12*zF9z<+Ze)7j59z#vccG`gV3Oy7|1%CHAE;-uI1VH8MrUOL90Gk>k-IhV4GAiqIi>wXL#1#+-YV$w8(E3}^ZD zbqW%Y%;i_+$PwSwM3B!lcFVY0ub2tR-aku{Bd#bSSXcc5s~v1y-GCGv39`oICkdf` z&zMYl+7&4~RHg!C6!1?y==7es z($|J4v=iur(4c76N~~{CrtedkqJ7Cj!*4%lD$A81B!6hrCY|4c%pbax@gYu=uR~nI zmoLd8BSm%fl1t-NU|Y7;CdmcaTiS;dDir8J5<=dk%9bhBE`^c)HVJa&|K&~=D4PE1 z?dzAPS|WiYr=Fc(GEw4EBzN6?&m4eZ=KYiFJV`_7w|EvhR#T9v_Fg;Snze9hHNrHZ z2iZr;7>(rB6xeP7oOg$ih0qH25f9gw$!qX)!23)A4O!FDn8)5jBU9jQ2{Y@JV6sXs zW1Zw-0Z86_y}ttPwv*+ELN%`EFpcAOJ|uI*#cb>Vc2p(GulIMRc{j}@S0gKsIG-pK z5+gB;xYvkevk)~m|S*Z7aY+~6` z49Op{#zq93(-9Ut^0mkySx5blf*l@+WSk}q1@-?RF^9CzOHiWyU59%T3iOO&jz5Bt zqHS)vDTMr5Tx5(@rIE6o9*XV?p%&Lz!m_;>Qn+uTM8;VvpgK_wX2?2bLW*i4BL4s~ z6P9SHfGWhAtxRBZ?DZCu&>71fcp&ndj-V&*fBFP!T=Db{YWE^DJ%_k69yBOE^4#6~ z_aDc;GpjqNmbbSnyRtg!;R^>)@OBH*O3Yld{?Ky=u;lc@dGTnvDgh`NJD((>V%I)P zlYuqGhwbuEVe%;vXqj@0oZ#^Ko0EV^mk0TWMf@1L8K_E###6sI>zi3*lZ4$P-=?Le zrlzKtRY2jt?tTSk6~DccfW~B$-zx%tB_eDS@K^$beA|JK8Ros^F_N7C{W;*VUr*y_ z>hqe*+-i(p>8oZga|+>KAqBYXaUCa1U!&z@J4*^&Ox#lq5g4AzVW24@G3*n6vA=0R zM2V9b+$Fx6mI!0bXWZp&Fd|cXO5`p@2=P2-tE`B!GD$AT_J8v~@PE+%s`&)|js2tkhv)x! zfB3)pKm9$zKcIi4|JCgE|3CKU;Sc(k`=9-P>wn|_|N0sJef{_Tr{}-O2mb$15B)q( zU)Mhde_wq{{`LLO@+0?i)_=x+hx^y}pWC19p4ngQd0gvH`o4{S9Q(of&+dP^KGk?r z`ETzZ^MA#A3VuWV8~m5$*Z8m9E^2M({U`koQ6F}`&Hn5A=kE{s?@nLvy)pY6|2Nzt z^=JJ@_bycb#{b6sjrauqq5c#9hxspc-|N5gf2#b#_s#jw`LFyR=zau$n15&gkNx}n z&;GykKmY&v_uhLPe!~CdJ|d7AUKFna?Ll4VG6YWtWOv?bUxr!1B1P>b{cy8_L~qgx zhG>Srd9~u81|EB83)Gz~Jw9jaM2p%>`r%}zjGZ2{rI>pr5cI;hkMbcqo+Pt= zV`ZBSx*+?<1EQ@YzpfK=|0_#!?`rN(aLAp4(i~~K^I^9@e{NE< zGk;E2l3&)|-jHn!x(Gm>pf3SB7l|}{bhW-1l{e282?aV(;z{gsOF$V2m(ON=tAjl> zw1Qrs%>X`S3f%Oq5UTz0?)y}Eu#$kK?ld)c(=PQ9biT zr~8(hrvLbs?In$z!A|Ioiv^rJ`lk8QO8hW2GO24LsP&g`6kd8K(6S!7fwU$3r*H(1x!f`j7xY)j^M38dwW#v- zpZy59pSz!X7^5o^sQ33JaRl{3I1K(WB)&8y_n>iFAvBg2d0+FN|M)O{pwYQMtzuk* z&UXWDvGUOyj5}H1#C2t9Q6oQIs(H`lBqJhn0?hOfraP`TrLoovN@0(AUpa1r4G6y6 zGX|qmVPJGm8*vQsANpTw+y}$OixGOQOBRS&QQ(OFbc?q+D9`FY@T2&a`$d+z8|<@= z-=e`MAuxRJC5+y14RjHTMek8g!dVt|;CKu5YU^kQL;T)wjek|6VDiVro+`AzOf6d| zWy6g?ZDhnU-6IVv>W`05hDlcon*RD=+8i+YxL+zFKsigA%zHl$l1+onMK;h3(Y|@> z%A7_|K69$cRcyA_bejW=cG}o`LWxPXJWpr@tmp`FV&P@GwR;h?kAwCFkP~EGmMysG z2IO5$Z?_#gwv3xZZ6e<`A4>>>y+4kb>TOqSEmj=)rEb)9Yy_!o?&dumE>!DJX8>zs z4K~z7U0xa@NmbZ^u@a?yZn*K&MIjuZmYCMWv8@MR7C|Y+Xxcp9-lAc>!6JhI-Kckn zW!1h73b)I)qH%2T_jjwt+GI2(v1-~UX(5}FB5piG?AaDL(dvnlH0t(#Gt*SBBK$xs4qSog4(FIbbudmZ83jefOKtWwDOOIHpsgCrYeR!;1`$57IHg+V!hPu8YDO z{koH}S!tDi%^C+K$6LnDn-v5#Tk|PXL*VAafWpiFz6WV!nf?Q}LrXg9w9HPL z><&Y)JJWj+@KFFfr&vMpz_|NY&M;wZmEzQdN{9<{)Gwsjqh7>-KP zMD((no5>9Zm}<2l7+Nvb)oi@ZKW!xQLWZrpv{cvGqw@NF2*w8)d2wk^`h?{JjU4wQ z@+}2(v?3zh-wyL^=Sr%};FU8d?u?J_%fjpA_xB}>GBlXNU{e2 zi!iDFtY$ND$4G&dATFXZCv)J?cjUmgFRo&(Qm`!FLfJetpg%;=tg_U6+Q6?ky?$;k z46%PBR;S;q(X z7MerCEgaV`053i!dPnvPIoW;QPp$^Wr{rJoB491WmJf8pmykS8`3POgDN|E47pNCUf z;OzIvtM+E4FZa~Hxj7K+M9*qbvzkG~;BSsFv~yR$Qz^V|Ippxf4xC}Ez`bt$l$Eaz z#5!Bx^H~EY9p}pn@h_&-Q>2^9X`9y%QYi?QrLXWe0ECn(<3{4hx!$%nN07bO*Fq&5 zDqSw(8Oz8XVAVoh$`^ZvHy5*%n!~pTcpQWrug|#0ud#gUstAaQF}>pzRo~Pnr_f!V zD&kW;JRKK}z&O6mof-ws$^tG&`IkTjl(MMw4}f4}izv99dn~B+*PuGKjRzrAB4>nj z4WUl^U4UK~Nwldw**6{!<&s~Zmy6fj zspSI!1Uj?r;Q3|1AS1f$sf*BRhO#<+Sp(<=3m&>;0V+3DF=4dCBKfAlkj07~p~S1w zx+SC{Ty*XHyPpZIZg;VwVecY6|8WLY@Y_h9n+?nH6pRgW)4kP%-SE zaM*%IxA()_1=^&+8&U}r>}K8~+Ph_DNU>Yq7!DF_VXOwKzO%BpqdXDXd*;$2QLeH) zwQ9!)yH0CV-eeSBRBFi!)>V7EjxN`HKRDT7un*DZ4W5r~G^(N-(cxz{7R=ZAPu+aT z;~?paKce)B@u7i>au$cuPL2G@I|{bAipnJXyU8C6p$2g6s>djrP11x!*T zYY)PCK55lsbBzUGJ>V`yzpJMbl1lMvt{9$T$3IQ6Kd1aE{!n9ZdMcLl0Zw_$l9dUG z57i!CvzO7(Y>1jfZ%BC<)xdb%AREZ+R!F1THSVvD0kuQ5W&d{dN)sp;hjDWozC{^1 zxx6h{4vD=d4mA6kdK-r%Ay(FCOE4rP0UZL|30jqt>|=R>0V(CV_cZe;9e>>EORt}- zv~70Y8*g?B&e8H~mmQk)gAkkTGeC5WRCEGsXseoO^S{`&W>84LZos>PdI8YEuDeq? zIvOO)a2`ty1v&2((~4eS0JJJD(3k8BuMW<+%kgC)KE2B?h~_oeeq z+~1afMRfGQO95Q!vf+%y)1+&jPDTm}?y7!p%y!&)9hEtBnAx z#swho%e>>F2S*7*eh{w9P0=3&tukY&pBYK*9*Rlq-0;{2P6%Jr^a0m?OC)}LcUSgp zHKnW{%uzo&JY(CyH&Zv$5)jO$bT-}-|JpWl>37jY?XH5!!pYtM0^`u@>HQT1Ivz|y zNMDH>?R|}R@v>3U&6@}zDT$LjK{Tb1M5basq#(jA3Rxde7gsW){~Kfc6M zkvX7&ftl`yD8t5N`E=IvdAQS+^P#s7cBy1Aoq=2SFuAF&5M5Yt0EiAAtH&&;4>u2Fdfk9`{Fi-qtdsA6;BBg~g>yh^&RtZ39*i9#fyBw8 z?0+jmKUbzSXDHR6Fl9^z$aF?pKWxlbIC4ak0J-|dx^p0`aO>Oh00IJ~%G@F}`YN4~$p>+$? z2yk?t^_GwZ3uayp-3I+9>zZ@J5#$uCMyPd;;w5OzS!&Y26}|gr%v32@7*E&W)2Oi- z`I(S%{EKd6a!N#08W@2aRmJQ_mQ00B*Mlr!`@E^aP@IFVmT$Ycy}+&z=on+ehXOMu zlXbyi5^64h&f{5hH#nMv`|HK|@JAL;=}^<{SDR}7^KYHSPKmMnD5@~&m=E>fPK-%*F_PW zhsa;>duV{)VIE;LZsxZN3^~}wHB#QrG&M5JjI_sG^dzz*J#_6W)Uh1NV#?2IDCRw& zO>F;32@oKtMDHE9w5)X?Fm|x7*h@~Yc%Lz2o+o6R-@VtzfQNBhjoR0Xk~9MVJ8kGd z7!VB+#_)_->`j_b9->?`o4*mmQ;_4U@v=6PWWiOk12~L3BRXuRnQGNO)S+i#eXAVV zgm@@mud1nU60q%D{ZS}|+k66V$)#gR;04d101qMjRmpc%Z26>mi*f=R+^nR-}UEYjY?GKYV-4_VZ3!O{-Sc{XJKj z8Cr1{4tmx6c)~u7J<0ox{#!1czMTFod+YeJi7%h2o&LCmi56tIir|TO5Q+{z*RWvO zU(do;{vt01JE?AUA80ZE=#d9Q(Io4gfz`E?@U)phQ@xkK(GKYTxncGxcxF@PVd#Gq;aS>T9-yh!XK z7LyjOv`dio3v&pj{KgkHJy_bN{t4#(-Vh^z;nmL&54@IgYy}BDoziP*FA?rLHL&cG zUEsd}#aYDT1SW{Q1}XV}tRd(H?nmm>gT~PUUz-*Ui7UaI3IFJ+U$6^}*+MH3w(tbn zKzK+Rs2~7#hYyUiT+v6PVN&GDX`B3BQ#LixR2uEo+8F6x9GDgG3{GG4pgc6#<00jIgPYhiZxtn;=(i~0IIjmgYVN& zrv~Y(?}y4Rb`Q~pC#v@W?8numsKmgX<}Olw4!DW?+#b9CP`6`BqS1rP{KIQ0yaM~Q zBF=bgtqi>uKzi&!0>fnp9HRy6VybeKV=lQM2gp!ROtY>h744o8vNW*9y_ee>9Ot39 z4zChV89QI?vxiELPKeT$>*T5Ad+A^#NBwNrM$1$i*;D2%Pm{i>JTzQ3vrPjXN(yBh zqX;v0L|;yiaO7e{33W+T{Pu;rKUOJMUAm;Mc_sQ@Qc5q)bL*4igpV}AY0U*F-9`O` zz=O35dsSWC?YbrKGxd2`fIDp%Cl|?@j9SB47o#uZ0po!7fk@_wCz9!rYGNqI4+*a= zO1hX++r%4nW8nl-M~q8c?DRUmo2s+fX)C-Xx(+L@H$s-i8j=t zFpBhmvC>$qf*#Y^du;*t30t+v&ork^W9pg0 z$NJxn+u4jsle;+Go$iJqjh3^^!@476U{-#;8=kS4I;GAPvcv_%8ZN2?&TTl>xo?2ru?G5 z^S7LEYOOl9k0G}ezxmLe^qW0>m2RtkY*akVRgJE@T0-EJ(asg5AoM%^F{dwkqoRT>qslX_@j;{I;7iY)sWjp8quC;^GY9xAhDKl^3ZocL%E>=VBrki*ud>g1WvF;!f< zAk#p}V4YU173R1Xon4Sr_M5X&axJ$|ddZ5{VhW#ZcAofRVdJhgAg-7d2)8X8{l9M5 zF2F0=ty>O!zCup;zL`D#mAUK;K?XN0UjMC^LTQM-8~@!Fi7?=wBZyIzdJT`a1>G?) z1NFPbNJ=y%icPMwo~lbbF9tKsgM<7DW>up&%t2S38NTGFU}2C>ynUC==)I zDw1QuzKR;Jb$x?#Up$6B@qrm~{rHsJEB;uSjIvwlF_4wiF<)pLYa1A9HW*fvZ&-D? zx_UaK{|+&2H)}V1*>DOks-IuSX~*hkLmxE~!_S@0ne18_i~ zr`OtWwaRhQ8wbD({V&U5Zd&Cl3tD@d8cl67;|O+0MdxO`D?Fz5mN&9Ke1Kwo}#t){&7KQ2#~V@bQRz!&EeBLK8o;j_Zc~W%F8)~ z(ap;3**|$JGhjGjx$rUL322_D$!%gAbKWbFRj(iumS(U2{2=rzUV+{iW@e>bn;jN5 zyx9_k5SImACn}M%1~~GrNt*eF__A!=FKIa%bhMggaoM}TVcvqvlC&vW%i0$Yl}m;d zA6E)SwaG)Llg+6rSN?=|G6xP0`d`I#%f%govk?U<@0yK^2iT8Vp36cda%J%a&HXbV z$bvCT8P@GyRS&9jYxf0=nz84x#n#E~q!rWi{`>MCpXi1bRCO5km))}4jnJl8_bn|5 zuGS6dIxU%5JL7brWh=p8dJPjP*tD@Bm9pKZ9s7;F@t&ptl8ccH3tLX{Lrz)TGLjcVTE%L&TkXl44R%Qy zTuGM;GwN83+7k86v`z-@Z&>wqdf6m*Ga=Fa<@H{Ve)*Xn-jS~3yNt;;QGSeDqh%*X z%r3e=gDHHfFRRQ{IWk73ap8WRsRddzHVWh|S0hU|@ddLeidtNPGd)PnqndrOrYkDw z2(`>;^J#>zbCPh{Zq zLVBbP)rKwNCXN8h1`V{JE92LY*C>Iwviae|R>_eKC$sR?Z}vi!jKTkhoB=c>e!z&J zbwa@yq!sKEr4#sp^k}+yyc**V__P^b87oN&alOxcG$61^fMqo6QUe0YQLh?8U0XW_ z0Bn3wEn~rp2&JACP~V~y18k3`d$iw(DY`z54d(q(;Re7P=0OIusGG%0pYTqex0K5$ zQ<2Asper)$|8YlX;A+$3i1$pPMt(Yog5rb51bN-Mf5F2Rp^v2Lo=M@e2mNe@8buwb z!oU9u`Sp^SO|Af(hU*==W<|dfDv;)EsaBNrxv?`)-XkI|rUH&9 zM@A-v0{gBK{BD;sF#{vtg{z?|LMh8{!n*fEw9LW#32FK!=!Nqy7}q4ACfV-iW2Drf z^4h`Ln=Q<&atvqH4l>=!eY?Ay9HvkwHV>C*2hM$g!ep>0Jq{VnkmbHUOE}xU>15;o zvXDRj{MZ7YXYccWlH&h}x_c=)I@$@LkR~gEoxoKn^dE%LCgkpR9u+!D4_<$ z-V7~X?C_ZB2zrY#8Bg=pbjxJ@*3IoVbn4%8IZ}mK*u)luW$4Eaz6%AKN=;LV5VZyo zvB61~RYGKh%01Z_yCjtI7ZkC&&dXi9vf>236W~7mpu4|JA$v>)tW;1W@-w*g`TW$5 z1C(x{7|*$kXuI~7llFozXXvIgLV@_9Dyi z>d}arR;?bM932jBKO9xlKcyo-gU!^6=@y>`8)vjcrg)&wr2}v|hJ?Cv!UJAhBSKC0hYG;?;GZO5SD0 zl@XuF)l1X>Wk#dBG4Ht=d^}=4O7Ft2+C6f?87p%c^Ab8+7DmqIhlT=ke|JQTuguw_ zs=eV;+sQr)yh3kE`cv3dCv_i3fb|A7rh!_w)H*sjvBzsuqN^1+t?4>^h2~6i3jHGh zQI0lSY+_Znkcs1D0^6i+NyahHLko?6wgpof0dj=qx%$v;R<*iB$cLM+DF0-JeA-0= zE8e{IFi9y|`P=`dp%`;goY+H~?W{%F22Rn=1IW42QGnW^^*>Xu)<^+z-ZV#8!<<2* zk?Q-YbXwA-=mg=}gv1_%eqz&8irCXsLijF~w^{WL4LF!=5K*!XJM4l0%C_ouf?t{8 zXSM~i)`Y=D_X4kG=`}8P;jvx_mJ?CYvE03&J7JeLsb>eTB!sAfyBQohad=6U3)gp0 zup`ZVwxj+A93pe4_@4Y}%=HCVZOtxlytqh$n&3btS&C}^Z$$C}8c$4G*;J^0doPQF z;d4fW0lD&GIi?~7L(bBx!#|Kh7oi|#VDt1!96&VfMgf+#%rCMwngMNfsbYAJH2qAk z)zfi}HXNHLVNg9Y;TYZ}%uEytrs+cZ^898Ow!`iAMMOAYwjY)?kBaI=CdSQxg(N8Y z@^tXESCyRbB1F>MqQoWFn`R2@S0p|bQTVN-0BqKYNQM>Veunvp;4~cPd|)qvoKjSG zSaHkb^RZR3FR-64zPKyb6%d zmQX}iprhVnfZsR(00Cih;{-$lw$m7BjdD76-0k$`#`VAiU z=_aTsPOP>K5+6$ZiNBi zKf}C@I#+>XBEm17=S5$G>z!A!U3fP&`w|(+D?UL`g%ycm9-gb@u26y?#F_L>d2P;r zzUWFmdtpJn`zoV|fSVp6mxeZ2+nk=1mOw)jahaa)e02d;k zyPYE=MnnrIso7CEYqOUePNS0Af}RT%BLhR}fN-{v)(~!!2tY{K$hgKQ*wz>KU9j1vAM^W3TU=6fGb`?kkSDSmok;8nrO-1P2+d_LT{NXKjGwtRf^>S; z1}pZvrG@VLy9TAuQWJ{9X$gV0)^p)rpk1818H2e2h$vYCIvD@L#9zj9BcfEpZ0rG( zeNyyF96W4gqH4>l;@HmKTm2iTz5XSBaiqdD{=tO{a4gxpt+vQ$DEZrwK3=UbZCN(8Qn%@OL2|{JJ3oz9>!+~=M9+P40Dp2fG&qN`6!Aq2-A7V?laKkl8 zFvFh_=;ZW7C~Ap@ueb&Al6ZnZa)#FU2Xu^1Q1|TN=2xHeg2%i$fDP`f<1Sq3J-PDm zq-44i2mu*T{hhT6-Y5Xf4`}jIF$VBE zE~`EQoG-c4XRi*0Sa&`16$lGh=!A%Wqyob#nkly_5)+Qe$dL~%z>>E}Sq;a}$pYsT zzVChQP(-8U-pPy5XRRo|Sskiw1<#W0VjR^M?im?DkxcZtz~zVL!uD=5f(K-Wqp1}4 z=HOfq4w7u)Rb;;;0K-KVK|p>C_67SP_J?PxOIFNK%VW{?su&S^Sxve+k|0FaQL9 zNVhxi&R~SCgTAEFigZ?6j?DsLO>q|)>rsyhrfoPl%SjT#@3_mwWEs11W`~K}=Tat- z<38krfl{jwmAmRtXEUf7#D!QEVpeFxfU%Z=4j1w_RKpr*!7A<`^tb>65CUVfkufNq zjN}?XQ}JAs6emgWyuS&5!7^|Lo^I#^BNKA?ahXRu@JKe<%TRAHflIt0_`S7`lWou# zdkiG&T1JNH1~7C;-qd^hotX6^$&-<3tgoE;Z@t{SO8*`I2Ds42;$k1Y)?TY-_`|kI z7{;5CKP#;^n>glg)4}dG(c?CjX1(W_ko*nEf@d+*61d=Y9MI%A=r3}~K1a+HR1s$0 zny9!Uk_5vQ)08e-c{QC!4!8dc~f(-_u{z2yW>(CVM?XI6PBB?hNmMjPK= zufEh+h1Fj|7M`TxS7DVPG;zR|`hOsd6@qW};a`Y44~}IaK63 zD15IU-0>&&4cHo+hre5SQh!GN(;bW>JI9?O zr8Zp-HQg*Fip<3<%bK_L;s<&XrSc@GyhXMEuCwT8^Jk%?N7Es{({rAR+PrY}@3|_j zQ?3w1JoM+N+gGzK|`ISsKK*LD9Q={91BQ)YGDbP{{O`rdz|KXz&6Yo-RTQ?K`CB6_5{2>U^uc zJ4T2KZ9FHseWh+P=)Jc|!E~mWsm}Gb;(Q1 zhTzY5|E70Q2L2dqEs6f265NHN(YontG-hSWpY~q@NQ~UTmx~K+3WQ|sp9`!bxGS?S zw4cjszs8G1k~M0P=CE8CIvcd(f9Fek zBq;rO1{In~z9%=BK{!=CqO+Wdu&sZ6gG!E$4^+f>b*k8vOG6yOL0y(&&Z^D^5FxY{ zs|z+Y*EEQwES<<4B6-J{3rgl;Y|^(Je)5$1Hf__#1?;;2JT{8e1A#F&Rf8-Dt6N8z5g)&_yXoQAi`WsyFM9|lUd3(W zEX3J%X@_*+4fP|@XXzwGi6Pu)U&IDIws#rFnrPijZ-x>%vlec5pl_3x><6ns(nV>5 z4t(7TFfne$kAbD@s)ulZIB>bQb%HdK3)87I)6Ti6zP+w4a;8+T<31EkUQCh9_9J;{ z`MRB*%tpXQd-sABmn6v${dbZmkY2Wkopi9TmtOYnolsHRh(vL*8a7;(T&m+`H?bEE zwj1d#x{+Y6_raR{)HguhW%liN>dC#m`h|`-?_I`h#!hqOymh4w!wfd|*4Cb#*P=YO zoIao0`~R~5R)b%^zVU>YdRo+yDTAq78fy6^&lrB`30P8a*@>IBpC5ca4_9B>7u1(# z&$KSu73L0bxE*LG+|0QCyH^(y?EFc~a|?1p3pk(-gTou|io>oTN%_+khcao+x=%tH z_B*#A%`Y($`HlG{#Xb>3=OT;UG>G_ijW`bK312+JeQUVEVetL~|Cvh_D!W|zg&f$* zLWvlkt1?>snW4uT0YL+GRL9GYIlTdI4A& zKBG-XN^&QYvhY%|6(6k3UBzozRJX$Zb1y^4LayGaKP=+_Br{~b1K`Rtx+%OP}kl2iA& zj*W>>o?irCQftrY`5W`qx7d~L<#)9Vpbr0L0Rw8mOu8{dKsVs7#{3hoA9Z_F&lUnm zg2kcf6wQBFJoRv~l@OC6V6YPlnO6{)-3a96sMVAvlU+bkPkWB<-QgNLAgxx|Wn1fX zmKYgFJFdtG+>>SK*m>9?B4fS(CMR%-XxRz`$^fg`I9nlW+f$k|{8ABq6wx%yHZH}( z`;h>-Xqjb;#9-oXbQmLde(LJ$t5=d}((w_4RzQwLn$ymp$-mnqX*VMpX6r)O)BnvUU`J=d|b5$wo|J1)h zEy-=0MKXTHHy&kA-Zna%Wsz3;1LNHgMh;~Z%O&7C!nasdJAir>wlld zYP}|DsjS(SN#WK%J=I7`R@R0Zk&~YqTy5oSpzEGxzo^HA!+iET}LObU*zy zHAg32d&x+oC;Ds`*awmJiG2hTWnxt*e9>G%+!ovI5tl2|FN(YCg5a90T|+GPbwhl@ z=Ws%#E};tX8-=-bwV95y1v#F^V_H&8r|^u}UuPC#*dx$ROPG|p2$F`mlSH5(ErUUo zNf8Yy7NH{8Vd@TL(r9Rrpu#(M#l7+sIJU_-sWUSS|xLA);<$Xb(`|Nn%*%9@;? zQ_NH*tK&@(?5E72(xItn|BV70pBWq(%6D?vuQf?pm(~~99MWw=CoM-4mbD0L=HV(- z#Z3D=Lg4D#S#H+_Htdv#?}hnkbCrspW0Yf3;d43r!F?M25{D*@RrogIPrVw9j9Y$q z3s}=c`v1_f%9H*b`d^u!Jce>!_BO9*)7o$qeg<)115oaiQVap}!p)>@ z)vBEXky{xljQr6|RKCtcmy-|`R8iX>hRE>9620_TcmVStIc){)T# zdlmP@osGRGie@o!@w=5Eywt3Jx?ATGgDl6YC?5ZebUfHqKc^<!o`Bsnm|Kkj_`%pE2yY&>Wd`&1t~NV2KHO&^pO9v7IjltE9Ur2HS89E=^tip> zO3y}2`S=>3=~Mc{N^7RyZX_rl4vHkJ4$^~rnk6PT2#l-YJ<;*f-Jat~X) z#bsiH@zHP!TDoF}-+6~UcTdOA!_j(i>rjCUM5RDuupeI45)X%$i|_sfYj=0*sOwfc zF<3UQ^#sbYrNTC!TBDU&j!}oiXD6PH6@|2|4{Dc5z^oddrlFRLc0(%l7Um}Z4@fZ3 z9*eB)4hIK<7mDY!#Z~sLsn8h0+^rB6b6{k~zxe%i0zS ziTiX#nR<7HCG<(-8u%dOja^y;w0f5xZ#w6(N-AXs9EEWf$Wk{*$h=z-O5T{1a%lD( zmHKh+KEY)nZv#TuUc_`=OGf<|HK(-=77iVp=Ae*5=?5{-m~HVr*qn3Q7kVzWQhC_J zp~ORl7*yrhMbv6uII>j=#g{EwbvV8YI8olb9iZeouYt&(S*F(ar(Mq1!ouP=?#BzT zZLCZsp?tc_%)QII!kRWkE_g&jrxTfBXQ<9E{=q$+mDV-WQG(pPZA{j1;+s{}y? z@-IObqTa}Io<|xl48roN)Vcb$1mAQjIl`yS@z8tB{&dEYV4`hRbRIXOA4$Um2ob!6O75*jTLe1hRXQsKQ#)f;+5Q z-2c@RTNZ)PHU88w5LTOjSwLt9R zIvIi5%=eb&B~kIY*MZ*R?`i{lRzw77Q(l8G?XT$ezOx{Ubw*2qQDJCovjsN+twZ?b zwZRwh*|JhIo{{om9l5?-dY0peL+o7K$MNVXDd2M?RO50>FMZ8ZgDAj+0k?7SQukZ$ zS@YivHAmv1f^)esr2Feesz-55xJ%eRe4uX`C=F5C9}#E0tO@1`TlCig>(3qfKRzOT z0V0llu^-D=aX6q6oyGVW(X}U!jqIn(VEtwug?Bz;{y*VrfBcoAW2uwuj21dDK@`Ii zR+YLd78@VRSuWVc0^3Wb8{B!lX^4cj$85-NHJoC1fUX~<`I_-w4)L~c>x}^l*pg2% zXdE$-+0AYGGP`SfDCUbmH103Ehi(%II0U3`T2l}DK%yC?X70`!kiGd|I~jyz2G|D= zI>1}YxOUKOKp~Pd5>D{~d~ZH80#ua_Z;$;jC%joySDNGRP*$ zi5e=ZV>f~T*!$XKh+zQP>`e|Um*8w*tyMoj7JRZ&T)GjYF>2hufT|6*RC>kV&y7;r zCF7~4UB?OWovGim)2W(V)QXovO+qJN?uZu3jqGYY6A*i3VG%K8v7PFpxr=MBW*d1s z+)Z+LdXb2-4s2-&XC+T{D_OdAL3&wYn;>LnY@uGg1<-Zn9^427J6evI#3QucvZgYA zzox`bzL>Md0qMX(w!~)N^zBwC8`6M=U5xbk*530FnPl!cK^E8n4Dq^@NU$J;&c#!~ zjP^1RgGnLlN9F+;W@YQC!-%RT?DV+|9O3H>BN|(=UQ-H(0e9+&|th51x52Z51;@hcE+^)H926DH3BdAB?zRV zXzf=~%INUPpPZ^NUX-xaQMh6Zh>;tTZ&nC#*>p$=@*Hh4Z2S>P{R8rg5uw=$PA;cZ`{}QAe6WD;CTL9D3H&Hu)^c6tmDcQ%y@kXUF{he4TjVss*H;fxm^~3z=1RQ- zPOjuFV*GllGWGkJyVS8|&syF<8vd_a&=w1raqVlu+x@g)>L+Jrtors+y#wkjR3zPgqVjZVN zG5vdwdGbuigOYFb+tUya-Qj!1DgfGk0DK-6upwVDkXeYaKKOrqD(pcFlCL+8()h^i z8kl}?XW{GgvWNk{T`WeOOApZ-EniN5$5q(^>idIm(eDQ|qq4quv#RP6QB+FO*!6Rg zdYj50@b!hhc^jEAGUturxMf*%rkd}*9%5<6-ed#)uD2M&0qO2iPOPfLV2&t^TG7!F zbTshFrBSU_5{Ko%!aMR5U&VPqORR40_@~Q#THHBSQhUQUc|zoX6EPX`RBZF^Sy=S} zJq=M=-}Y-H4Zp7Sq4OqV10PGzkO8Z%NkdK1C=%Ld4fW$4ScSH?qW_J)Hh$xp@}AFl zMdW|ay(FQtgX~;dJsi+9 z&g8-BpQ5r_vzx%wescskhFIY-X)u*aAR6#Kx&9iEF69Y#;=SSsrKlOq+!V)N?sSih z&+P~lq0i9S9EZ%@m`(=E`0^buW8{K47Y`>MZPDgp!u-vnH!LLG^vv5$4>(foVuN06 z*$&4@AV09>DBAz7T*$&wjt17k`gQRyc*6$ z5`%~r-33(G)?2p3ml*&y7717wg7d!K9@t+2v?)o9t5aZm3+=1+F%#oPOu*0Q?LH5%)u(Bz zr_w$lf3Us;fKFh`2rkussH6p#xCpTMeeIO;?Wv}qJO_#xjDEszWJUDUoP;SkKbD{+$<&hi%nJg(IWJ{t9g63qaoa15ta$c+!}VNkPa&Yx?JLI~V)i$HyTWYrxbcK=~a z|BzW0c$N^~exLd?5y2(`^H4z1Rww%Ev{V*DnGU7d@^AR?92L@rD8?NA6= zrEyVXx3YZis8>5d-&gTc$LI@ysZ@oU4QJNWJ^Pd(Dq8~dS zV71)xY(I^~I3$^LjiirlhiA(dWVC*)-|+AK%~Kw_p)E0*xc_QOxs}Z))Hz&-IThT< z%-Z=LxQ&89lfkFl=38p>60NS!>Z;{VA3-Id(786na{6!-0&$ufvk1FoxcD|S^e01AH6=rVkxr!;OhCX<74cNtC ztOSi%4}-uf(HdjHwy7Gokl%$LUY;%Ypj`OU z{K2;TM;Eu-K~=?eO5l#I8VK-CX@uPInuk|0lqnnJx1e^>2HU>gD%C&9GfUwSv@L_p zqLbLG=%yII9U-1Yx?f3DcwOg5e&L?uP{CqoxX#SBg>+q@E3n72H*-w~h#VAaYC_EG z3ab~k>9jR8yfA0;ouPLk^~AgKeb2*4JF19SAC9IbTze; z!NmVA^&d}AD?98v=@s_BO0y94gwF$Z7x7A?&-3cYAwU+qP}nwr%g$Nj7{RNoFR^d$ad-7ZDQx|N9>Rd_#W`)czL*UJbvxBi)*PS)#x1`Ene6;+}A*oAw~d_%X^ z6C0)tXqZ1Zb!cMLiXmpv^ArclX!&}1T5%y2#O#Su471RKZ_A{DieD-#g&eOvfiqJ< zERI(S!_cF*(t0e@ZM>CU83bSSjH!;N(kw%C%nVcls)h#7;B=4p=j?vYfrz#PO)}|u zOIvO9u6O9+x04!1spZoA`5N8Y?+sM(69RsFD^YeABGsz>wG+O-8#kUm=GXU4Q zMqe^v>a{(bR$MKEbIHqyc%za4T<4qi3RC;nmBridEs5j`1pC)FMMOcAL z1=7;`d7$b!^w!Ju1R0=da($p&yP~CRx`AKOdlSa^%aHRz7Sf{L%#q7~k{P|(wk%Yd zUuB(}s1^c2H7%DzKUSWd*cXF!X}y!5pP$Nl<6AMcqKxP zYP$Fqa`$#+>nIVbVY*%uDhJAhyV7LcW!d zr_t#^VgT}8M?~8MJ&2!-luni>x<1fbJpu7(5QtBImy~-MWg%I+4K2+e~8oFnFH2p8IY13+TFFU0&P!Ua^wETxf^7)J*-B^Aw#5qkgr7r_KNaPty^SWEz>d!WB>F-SH+er(^{pT`?d(+9T5vN3 z$&O`4vy-kF%!J;gYqh{jmN#^5SM!qp`@bKXRlMZ=O4kzLHPbV?{$VEMI$hnF(H$hq zSd|%AK-QZq-1H=Czutu=V+a?8zEd>_oFkS#z1q)*2mcd9z0B^UUyX>?emuY+qW%Ww zoF5SpZ>HfP=WCMAN4hAH=#*TJ2l$;J^YMJ(vQ(0jy$us-cG5FB7{p;uCLqJ~$BDFj z%nNSonVsydxBwwTg}45yo}}~a5o_{1b3CvaFaGT>Iy0)9&?`f>%1KNHeA-*HH)vZI(hFBdR zQ1bx+ubK(&%EpUBV?#zD_g1r)S&x*5Fy8M0T>d4Deai}zZ2 z<&)j3?C%fu%j7Cu<6{;{7Xo+)?Rh*7Nj#hm0MZS(X-4TgH7umt;XooSbwStkvIFJZ ztz}^(lIgg(ac45h`!a?D-{W+6GQJ20fj2q$QHX)y-fAYVJh#=yjB)pW4ort?g^cZ#qAl2^*G81%`ES$wmfp2u(2;rrYk*)`6cqyGk z*GWHK(%m7;-W`~YrRlpJR|Iksd5xm0<^LKK1nic3=yZ4P14?=V&-oYMe|e}R{PxU7 zhOIGEph_Fz2cXJ&jEtw5xpF^&3R7R;zNwXf9vyMO>Y4^oECNm4d2&oV$_}pnCr`n=kl}Cmn6Z54lY9e~$fc_ci(d*bnuu&kxm~n9tfj-0ygg_KZu{JzDj?m{D||P$M>?m$>mz~KZpN& z{rikRkNSjuKm8y5zWY6?^bYSQ{SG`kmbkT>(2~CL;$6#8=vVF?I^=07oH0d>5G6oug>-8hw^`6=?W{BFqoITP1x%~ zYk+V;#%9~^nBVqxi9R#`3U7d~@p2SF0457yjma~Qlj{h0!u%tNpxp1%2M{YhjqNwh z2Wtyod~sw{e@2jR_}0eaZK0$d{9*GXH0;f-3>y9Jglc^X#4e??m|k(QmdS`gF&r@8 zwRH|i8!rksynHn`CswW0+254*?#-EgpiG62>pnjA++1@tl{lq!Xc9W9)LCo{szCSb z*;p)tLDyee*lq+RK?;YPw;=T{P+>>XFv&9PNpU>3#@^yv5#?AwTv+THNzIAAYQFfD z)@v#&W6p-OTOfif`=iH1wYt0O6js0y2?18k{E6J_E5$@$knMs{kOwTOe7jtu#2S4o zt;V(XR3_4_3qR4F82K!^K-DR@{DA+`uSQ11U07!nhb9aMsVWjHm)I+2%6T-Jjg^qB z0RH$wzy&7-cHe8Y8*h}O zUo($fsL37-e?waERsZV0#ip;58KVk=jnm)Vr`FPK+4B#99v^3l(K(?7dGh#|Sd|mo z%i;YZNa<01djVWEzYAdMj_el=#0O2hgXQkfcdseX(Ky(`lJeaUy1$ z14>2TpyXnDcPkR_9OF^X*ljQu$o$&T?Wz{4Rhm1iGVlA8KnL#i-ixPB|KprT_)n1J zMJPFrW49IzAdrcHJo9bx8ESQ2|13|{60D}%gP$GEd>@cGv#W+J&L6R?{#7uO&Q2l^ z8}Vt>G?*H)b6>4*tHQW5Wu!XKtNqV^P9IS}MpiGJW0n(WC2|q=@|HL}V^}mUSxV`- z$UyT=6Ml2H)tG|GOex+3HgIo47dM=t9BvaYh69L#F@Y^t~CC`7AcJnySKLLoZ zu3~qx9UC9k-X?EYH4Q3FKm*s_>;;Tugy~YmWD*%a_|uT2dH2%k@H$7nJAvwQ{O`ZY z#x_)yhOT&x+#k$oT={(h1?7NqXlm~jUNzpEDl3>lGb-q;LL;RV7j0^pQ{1^i&?BWm zY{QxzH)JA)&7cL6)yxKx4P%^3Ze(_I5t_<=;0J>Y0`kk@>wC@m>y6G2xsa)zz>Ay| z!67xQZHzQB|Ds;DTGGt@tqMc1=)h~;@Fs!`jjs~?(wqHx-3ld7fBuozHx(_pAsbFU zf>6|kZL6J4`k6jNs)aDA*B!KgEA9pyNn{^`ITbE&BGnsHU~d6wi}-q zzG+*oD&DQY0<>R4rpw=vB^_V)bF`N4J16JaMT5BftN(%{n5@{Cc@@Jp$Xy|PvH?r4 z=!)%Cf7l%Sx~@DQ^GaG>)c^fnlXX+-=9v~zQ5vahURwNhlCYg?fP4a~_+uVoZsIu0@i->tbpX3|5ObSG5^$8(Ad%bYE z5NZePOt4XiZkG$E!m?SIJUFW?RDWc#2z_l5NThAFlwzzX0pn$n(5UGqa&6Usgzj{r zgJuMu|KG%sF|%jvO2tv}rH^~?p2vYy=h~M%esN-OJ4~^LVdPhIDVh{m1h?tHyD@Sy z@BW{*TTx=S87$%FPtQov1W!<0KSZmc3u)hJ+3lZojOl=FjgGbZ%5Y(Qzf|T?BqOQC z$Z$F_`v+N8V#V?!;t@}$PlaX)lI`OWrwt6Y#y`thAaG*>{Lz`Ixk!-K*C6JCy%4*c zx;LfUbZ?Kz;RkFAVf0x5Wy9n{B)r#&H=mot!Vx_S+CyeyI$2Q)^foAF(c1OhINt?~ zDq;G$sP*+$^oP_Kv%I)Q0!MA>&8=cHPpr|E68V z>zzt{x9q^xRvS-pM?YPf>^8?oM^1#hDL~`4wZ82ZU-I}0(p#d{}{HAZ}zZUg5*=QOb|+Kyd- z62o(1XYr1zj^3f+dsBmLc#{K8&YR_F%{2aL{^9ilIk2~l*;YV(zc0o>>_6sSEF>|3Rj3X$)w^P%QNquRpn@X7r66h!oN!sXL^ZhtO+kij4^0R#C|61 zqjYsWEo68#bkIbv|5V)~>q3LIb^xG;m+n0|0jLl41}KEM*@#^uvi;t}-k*X(Z>N}X zlStICsD^$R-RwuIu5xHgnVI?D-rQ%upF5^TEah^F&53SfwO?qL;=O~ zTI||FVz3G%S%CfQ%xSB8O!jvQfKemm8YSfb-3?1xkSAw@{R#;~-ag3HHkvo5cK>>7 zwCkX`oA5AYW>=uXaz*|P`Vkczh3QX5lfdzF zIi&ZL>up8v<7xeXl$zqAX!>OhB$U74?YL)tNKoxD%uJ}}-cxKh#boa%Z>}U4{cKW0 z;Pj~3&ujSdswA)-%cunAa6bh%NY7vwe=2-H!;Yu@tSOo<1-uZ-QlO!FT9h{&aHATRNS(yd!REBrOx zgGghDg7{@O;9bo^ZF7B1UCw%(n%c&6);kI((xIaoL)eqR!n0jlKL95XPxYpg<}t~> zN1cv)^wcKXtvaM9EAMm54my?H4-CNVwt0hkCQnjD{)RyMME$d@Jh@29+3G9{F18`n zFo0nAUA*|Lu*40Ap8jZD;b!ml>Zj=ofLCywkXjY*e~pj)MwQ$iLp|ulRNnxsqu2WJ zRx7iJq^C@WN|s&$vto9~=LF+>FyZT#d3vt_F#KAH#S4znJc^v9kpf0FiN%`uJ~XbX z1Y@X1WQU>IjbOkM#o}LOc;j?|asS6H`?~>5z&wlQ;4Vz7?VGP2*dn`|<%9tf{sPgP7v0lyTN7p~%dN7&O8st6p zMrN}w{KL$1r8t5b4QU)xOES*LgEV4m;v+^hX)M3K{f@iUj_J6cz3$Q&v*;pX%@L|h z#d)dHb}k6c+4d=KopJ4cY#h_EW^F8#LGHrTMRk(t1UG4mN(OW6D1E#1eI-V{&$?nx z?*BDYXhUA6`tC_?(GZ(!;l==*kGwQ0cJiwhj`!-{mEsnUcwmbA7X|SGEE9v&0EG98 zA7>u3HdVf!b5CoRS_e*VePyV>1U2I9Vb=VdP1K zIzElI8sBKla4S>6e+rCQ#a)f7Pg3SP##B>Y37jI@4+(m{VELO(ijZM{tg`#HB0ViP`;GO%3a*QFM+@>3Z_ zqJ_aXsuW)JWLJi*P2Js5#eLzFVw2C08gu$mLxhI%tvvN9>lZ~8$W%kc3wcx$ek4oW z9!~gT1#}mJ*%LV{bKL3=ox$XF-@>b+tBco4AZ|=jb>GV;z3`K>|Ad{w24(fn!OSPg@~CsqKAICk%9jLhnq2TGe$_Gm_So>8hUYeIlQ9A{(9p-V|G0_ECjc$WKCgFcEjy>$P5 z?H=rY_+pf=eD1b+gKqtVI06Yx4qFw+Csr@?L@#_Go#1<Ay1f`eF!)*=B_rUv(hH#9~D zmUd<})w0H%H5XU!Lv6zp5*n8O)rz`U`L=ePyzA*NXaD3E@B_OiV>%jDL@)00HpE|Gz1> z{{O8#$S7{oG^xVcIDMzK&3A40sLD}o+qSKvp5mys8#Jym(L{s&y3kCT*?X4a@DuDQMx(2->mZSgxY$fR3%LXoeP!K={MMT;I2 zh*dQ=n~P?-QU|A0fs5dCt>2eHDgBQxK1(cEp~finK`6$vHFfw@V(hcxh+gQ8PTC%3 zDAu^vdTi=)p`dtdasS1psTq#q0hr?>V3H>CBozClUZ@oQFSzhrkbp;(mws?X*2U_0 z)b54V6_C>sjl3q?!KGP~OCyoys|TWM9GO1~D5MEFXaiTr%=5&c64PIR>tBDN#UD-U zM37+ePht4e|B+1!_BWleM-#RQBzma_^5`brkqVsh6;LPWcaZG$_8yp1{dAUevMvN8 zPJ^u=<(x%T@n$1PJPL0%N$qNGt!JUU)Ml)*+>oh6HZ|rr4zPt|sD;%KJY` z=jDt*%rQjXm(D>ILEV6>(%GxQ9c_!_QrY^23U>&ZE)DCewgi1AnjKPjyQ~WS_zZY2 zh39qD_(Q6Gkid;G27Kop>;%YTFJTkV9bHT%J2_}25Pb$83mDHG|@>o+`V&*7<%u9XC3EhB3LdkbhHs5E+m$-hQq-8$d=o|^WdaI-~}K;DOhE6ci-(i;p0gI=c*utQa* z)8_MMF8p!*>ec7jAbCstt&6A6oH=uB`>f{fxS{X!?Shk8K*8MT15sl}j~!4`&#a)~ zV@Hn})!0|5=vu~tC;}ZtQ550rs+PgQ?kX1PhFCxZ+tEH{waTPlj2-A|{}}W7?=GG! z8g$(O0FV`XB3y~Mjk*Cec3hrPTg#pWdEzGk@%5F6@fV=pJqdK4ADkCEz0e_9z=022bvcg zY|Rd#%oT7hX0IR$bTQQc*0IC4@BJb;e(k4L9D->ztRNQuF(tcApnDM6SmRBQXyuNF|O zuf8AD#9MwU-{nx~>lqs2VpH`l0|xf0;YlI2q2jt|n_W~c4y$;jTZln!y7s3(aio7k zT&<1Vm~6LYZH=T*(74<0ZJ=;1FK+gin{mR;Pt1e8UDoyRTM+;Xw_Jh_6oV z>Ln{}{X+ooYO)t`ZRuV!e|Zmu-knPq@6ciCpp-2?4+(dd5g`L_{Bw6Eaj7P-=2us3 zP(+FdDzBc$G8~G@k(Yg}mA4=xqrbnuhf7ouQ}0>wMJky(AHSOhirRc_eH|IOTo`-h zg%yfU4SnzLPlNLQxV@Ddoq~FdzXhM^u+l=;A_hbS2JmO&GA0W7j(?sHpZo3>E&4{= zL7C@0Khl{#`i&n8;ImIOrGl#RH-Ce2H(#5qqf6cC>yqJf?T@y0(9s~U)BFTb^a=g_ z=ppzuT%Q7;X@7j}$h2x2B7ea+`DM04*+KevFP%UKeei%s4o6W$n8t`Do#UHB3FKpoCCs6dna|_0nC$#kc z-T|DLyR@tzFIBnkU)!OG+40Zsu5U`{sau;3yeQ@#Dm!F`S$cyqg- zN;Uz*UI2iY8-Kcs+D#+S#klbUaA)T6K-mc4w*VmbP=g9ygtlcDyax`=*-%w3#BK1w zpBGGNLFgMe-3ENRGDI$f)a>p?D6CtNCvuAIkdJJ%27yBU%M*ZG(+`Boh01PCV}WDs zE2=7oO7q_H48W#RDy1;!t(|Wy$4e@Nq5lByCJzr#0;Uf4fqS!mX{-cVB>)HKjB#UM z$1^q_g6Qe&o1X39B6v2=n{BG#9l*=)+G&{abJxX^!@tXyLRoLQA&FKfvb-+(n!p$@~=>M^1XK-qVGKZ z=SrL%03b1{S0#z*##^}x{wO7}fT>TNhz?P7I2?An13ne=Kh7~oN|==>^3~JMUeYSJ zHo-}8p6Vba&n~xsv(3k=N~`2I&t33xzm!5xn0X5drUgmLP;XzFwb=4LNU44taJQJZ zL{dc8X2XlD{Jgw;dokE@CDiO-4u1h`los)>+O~Vo9=*T%r5G}zB{VTxI=JB8TfIkZ+ z0R}z_WWhKTco>0YvBLtWrwUq#0+uVyspmH6G75k=kb1@gHXN`Hpq}X*VA**r=!a%w zG)QLO)kH-(BNjMrja5_50}I5DOEq*;hhIhlfc;v!1?ghMIg1Y?DR8U;)uDa>`dnXN7E>90n2n}v81uw zG!d7P#N1kLrK4UUN#qA zhW2J8)AvnBGM^C{NER?56Ulf~Y(naie6+-CP>Y@cX@~;F_!r1-|a-6EEqXY?w zLyk3sn&p4aL5-&q>0xMc5i>FdXz3xi??#Ncp}vHs-@LM8#q%gNp=!l>`6yw_csq&E zwofif#I0)SL!G8it>}<4f9mksYC_%cMJecT-H*_@^kr;F-dKWC+Md54!`@0-N~t^+ zBEteg@4?S)h#*>LQB2<{6%h)q_oEo`7_{v<8e~lhq!}T#qkhgog7k)j?rI_ATDIAe zXX8Hmvj&tSL|wCC&-JHYao@j9P1GZV=<0VEx$zy}Wo!@#D+9Kra^0`}w9HWX?dCbh zWR1WKLbh;R!Q<|PrGx+Gx3pOmnL;|GaNFGo!q%|GNu2g1%9kToBhV!=B&q2CPu0JmZyqb zwa`<=C_<_)NZ_cvMU@D{D=)on=B1CjOB0q0-W`uxL@u&z_aKb-3i~RTix!k8><^fe z#6KBpDi9^8U7488IT`zXi6Rs{`d!ZCn>D^FWk85;n_15}#qSUmRwqVH{{&>1)l#wV$ugZ;^HR^u!18}GP z{na085T&Tmn~WXzr)H$3=Q+4TOx;>euT>0<7o59r`#o@}(oTn)S%H=}V01<>MQY#R62-Y3rynAhVn@Nw*HPqw)=zYM(macn7;33Z5Z@m9>Na%>7&63<%+D?-^6yzmWg#mp?rCf`2yci|RvFf(ArSypP&gnWL;wKr?*N?vDrf;Y0X``bheDyD3QV@%3;{w| zo4c4q9dNC{11-2~{5M+Xi19z;zihuU{#>iC)_=JEdj9GEkNr2vKlI;l9+GtT{SUV9 zeIGOZQ~byGpWLtWpa1{*{k8ug{|EfPx?j+r>VG-DfxlEg*8g<>G5@FhSH=hJr@MdK zpT|#tpVPm*f7AbC`~UL${*V4o`tNjq+dstq)PJb?2L5CHll{-<|HhxOANgPU-ofA2 zf6IUB_I&@5|F8XL{15;CN#E7K+<)Q!UHkv^J^cs!=l#FWuaFP^zMvoVy+At8e+vI( zy-NQT{zLLt_hZ*z$9|Rj|M#!kPw+nGcKLo8{^z3~#{TsFoBNmUZ|1kn|Mh%P{L}cK z_`l&jgnu9Yh5m>02m9CVr`A+hI|=q)f=XS5`rrIt@O-M& z2mY7-EB`P0zjfd2zxIEq{J8h=`N#TS{GaIl1wWd9aQ}b)1M-i-XYSXshwGu@pZ`)M zTb$A)4$uBRWW?x8p7C**Vx{;w^&&|IO_(a z$ZnV5=hQxwcf{xdMw8U@Di}w&{k&|&S9;F&gVVp=we-M*JL!Q4chsK;KBP!gW_pGi zV_au|!Af`d~sG^uUDA{-j8&Um&tLA9bzjR$auzDi0vS9>WE`2TmH! zwj_oX_VuSUltXnwZjcuffBo%uYzk-97-t8!FO*jCfJTu1^#6Z-b zFmaJVsM6ajfn@dNB}%Rs%VJ|GyF-xb)-i3A8OzrXPIy~k#P`}Q+BBQw*_)F8u&7u5 zt^s$mDfs2-ap22~sIRk*m*24ZGh@~%wP20BQUFnUW2PM_p+nQsvV4XLU#v zk-g1%`Ylz3`$F7a)X?;qV{thDOt*wBB6wE&HZG%$0R+1oqKsv>xfA&$ddRQ?Jk2Mi z^bEJtX?s^jJ$103%pCE8i!vhuC0H1LJ_&*bt8RO7qI)ITJI*h>Kl*VKKvs$R+ju}5Z-2Jl_nOAjB$0CR?_8Q>Ox7qvAQ~p>| z|LM_+gpMTHuJ#LQP1vXk2R0CTgAHAT`544dQi8$N>nPNjv}rIR)ZB|y?aJc5e%Z_U zL@kfy(h~>CA0;cm7x+-N9Ug8o(`x-=fBk`8LJEWQ4oZap8siNyMd^?p9e4JF7BF-ptgYiFsAE!mpf_xpmsn3~E#^N#vH~-ED&k5ywvvItf z8NJXK-XrfRm{$c$y!y&vyhQ}43_}B*0O~73&7n=OTB#!*Yby3~W<@>kci7DF9D@pO zID9jxHs0JM{^;sKg|psbITBdnQ3Nv{Av^x;xs!f%5lcT;((fJHw>Oev)LmKjBk|TS zbTY!3^AwzoY!ke2c8gvBCajmY9k6!)&z#b+F=&Fs>eH7G!0m%K5;ASNLXZm$=<>6Y zG}N^W_>q$8amNG^v7mjN2*WH-r1KeY{zp7y70qe0tS^6Zu-N`geO)k5Mco>Q(V*|p zN+raPnLjGFhpP770Px|q6t4X99^jwfp;%IVA2S=l3lML0KQY)ZMvY{c;WoidqR3`XZNnSaV}jXZ0{NLtzb-4f@bJd>UpX}8+5Kq`7nG7?Q0YPN*Qq+zLEnOd$r??N2xfCx0Z*ud>>zWQk%{@&Uf#Fpy=g8170%sFRxK6Vo9w4=%p`;U`I&EoV&j@E znzww^73kNE7Sp?8KOc8Eha~=zyApo!yz6frtKGqQ<$yPqZ`gcnVG8~1=^rHlGv^|U z25=Rr&}J}vFtBzHoA`QXjFR2iDEdrV5eZE+)V&}8{{7@Q1h4W7WD;iebQ%~LIQ?Vb z*o4YOE5Aazx(*)J;#-K~{Eji2@4~5a8j>8E&b#Kx`6$O)rJP)+g6oYVrkptSiyqK-``ht_ zVm_(`C6G29#yt<>O{N6DH$(rIzqTYgN9pp(om^PIUq6)&38lozsCkK!+QwB^LP zg9Y@ z_{yJ5gwD3R0=_J6LSgl9XxPU&?psZ6C8gE!Kc*F_>Npqq2$ftaW}!uR=b*d3(%wqh z^vcsWvK0+7OsX*3oB>zq8`O;~ssJ3Y`@t(sO+cGNQZ-w{Uz24=1B)qgVS7#k+h0he z6}{9%y)r?;d?B%M<82P&xcAq(U(eJ!=_I4>h?99N7u-LZ`lQQ@nA!_!A##>@8IiJY ziM_r0-v41~tZ^y%05kQhp?~T3Gz3SqrB+M}=8MDB=76SPub&yYeD9iUPRT|*?tp7% zIao8{bTn@1?EGD3s9f&QttJg!zsa1nIdggvecPM1(mQrgfq|gI|Tl z?#0gBit&koIH?S<1L}>iHVmF}BvEi8hO>~Er*LRU82h@s=X+Vy4wp*GYH17SkwibF z_dzn}kk`l-M8@x?t;!PqdVz2ag+8(nWNk~LNV0BSOFSsbKqsblowYznqz)~GNPCOP z?`_#i02&>pl|gAkgZbuaNtgMH-eJ-~|NLVk+W{wN9{tsIu-2a_ z;C|-T=JR+I&NK^wdcCXwlM~!DKS>v#;}Q1X{sMCc%s$}I`>kN3PW30GG?k9A0vNgn z9JB*bfeKBhrct3vO*-dcKPOq(W`E}v1Whw7bWm|{*n(ouMW04dFNOV2OGt>@@xBxN zMXLbqL4_PRHe=7YuJJ#iH~;_>U^NVXn52~-8l%HWziuSbs8$H!}-j4#5NvppCt>rF@BJKw^6Poj_>Z6H;xxB`5 zbJ#}3pocF;f_wBy{OZ-p6L>z(wyKw93m3U(Pp?K>EK;EmrBsqYU$ST^vU5UwCo{pT zVGBO=o{M#U+fX?=s?x_@C#YoC*#Nhhx{=W3Tz+A>@f^>oTn~%d`^gk=-UUS22ca~jA zw%B4be=WEU`VbY3{3um+=r{{j5HDc(d079)i}7y9V#W%LW?ywvyNEi>lU~l~)I`t! zT!0?82}y|QTycH!U24+7alwYl8)&t>-%9Y6Xw!k5L%ii}gi)Khr=ar3`gImA0KEot z(C+|TvIivT%2hn2@PbMF#!ZcR(3pJPJzw#t27!Lh+Z9dQ!Cr)t(1BDK#^{x`*eAaMdY!s=N&h6=h<=J^GgbylkWwy;2l zl571ziF7 zG$(Jr*Qc}-ED_(N!V4$BGEEM3(p=>;N!T%N{-stfKLr%_kYu_~OkSc(9jU*2KpWKw zX`-9Ha7V8DcnY#p^6=nTG5;kT21{=Sgh9t?V^w5h^;yZ}{~+^f!rQUW$94TPqX7Tv z&_5+)r$dH!BIh~V7+;P2LtZIYr~kDAfZ4k}GP|xNT)I$c4+2|UBuKP@IIg8?z=yEg{^FxV)vrRPml<^Iw$nu4Qh?KoGFl2{%Qg}yGaSo2S-58BXbS%* zec*U(t2oTJ{=gcTiv{<=BM;i2Qh!LqxH9&@N7+x|P_1hdqbxgjv{H9KjHVf;|BCq- zKlC&)gI1h>tU!9_Cj)u&7YFXXLCtquS!p$y7k z&adU78$ja&p*7WrdGRVWToi+X^%K6*TuuuP<}<7&%c*W8U=BDm=$F%(w4k$ei$X}( zWm;TJuNxPSrV!BMH15m9W#40C6v2Wfe`$zm z#{G0dQ4ekYIG;@h_#&sMwWcL)Bn z?$K*$$(bxGp8Lm-o$+?vOU&~o)S+z!%>3X6pZ0OuyRCR#?%ztDYTiDU@X1B3q-Fhk z-mw2ZA0h`&8}F5AAnfuas-zlWSHWHH=#IUsl6tY~$Q5iAev59(KPg9lSLGwB70ZDV zt^aHsiQ3NGN*N7S*D!QjeL@1xpBA`OV7Olx(F2s91?zVr=m}%fe(LLvebsDj6_FF4 zU~j7Y8L;p<;bHmlRkrrYW8O5;w@sM(a$)jTq~ZT6>$%i}2YApOYVZ=r{g=RX&WZN4 za&zg~xvp8eNB!@A|Nr0+>2*`-5^pO?P(pG*P-4gB2i6B@GInKU7!Se#GX71m<4qoG zK=l<7xFeL$K+{JFU|E=i3!|->FodKdy7Xac6vKFPvU;m7xK0ySx6n%yFAiYhg5sb# zO;5mK4Se)dpN__mM<-#`6^;Q#L4#6GWQ?^C=Z^XuNfc!jKHP04MRwgEH*3PtGYdmF z9MZA77a@Y8xwj}2=Q<{giCzv8_}O2C#SmeOZ4bWjb z`S6MsuiX@xvM*pB#2?^a)|p3gB=fPp`VN!`Rd`CN!#^iSeJ(LPay$OZ73o1Y{&~TR z7ZAG?d)jL`ab^{R%6*=IBovA1@gbiB;NxCc;8-v0Kx9&f#MH!Lm39kNL4lbZluU3q z8oVlF82`RWO=A z^ipP;0sPHR>-hoLCVde7xn={SW61^zi;(r%qMuGUlZmKNhF!9G4F7(2p$)5fBsXI! zw6ZK9BpBmQ><|Q@0rQzi#z#%eFVZkCrGJ8y)X`Ke%YRW&B_beCE@i@M;z0bX7ogUG zU7Y(|N*-5cd}R)JI{rpy|8H`HjlAh)wD?S84mh$CY!oeGx-|z9V%ddhXp={?e8w+M zqa~eakw@&y{x1LU zl%x^{fcg8TZDr}Ktn?)dq;OWwx%Rkhe8{62<#5Y*u!OqgN#Y49yl;n@1QxC8deyGm z9MY^=QBbcqDS675`8RYK$@Js#N^*A2CWuXsV9%9>Ui$C?W%LP(usQy$`b!u18#m}q zE)kBR;HaLBVq6Hstdksv&XzW0mC5JHOj%ww1t?t7D)%-~-b#xvQn1F$*ulPy0@!kIa8qZC zL~u8-_^!fIRVv>RZDLV5slWJQCQp@z+d8R4e*DJivC%ewCk2;el=r#Xx|9cy=tQZ zd~KoqFCmyd_32zd&^F;h^|is{)rBT&b*YQK;$!be;cabWD(w99y|q}ZV&6!bq92y9 zL)x*#s&yQU5E?1hODcR^0l`npc01Tc=9m;~x^c^jHsX@J4|d>!LV=qhHzf0}$h;QB zJ2IbAK2i*bDiNffv1k&W1sF;B=iJn#^K7_8+o>!6e#AD)>gEqIY!}$RsuYAH60k}}1 zZ5*({vmF1u6EkwJN0zi1bVbDNoSQV7fg?QnE2j(=BNNjS|Y1^VZj1qm_s*{Fqikc z^2kX>PFGTenV#_#HGqw0FX9$5Dd|r>otXYXf!X}5$)Tilew=uk zH!@qzeIE#n6Ukyv?DAqgifLRn-S%;6q9%4-K^R`$3WAr3oWooTu(5(rOA-CGl zWR}@_Wz6Ch(dkNo?5m}s=VY)u7j{Y%Xh4PiXA&sragiN8R<|w3yTFgZ!$Q4lL>gpC z85@EW_ar0t%u$oH5nSgAIV?56;kq>al2&y6{QqiiPsvr$*qy9m zY3A2xv06E!#w4AY;{+*FIz)A{vv=IM9nK4khdkQ?hTA)IOe0RQ7^$*`!=1O)3d5>k zI`vY5eSmOK)H-8Vkf->Hfuh$ecX1g>uB79tc{J5Zo6edYQS*eM-b^MQXl`VqpMrk6 zKUE0W^1AyE5OQ&8nk0$PM|aDTs?Uw8g=6 z6jb1XMk+gNo9;JQZ)E=!4IB$u1dD_XzW(O5F1bAiZs8GKPTtXRoEmyU4MjAVg-~5d!M$L8l9*9k+ z;~h_6la54|3Ul2Qk|TzhnFbAdwfkZ@8%Wdn!0Zu)o){wr?g0_LKzUq~v!&?ZDeEq> zdgs}n?=mmOJpGfNUB$NM+g^?UhWJoUw7?}1DmB8-6p27p+UX%JY9dS^KZ=Ld7xC*n z>EDM-TT~TS-AwgfzjFQ6{FDml-K`Yn@jBsE;wY{;{arbc4j!a9i^cxkaxLM^jDOLG zqPi+$^uMQ-XWMXEYx6ebLh%Y|>3>dv)YQ=j|Hf#u_(TAfrXjl=iUl<-1`1G^<>Hss zr5{Ko{OrUyJ4PdmVU@__r9EEJ;kg5pMnt`zE#u)Pc=*@-M!|xeQqdHAvW|npMu#>N zRfwow;}lCOwJ95^a50fb@PP0kc}849U{!7R##q#jTcd(`nX+}}DbLBF$$P8JW}nA% zg?0qMWj(lM@qZoiuBrqok2FitYtr^}g+%l`L`kNotah$SZ`1iFxm+R<=zfU>;@wd_ z4RDslY7b@NgK5=S$&MB3&%1odaX-^`j{Mh;!McSfY*eHPI@=^4y&<@R#>a_-3}GQP zYKlC}3;{RT>-b)W5o-BkPHgJ4&c=PT7I)@?e`(;|(;CMObY3%>kQ=?`Zt>GfcxhWD;^wc^gcXsHzq;94^e(SNZ(Pw;H&<-kng#VX=T4UbrD3A<(C0b$= zZwk{S!n2=(lqUd8)kRym5pYvS7NAL==_4|weHIxDe>YJ1u@ahKt>d%FR7$VZyh-e2 z#=~aycGe)xi83i35uY<0r)QFFIp;HN_9%r(i0Y{I_9bHb{aYj!MMM7Z4rzY7W4gXpEOb1;WrTTr%q-ZaZ<1C)g*kD-y`T8M%`R!vuDGn~05|tmEl4vCTEK{N zG=$|pqX9=|*IOXr6p`@HT`lLmdGsTpOZ969`2H8;)mrC)RfB*UaH-La81xD=d$L#= zgW;n0{*1gpl)dK2W<4i$&G^Pr_B-}zf9yEqfv=BnHI`FpSSK&sbNp6D84(1RtcJ5Q z$tlBi0o}fixd{w93|^M20+-J=Fo$jVW2yV4-N&lp$4z~oUP$w8ge~cv6{8VW zMyAMOg&Lv9_z5?pm@b>kv@6|{(gx@`fn^usYY}2U8t7r8V zppJAIMyY~3MbMA{oO`xz;}FefeWa+4#)IlH?@!Wiyy91qm(+W!5 zPH%t|a=P3{k+Aq;(x6bRFD-f48&4Yx22C-@++;@C!1FPkEjCmDLCY?k_4R1BC7K}Q zGND%zi7LD^dh9cl>Pgf{H0?5ua6P~E7|sA>PCO*Gs^T^iNgKXk%CgKoC!D}LJp|rIoe%%$aqd}IP}&J4xf;~HN>cql5%&-i3=>pT$&h8y z)$Wr1YWzCcu36@Y@W(rD9Tz?%)XIjhk_Q=&xhVS>HRBd)kr`+OrE`}p{gQ8^s$at7 zQZbfpzs#_eYMvV0@N-?g%ZiP+Yf}5|HU9WzE-cOQU+f2$9C$?@o{{b#g%+(9d*u2> z*0GZl1==JZ-$RqbCVven7CiE-BUZ!Z+RFcm#h$&4vo2fo61E@nu;ecPIuKU#HuJ3b zk`kro>Y%uoiSaAWqJn|2XT?n<{ACOqP4H?e?m+;VF1b2=OnC%sR4!|m6Y3;mJFiIX zzpt+@jd3MyL5-u=T@5Wid;hA=`c(WZVq$#WEu>kxJ5I+F4nbK_o0DhzfY+J_y zYsMPv@$rZRddr~n4h>`BEJXy<-8pJ0)5sLlw?~xN_4}@lGnVkS6WO56pN^1F4IVKb z`X9LpJ(#K4WK4Bb%MU!n>3o68rv`LJ4a-Qzodqb)8sWEjWIxA!Au{j7S(l+AA(IZl zQ+t|Qfwqeh&hvir8BmPiAbaPGY;ei2Vqls)f7E*2eQ9bz@ZTQ_tJE`p5AqUfeA@8e z`6>mrdeaFmJ%k>Np;`=vObn@gO~%9e*fyhceT=#cpgqDY#9=C)@8Z243XN9eK2VA; zgO__*|JE%PsSZs;JKh-F^dhD4rD^mM%1@(doV`#+_2nnNmEL%4kWo6$X1y=_ASPT- zK6ifcKxSc{&v)S&hH~SHqY6eo@^@?vKaWrWRmH9v+@ky%fWiLN78+~OkC zXULlMMuqQ?wm|GjMA9F3b(D40P%Z=3BM}rr@VD5pnoWP{`eUQ)Nm2CU`?i9}5*qSp zGgBqv2NN1#M@KN`l1w%P)U$vxcvd&-y|?z8ulj#x_xKxU2sgWt{Oi!G2~> zu6x>G#PjOtcm%$KzwzAE9&w+#6B7-pI^i*Zy5YT9r?0ki1u$ZUBr~4NU|d_!E{Qc8 zBj7u35Bqa+Nu+OB_FtSOC=TMT4zz`D{i*sLWR?iu!MF|G;Qs#hpM`sZUQ@QFWmWxp&munT&U6(z>5o?*^uv1e2)4sYam}e0!5lS?!5(ih%PLiG>_hlc}(Yp`|oaQC)Loy)-c%R(wGGrnYwen%d za0Ftnt^SrZXtedS8C!nx3&PKZJjS3ENFggMoH_YIW5~KASoDUL>=#DJ%}O@srF*5IK_>4c_QD7 z_|+BjmNDba4|wqsX~btz>nX?Ey(7`K@YeN$N-`_G-fje;^z@M2aF6!yvtNmhSfeyf}I;&=NRX z{-l#GHoHu6n**qVTou$_t|nJ^#PCR{+(#4h-R*49t-s6^S~KJOdf6#sRpbWyrsFqx zA*k%h5|i3==$t?_{CGne4w#%7k#VQdF*WV{93+&2q}Cdj2{~r>{f1Y}tB7$87%d-Q z%!~2~$>;(sfs=-#`~a}w$gsH#{zZ;^bgBplXwHu8JSoih^)E}M-0_ff&N+5}h0X*{ z9Cf;qu%nxxmnWY_apKdqahGCx-=T{(*o_gK!6Wh9Z=ZGoZFW?C64>K?f^ZvmEjQJQ zI*nFpb3261r0PyVx5%YgJt<-X=D}(=K+K7S3sreYV`Q&cF?Njs3?c)npuCplvwHf^ zPFp2I*wIo7XKV6xN_WVakNDp#1GlyyC&Re@G#`6_$IDhU5bqfg4X=qu!x;3~vqU2( z3p@^5XX5$uhsaO;tn!qN=pC;w^<-$^sP9&X%h?W0I~a3ENV`;sInM<=W2*1(5*|1! zQUb!vwZ@R3YX73-m+m6R$JkF1!0gPk8p0`&u)hdGJ7c{&E0-Eq0g7_9(Vy#%T`u|j zY$m2{iY5WH)r}6=AXK^d@wDyRnLAxFTvmL(_EndHX34b6igam3-C9?JjEn$b%~^l% zPyXNe=aSvvKk|H@7PmTXdgBYhjC5&HGkWs{AZmtQqce?@^2zYPJc$OAgi;5g49jB8 zjg#<|c3`$XXuX0DQqCRS(z2pO@s@yj#*_gRDijvZ`;*>TDhz|j6_a0(sYUGiz@%gV zajVmj-phKprcs#h3HdKb<;D1UpfW&XlUwpS9U<|dj(`MTBHS7kwe_Wb!S`8F7+u#2 zIAAm9;&bueqh*g_Z3|VJ_;SF5XS5npNs;+>COzlDBc^gw+oHoMP90SIsQC_zn@&~P z&}L|IZBJN+^$;zgT(+KAV0lqv=d&^NBky!ZMqz}jDmv6cybW%JC2iTs8E!SjF8hd7>rwK!}PbJ5~KiR ziiAUE=ddl8o4`P)cgxF$S|b>CsqM`3M)zxp0Pd>wLb!-? z8{1U5xv_CPIO9+2BZ#5*T$tT7WHsxzMC6!dK;wZ8ro|ZoNxog7kfYto!jzHL42+8| zB{cmS$!viwFg{RJTx`py!;y1rb`1V5(%x-Sq7uKRh>DlF_TLle|0JtBy=Iv(Ofji~ zRZJ$r`F+9|i)<72addGyJi>4HMkz;U?fr(ec)ymrHYs&^KvS;daTY?EB*NgtsM@d; zq2HyN(Ti>Dnkro@i!!1~a=&JlC3GsX7WD=n)XyUK!n_{RxHDCP9csmLqr~E0j2fv* zH%9eb=Fn1ndbnUgC%an))Rh#nWv;HdX}biNAPS6!iXlyj?q?I_a!LA(EjZj6Jd3u- z31%u6AKav~e#!LJr||W3wk7a}zHQYrH4SAA$7~0-7QqGh-Geb#TdV_QxgWP=LX4%5 ziikeI@_t!!hWi*viECl)F&2u`;x(--NDWiHy67y)lg#Qp+mA^&PF3ThxF>kng*9u6 zBi(?7X4}nMMDE?gtLFTItyd5^owF9yxG6$t!Zva@i?=3&$4!3v!f*5sP0HJP@IF|CSYwB;aXA$vgE zBARa~^o4KSoZ7=A9bF^jtZ_=zf;9h(!TP+AYCfusw#_BR079?2t$ve7OgC2H)T6uE zFnXBpICOX7X@2N%@rWZ5@_Y|CctnA7bPfGCQ0}oZQ={BFv@$R$L)hQMufb0fADqYJ zPX-=Kem$GS(18C}Dz3syEzq(z6L1!@sA@L45a@L^o!&RYdedxMh+75*7DLebV*M`E z=yJdad=5A5HRc-@4d149d84QSFfKeFq%bK^pZHXo|ZKrCa9O_J*2?ROGCbO zT+>uyi0<=0RHyJ8hfU_(09WGhUxWtE`P!=zJNvt2B3a?1S?7@eh<5qVj4RYrp2`$X zU3IVd@CjeHJ-PC$bb)CvJP%l=1(xpnwrRfWJq0-@;xW>Xja%iSeH>^CHJWl;22crQ zDT##O`*6U%!;Bt~?E->iZXzw#MK>lIx&XeU1dRQy{W^^Oruwgtgi~T%_$V9;#>A1X z0-RF`CWyNPqDwtB9#i-4#{+*dyc^t7l6p`U6gL8tZ7bXV;gYal1~itHJqo$=!y19L zkDIB{_Y=&8ljXnjai)WR^aVuTLTF7r>;?>scuv5+!dzc<_(}GB)bO5)tAQ|TxrV-E z99?UXD<2Egyohln>f9XNp4Tv=BTrPIdq+`}VEGcHfOj86TYW+-Nfy8OPvUw~iat=_ zbVsv?hOhiB1L2&v=&$zGCOLK@?>OrYZC%H=)DR-%ST?~P;XGHYzU2c*7ypN%t12N+ zLCma+!@lmV!1;3_Dx zWNS!`l>J)+X~5x@L_F?y?wh$2S@Wt*B9iR`6P;BCM;Zt^cbS+c=Nml*#g&kx){h zicKevIp=(KVXZZ@q9p*vx2Sfmk3_w>==2}{k1kwf^Fr2icgY!e*WXcD11F#9Q9sM{ z^@8A*hb=08+S(0WEgAG5SLc+I6Jsa}Iz;2IvSR+b(T$EJnh`#)H+wD!rQMPoUG@2S zDYw^X=pha&;GdPNkEXYzSA_BM`Zy1D978aO zWHD4^SN+xMfJ&jRGQr6{6pa-AUeS#k+^h6>9t*LI?Mzx@TBZT@tB`CDcd30VP;@W| ziOHzBMRP0MxkoaCF?m9_iJq{%;AB|FWvu&3w52xlPI@fZcjtR5d&&7F!j^)XaLK#& zWV(%&;bldZ1!Gx^ypa*(e6zI0g9EET5!t=JH12Op-<$?xu+sq}6Wr_+9-PS&fpGAY zk~6>og>pg*<|bdXyd?>qY(=R<5bBgM3YHi5nxF)QouGQAmA90Xk>=EyuR_>J>O8}c z?=VtcJxsH1fqY3rck%su?4=;3AKI7&AAmsbC5er26C&WYSoI~L4F;IWFtHo`iaPwk z<_cNuyGNh>g#yFkT`i>J#s$(Cms zr?fNh25pNcX~w)DoAa2q27UtX>YYJ1CCgm4p%(ysRivmbS!We~i!j_f`5ktg?$XRI zWbt;ly?>4!msG~A-V%eX7x58}&l^v?0Z6$pyoV$gfvF32Pf%ak|Fi|AU%PN1$A2-i3DTS=d5OW%N4CzU%PnEyiTcp6wHNg?V z&!3Yc^!Y9^nf60?wRz|!5^i#o6ev6n_t7rKUl!P+B){4rXAa>+7xthlP(nHPCh;-az0hq?0UgJd6NyEo1SxN1Nm_9kw62{ zx?z$O%KHLb=RT)Gdd~D`*0>xfP`Hl(uEV$#)W7hO@iWXheT8k?Dk_1Q(O}sG*T6L$~&6n#)sI7Gdmt&W5Ea#?p8f++x^zU4oh?)88Vo+p4X3 zcX>H?=cEp078-{K%w8D`rFH=+pa^UqaeihsZTbnNrhMq${w3ifoFPB4=3+<1&0qGh`!$jfE$fd2AN>0Q!=oN(wE#YnG{d)bS-@^3dyn>`NSTv@HJj z!JV3yG<$11HMVRVUgGVdG`y~8kFW;^+{7M~=R=ELueAFQQ&~Sf!7qC){AODsul=h#|KN(0-M3s9$`SB#WZ%{cm$U*D?`dP7iPuRuRfa27g z^Gr@m{lU% z(M*^mz+IQoJLC7Pbl2qEQZ@EO)k-g`0qi1l(u75Z#Q{j>mXM)THf>!DGBY?^Lfwwj z(lneo;$}KRJ-*vv6UF|`gpUsUelJ>CE;%v>o}SO7xB7U=RNRKzh3`cSW1aN5eGFr=goWEj-81&Pe1WxvcHvnz9Xt(b845Eyu8IjUr&nEXbvDXFA zM)RTarYmK~x&y2B#$M*?Y12d3E*%@D=h@K49w(wDH9-Q7pIGVOB3NnjAeR0yW)%}? zbK9f#QkYr&Ij9Z`{K+ZFBU;Tk%e{Hxu9yy?SAi7Fz|JJE!ih3gRX(2LH|?XUT#Bx> zT&sbl$8W^&;~68QIUmBr0enWTu$_*zh*atzAuze!?Gm6=S`}?e;Q|WgG0-e7>ED14 zyeo5Ep$-Rc=UwsWVEVFc(D(uVD&!Yiwsuwx_74Ri^re_U z?;@AN%h3*T0zUYtOtrqv`w{eCYk}?__yRTh;oTV94*l#>Q0QRa;_Uy@I*>i95+uSV z>lylRhxNJlxDCF7zwjLRh;9^&*_+`#FSkhsaT~q{!nNqb0l^k#RG>VzOFyTfV^N-L zR&AUyTwBcVVOg$`+b|pRb5A}od)`5jRs7BKs%2S`_2w&G!f=mfH~wQM^UMSG<=?kO zUf*KDme*jp(mrCDl~opo2H@Eiu~)A^%n@OiWSslxVwuu%a=U(OfTs(cb91 z=4l#P*@Maqm_(4P7J~A$!qNu$UzG9Jy6B3PJNq?VvK|?`Q6IdNkPJQDQ#0u70qM?g~8}!wt847)^UViwWRE*#l=A zE3uWuFV{4_*;RYj(P)r`_hlSagdDo|-4fXoh6pEc_sV@^KI6AB!c6&4_=}&7WVJ@4j((T$rd!j?EECe+<1$eitf;Ivi_*RT2^P0X+9&9GTaHd`aF(0 z^(oL|w_tZWGLz4#(bI;N9jQ|33`iI|YT>1Pz403Atn_JK?Se``M6t_=nDrZHG+KZx zSYyAibIx(2=n)A!XQl%6m;5er#M^g+Y}hiahHFtP`(fHsPt5mDTHgTs#@)-OQjwR5 z<#khEn@Jlmu=jnKAB^PAu==kv7TvBvxBb0j%%1tL<4kRX4^?=&l?_%NJVC{Kq>{s_=v7#2;*A>-Qptk#J#^eoa zXPwv@acYHFT213{X{TYTde5}KNpm|u&HW#GQEGS~J-PB{2Ax?_W^$~_7w{>O${+PQ zKk%SAMz34o^%j=JzDH7%p-O*Jumz2V7EI-!*Glfg2tJjF6Cg0W{0el_aUYaQ8c}j2 zpr1@R6KQ6hVrocE;5B>S+&Y|6FL_o^X%FuWPH_3wg9t)DjzKm&p~3s!5JpCAdD_+n zT97Ng;lMZw4QeGVqjvim{0GEyIe4K!8Z_R%#AO)dUHfnlBn(9|l%WGUIMCtcPHhv` zR>AM?8Qj~>W@YL*E|Z~JGgS6FRsJS_&07}tF#NYy%9n+W9hgF(;h5;7>--_Z5H?FU z_G@kvy1Kybv)}GOtWV?m<<1z2z{Mj~>bzD>s1e$3*;cm1%0_8aObr_j4|EmA&2%}O z+QT96h7Pp9efTboTG^Me-RHw+h6 zf2A7x@|-+~88_4mCYDE3mD@%lHp~=alENIX6u5y`OH**6>!2&IWFyjSH&`Qm2Dh-< z&D|q3t_R{iohM}B17I*yHY6yZc)wF@N*+g&RS?ij>t7iT`nJ_eJ=z>>o!c?Er>B+V zBh@|omY@Ipc}_K(!Y@K{e8qWy^sDzpI8%?U_fq^~Zg8}jt0DD0A7uwaO61#4&bk$t zLUFoW!81Xj?y#J-0M8WaonY~_JB?oom{`$%a+-^rUFC?#R4ZFgwTy2;^btWM6bQ4} zj)mrj2S|ve0z&#a&7B#x4Yt3#WWf~`oGZ_PfTcE;jKIuXm4zd`>jF z{Z2zn&34j<|4YMFOw=G&y_gA5(_tnMcIS1q%qWvYyl(Yca!ylnase(Hb?%R$d(oGj z>A_S@X>Y~OnUXn)lV~7VH5~KS$gXRPWr%h;N0fj51tDA$V&cpDcnaTTn3Q<3%L2uK>scaS-z*25vu^%h+r0hPXIr;JtK`1c>8NuRrTr&Uc%Cw;; z|HTG)dwUuQqDaN)n;bD%E!T3a3Oo-i>i*o4n_3hF!#T)LA76hEc^8-$B!19e5N&?e zo=grRzp28}2zRBD7%)v(jsqI63r3_d`1JR}_!5dQ@jsj#Z;G)6!I}?Pe*lIrEJ%~l z^ofTyLh}S>>h*R!0h$gW7!w|ePw6hbRc8A`$I&Xu2Xw|q5FjVN;GZY0`_K}ruP(qk zj4%l<{kRv3t#M?3WNVzUz3e*4;+kyr{7mTXZj1H7zG9_^fc~>HvgFMx{h$sRI9oCR zJg~O#0nBp$=Asi<`$uBShcM1)*gbBO1ry^s?09FvccYN&yj8>!8I-_80AKW<|3EHZ z|H{Mlv;X*PmE-?&dT2ZD?nUy&Eiyn><#O}N7KuIc z#Ig}y+bh*~@WHqy@Mm>G(@0*y$Zm6(B()oFi!{6On%1hnN@zSh(goJhlY_TC9EYSl zg4Q<BRX728a8!} zO2G?78fP%pmo*)iU9IxkbyGyNkcdrzKEZQ@-1hQL{*J5T7u^^&rt1pgtrH6f56^WW zKc8R`y-CRq3;M0?rYIM(b`1RSnLtb6#$Ffshou;K@rBbvy#BZgs#ibCWXwyTq#Ym_ znYLCMU*pA5!WgIwB|JMlWcTS9Sy3TACL7soVgK%(*#?K7CnBWOuym^H0(x-kZS=(u7Cbuod{zCsfZrYdX;) z0SVCvQcp0WoCm9{3oz}cqxop`Q+H4WcX|)8zPZdlFj+bNeHt@wAZ-WYcq6%ijj&X=DRHtY zxm$GQf*mvJv$se&$P8-?B*;*Cr%JHpJPMVJtx~Wdsw9JnDm_AY7I~ubFx!x1Fi7wK F007b&;2Zz| literal 5814 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 A2mk;8 diff --git a/site/src/components/Callout.astro b/site/src/components/Callout.astro index e009af5d..a9c248a9 100644 --- a/site/src/components/Callout.astro +++ b/site/src/components/Callout.astro @@ -2,10 +2,11 @@ /** * Callout — a labelled aside for the eight Guide Companion states. * - * Each state pairs the painterly companion sprite (a trailing-edge badge) with a - * state-coloured glyph + label. The glyph and label carry the meaning, so the - * callout reads for colour-blind readers and in high-contrast modes; the sprite - * and its state-coloured screen-glow are enrichment. + * Each state pairs the painterly companion sprite (a trailing-edge badge, sliced + * and chroma-keyed from the 8-state sheet) with a state-coloured icon chip + + * label. The icon and label carry the meaning, so the callout reads for + * colour-blind readers and in high-contrast modes; the sprite and its + * state-coloured screen-glow are enrichment. * * Usage in MDX: * import Callout from '../../../components/Callout.astro'; @@ -19,7 +20,7 @@ * * * Colour exclusivity: amber=warning, mint=success, gray=invalid, red=broken; - * info/hint/pitfall/debug share the companion cyan and are told apart by glyph. + * info/hint/pitfall/debug share the companion cyan and are told apart by icon. */ type CalloutType = 'info' | 'hint' | 'warning' | 'pitfall' | 'success' | 'debug' | 'invalid' | 'broken'; @@ -30,27 +31,40 @@ interface Props { title?: string; } -const META: Record = { - info: { label: 'Good to know', glyph: 'i' }, - hint: { label: 'Tip', glyph: '✦' }, - warning: { label: 'Heads up', glyph: '!' }, - pitfall: { label: 'Common pitfall', glyph: '?' }, - success: { label: "You're set", glyph: '✓' }, - debug: { label: 'Debug tip', glyph: '[]' }, - invalid: { label: 'Not supported', glyph: 'Ø' }, - broken: { label: 'Runtime error', glyph: '✕' }, +const LABELS: Record = { + info: 'Good to know', + hint: 'Tip', + warning: 'Heads up', + pitfall: 'Common pitfall', + success: "You're set", + debug: 'Debug tip', + invalid: 'Not supported', + broken: 'Runtime error', +}; + +// Lucide-style line icons (24 viewBox, currentColor), one per state. +const ICONS: Record = { + info: '', + hint: '', + warning: '', + pitfall: '', + success: '', + debug: '', + invalid: '', + broken: '', }; const { type = 'info', title } = Astro.props; -const meta = META[type]; -const heading = title ?? meta.label; +const heading = title ?? LABELS[type]; const base = import.meta.env.BASE_URL; ---