diff --git a/packages/exojs-tilemap/src/ImageLayerNode.ts b/packages/exojs-tilemap/src/ImageLayerNode.ts new file mode 100644 index 00000000..413b685f --- /dev/null +++ b/packages/exojs-tilemap/src/ImageLayerNode.ts @@ -0,0 +1,249 @@ +import { Container, RepeatingSprite } from '@codexo/exojs'; +import type { PixelSnapMode, RenderPlanBuilder } from '@codexo/exojs/renderer-sdk'; + +import type { ImageLayer } from './ImageLayer'; +import { assertPixelSnapMode } from './pixelSnap'; + +/** + * A scene node that renders one Tiled {@link ImageLayer} as a single + * {@link RepeatingSprite} wrapped in a {@link Container}. + * + * The layer's pixel `offset` becomes this node's position; `parallax` is + * resolved per frame in {@link _collectContent} by patching the node position + * against the camera centre (exactly like {@link import('./TileLayerNode').TileLayerNode}), + * then restoring it. When `repeatX`/`repeatY` is set the wrapped sprite is + * grown each frame to cover the visible view with a period-aligned origin, so + * the pattern tiles seamlessly and scrolls with parallax. A repeat axis or a + * `parallax` factor other than `1` both make this node's rendered position + * and/or size camera-dependent at collect time, so its static bounds cannot be + * culled against — such a node opts out of view culling (`cullable = false`). + * + * `visible`, `opacity`, and `tintColor` are applied **once at construction**: + * {@link ImageLayer} is immutable, so there is nothing to re-sync per frame. + * A layer whose `texture` is `null` (image failed to load / not yet available) + * produces an empty node with no children — it occupies its offset in the + * scene graph but draws nothing. + * + * The node references — but never owns — the {@link ImageLayer} and its + * Loader-owned texture: {@link destroy} frees the wrapped sprite but leaves the + * layer and texture intact. Image-layer nodes are **not** band-selectable; an + * application parents them into the scene directly wherever the image belongs + * in draw order. + * + * For a `repeatX`/`repeatY` node, bounds and hit-test queries reflect the + * coverage geometry computed by the last {@link _collectContent} call (since + * that geometry is recomputed per collect, not maintained incrementally), + * which is also why such a node force-disables `cullable`. A repeating or + * parallax node also re-sizes its wrapped sprite as the camera crosses period + * boundaries, which content-dirties an enclosing retained group the same way + * a streamed layer does — give it the same treatment (its own + * `RetainedContainer`, or none). + * + * @advanced + */ +export class ImageLayerNode extends Container { + private readonly _layer: ImageLayer; + private readonly _sprite: RepeatingSprite | null; + private readonly _baseOffsetX: number; + private readonly _baseOffsetY: number; + private readonly _imageWidth: number; + private readonly _imageHeight: number; + private _pixelSnapMode: PixelSnapMode = 'none'; + + // Repeat-coverage cache: the view span and patched origin that last drove a + // resize. A static camera pays one comparison per frame and skips the rebuild. + private _covViewX = Number.NaN; + private _covViewY = Number.NaN; + private _covViewWidth = Number.NaN; + private _covViewHeight = Number.NaN; + private _covOriginX = Number.NaN; + private _covOriginY = Number.NaN; + + public constructor(layer: ImageLayer) { + super(); + + this._layer = layer; + this._baseOffsetX = layer.offsetX; + this._baseOffsetY = layer.offsetY; + + this.setPosition(layer.offsetX, layer.offsetY); + this.visible = layer.visible; + + const texture = layer.texture; + + if (texture === null) { + this._sprite = null; + this._imageWidth = 0; + this._imageHeight = 0; + + return; + } + + this._imageWidth = texture.width; + this._imageHeight = texture.height; + + // Non-repeating axes use `'stretch'` so the axis shows exactly one un-tiled + // copy at its natural size; repeating axes use `'repeat'` and are grown to + // cover the view in `_collectContent`. + const sprite = new RepeatingSprite(texture, { + modeX: layer.repeatX ? 'repeat' : 'stretch', + modeY: layer.repeatY ? 'repeat' : 'stretch', + width: texture.width, + height: texture.height, + }); + + const tintColor = layer.tintColor; + const r = tintColor === null ? 255 : (tintColor >> 16) & 0xff; + const g = tintColor === null ? 255 : (tintColor >> 8) & 0xff; + const b = tintColor === null ? 255 : tintColor & 0xff; + + sprite.tint.set(r, g, b, layer.opacity); + + this._sprite = sprite; + this.addChild(sprite); + + if (layer.repeatX || layer.repeatY || layer.parallaxX !== 1 || layer.parallaxY !== 1) { + this.cullable = false; + } + } + + /** The runtime layer this node renders. */ + public get layer(): ImageLayer { + return this._layer; + } + + /** + * Render-only pixel-snap mode forwarded to the wrapped {@link RepeatingSprite} + * (which snaps its rendered origin, and — in `'geometry'` mode for + * axis-aligned transforms — its repeat-segment boundaries, to the render + * target's device-pixel grid). Purely visual: the layer offset, parallax, and + * repeat coverage are never changed. + * + * For a `null`-texture layer there is no drawable to forward to, so the mode + * is simply stored. Setting the current value is a no-op; an invalid value + * throws and leaves the prior mode unchanged. + * + * @default 'none' + * @stable + */ + public get pixelSnapMode(): PixelSnapMode { + return this._pixelSnapMode; + } + + public set pixelSnapMode(mode: PixelSnapMode) { + if (mode === this._pixelSnapMode) { + return; + } + + assertPixelSnapMode(mode); + this._pixelSnapMode = mode; + + if (this._sprite !== null) { + this._sprite.pixelSnapMode = mode; + } + } + + /** @internal */ + protected override _collectContent(builder: RenderPlanBuilder): void { + if (this._sprite === null) { + super._collectContent(builder); + + return; + } + + 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); + + this._updateRepeatCoverage(builder); + super._collectContent(builder); + + this.x = prevX; + this.y = prevY; + } else { + this._updateRepeatCoverage(builder); + super._collectContent(builder); + } + } + + public override destroy(): void { + const sprite = this._sprite; + + super.destroy(); + + sprite?.destroy(); + } + + /** + * Grow the wrapped sprite along each repeating axis so it covers the visible + * view, with a period-aligned local origin so the pattern offset stays zero + * (seamless tiling). Non-repeating axes keep the natural image size at local + * `0`. Reads `this.x`/`this.y` after the parallax patch, so the pattern anchor + * follows the parallax-shifted origin. Skips the rebuild when neither the view + * span nor the patched origin changed since the last frame. + */ + private _updateRepeatCoverage(builder: RenderPlanBuilder): void { + const layer = this._layer; + const sprite = this._sprite; + + if (sprite === null || (!layer.repeatX && !layer.repeatY)) { + return; + } + + const bounds = builder.view.getBounds(); + const originX = this.x; + const originY = this.y; + + if ( + bounds.x === this._covViewX + && bounds.y === this._covViewY + && bounds.width === this._covViewWidth + && bounds.height === this._covViewHeight + && originX === this._covOriginX + && originY === this._covOriginY + ) { + return; + } + + this._covViewX = bounds.x; + this._covViewY = bounds.y; + this._covViewWidth = bounds.width; + this._covViewHeight = bounds.height; + this._covOriginX = originX; + this._covOriginY = originY; + + let childX = 0; + let childY = 0; + let childWidth = this._imageWidth; + let childHeight = this._imageHeight; + + if (layer.repeatX) { + const imgW = this._imageWidth; + const localViewMin = bounds.x - originX; + const startLocal = Math.floor(localViewMin / imgW) * imgW; + const periods = Math.ceil((localViewMin + bounds.width - startLocal) / imgW); + + childX = startLocal; + childWidth = periods * imgW; + } + + if (layer.repeatY) { + const imgH = this._imageHeight; + const localViewMin = bounds.y - originY; + const startLocal = Math.floor(localViewMin / imgH) * imgH; + const periods = Math.ceil((localViewMin + bounds.height - startLocal) / imgH); + + childY = startLocal; + childHeight = periods * imgH; + } + + sprite.setPosition(childX, childY); + sprite.setSize(childWidth, childHeight); + } +} diff --git a/packages/exojs-tilemap/src/TileMapNode.ts b/packages/exojs-tilemap/src/TileMapNode.ts index befa7b4b..1cc4038e 100644 --- a/packages/exojs-tilemap/src/TileMapNode.ts +++ b/packages/exojs-tilemap/src/TileMapNode.ts @@ -37,6 +37,9 @@ export interface TileMapNodeOptions { * Layers added to or removed from the map after construction are reflected only * after {@link TileMapNode.refreshLayers}. * + * `TileMapNode` renders only tile layers; for a map with image layers, compose + * `TileMapView` and its {@link import('./ImageLayerNode').ImageLayerNode}s instead. + * * @advanced */ export class TileMapNode extends Container { diff --git a/packages/exojs-tilemap/src/TileMapView.ts b/packages/exojs-tilemap/src/TileMapView.ts index 39b9e572..a34d32f7 100644 --- a/packages/exojs-tilemap/src/TileMapView.ts +++ b/packages/exojs-tilemap/src/TileMapView.ts @@ -1,5 +1,6 @@ import type { PixelSnapMode } from '@codexo/exojs/renderer-sdk'; +import { ImageLayerNode } from './ImageLayerNode'; import { assertPixelSnapMode } from './pixelSnap'; import type { TileLayer } from './TileLayer'; import { TileLayerNode } from './TileLayerNode'; @@ -83,6 +84,22 @@ interface ResolvedBandDef { * mutation API: to swap maps, destroy the old view, construct a new one, and * re-parent its bands — the actor tree is never involved. * + * **Image layers.** A view also produces exactly one canonical + * {@link ImageLayerNode} per {@link TileMap.imageLayers} entry (stable identity, + * map document order), reachable through {@link imageLayerNodes}, + * {@link getImageLayerNodeById}, and {@link getImageLayerNodeByName}. The view + * owns these nodes the same way it owns unbanded tile-layer nodes — the + * application parents each one wherever the image belongs in draw order, and + * {@link TileMapView.destroy} destroys them and detaches them from their + * application parents. Image layers are **not** selectable in + * {@link TileMapViewOptions.bands}: a band definition only ever reorders its + * own members to map document order, and image layers do not share a combined + * document position with tile layers — mixing them into one band's selector + * list could not resolve a single, unambiguous render order without silently + * guessing at an interleaving. Interleave an image layer with tile layers or + * bands by parenting {@link getImageLayerNodeById} / {@link getImageLayerNodeByName} + * directly, the same way actors are interleaved. + * * @advanced */ export class TileMapView { @@ -105,6 +122,11 @@ export class TileMapView { /** Unbanded layer nodes owned directly by the view, in map document order. */ private readonly _directLayerNodes: TileLayerNode[] = []; + /** All canonical image layer nodes, in map document order. Never band-selectable. */ + private readonly _imageLayerNodes: ImageLayerNode[] = []; + /** Image layer id → its canonical image layer node. */ + private readonly _imageLayerNodeById = new Map(); + private _destroyed = false; private _pixelSnapMode: PixelSnapMode = 'none'; @@ -136,6 +158,13 @@ export class TileMapView { this._directLayerNodes.push(node); } } + + for (const imageLayer of map.imageLayers) { + const imageNode = new ImageLayerNode(imageLayer); + + this._imageLayerNodes.push(imageNode); + this._imageLayerNodeById.set(imageLayer.id, imageNode); + } } /** The runtime map this view composes. Referenced, never owned. */ @@ -186,6 +215,10 @@ export class TileMapView { for (const node of this._layerNodes) { node.pixelSnapMode = mode; } + + for (const node of this._imageLayerNodes) { + node.pixelSnapMode = mode; + } } /** @@ -208,6 +241,45 @@ export class TileMapView { return this._layerNodes.filter(node => node.layer.name === name); } + /** All canonical image layer nodes, one per map image layer, in map document order. */ + public get imageLayerNodes(): readonly ImageLayerNode[] { + return this._imageLayerNodes; + } + + /** + * The canonical image layer node for the image layer with the given **id**, + * or `undefined`. Ids are authoritative and unique — this is the unambiguous + * lookup. The returned node may be reparented into the caller's own + * containers; the view still tracks it for destruction. + */ + public getImageLayerNodeById(id: number): ImageLayerNode | undefined { + return this._imageLayerNodeById.get(id); + } + + /** + * The canonical image layer node for the image layer with the given + * **name**, or `undefined` if no image layer has that name. Prefer + * {@link getImageLayerNodeById} when you have the id. + * @throws If more than one image layer shares that name (reference such + * layers by id instead). + */ + public getImageLayerNodeByName(name: string): ImageLayerNode | undefined { + const matches = this._map.imageLayers.filter(layer => layer.name === name); + + if (matches.length === 0) { + return undefined; + } + + if (matches.length > 1) { + throw new Error( + `TileMapView image layer name "${name}" is ambiguous ` + + `(${matches.length} image layers share it); reference it by id instead.`, + ); + } + + return this._imageLayerNodeById.get(matches[0]!.id); + } + /** * The band registered under `name`. * @throws If no band with that name was defined. @@ -247,6 +319,11 @@ export class TileMapView { * Application actors are never touched, and bands keep their placement in the * application scene graph. * + * Image-layer nodes are constructed once, at view construction, and are + * **not** reconciled by this method: a subsequent + * {@link import('./TileMap').TileMap.removeImageLayer} call on the map is not + * reflected in {@link imageLayerNodes} (full image-layer reconcile is deferred). + * * @throws If the view has been destroyed. */ public refreshLayers(): this { @@ -313,10 +390,11 @@ export class TileMapView { } /** - * Destroy the view: every band and generated layer node is destroyed (and - * detached from its application parent), freeing cached chunk geometry. - * Idempotent. Application actors, sibling content, the {@link TileMap}, its - * {@link TileLayer}s, and Loader-owned tileset textures all survive. + * Destroy the view: every band, generated tile-layer node, and generated + * {@link ImageLayerNode} is destroyed (and detached from its application + * parent), freeing cached chunk geometry. Idempotent. Application actors, + * sibling content, the {@link TileMap}, its {@link TileLayer}s and image + * layers, and Loader-owned textures all survive. */ public destroy(): void { if (this._destroyed) { @@ -334,6 +412,11 @@ export class TileMapView { node.destroy(); } + for (const node of this._imageLayerNodes) { + node.parent?.removeChild(node); + node.destroy(); + } + this._bands.length = 0; this._bandByName.clear(); this._bandDefs.length = 0; @@ -341,6 +424,8 @@ export class TileMapView { this._layerNodes.length = 0; this._layerNodeById.clear(); this._nodeBand.clear(); + this._imageLayerNodes.length = 0; + this._imageLayerNodeById.clear(); } // ── Internals ────────────────────────────────────────────────────────── diff --git a/packages/exojs-tilemap/src/public.ts b/packages/exojs-tilemap/src/public.ts index 986ee45a..a00bfba2 100644 --- a/packages/exojs-tilemap/src/public.ts +++ b/packages/exojs-tilemap/src/public.ts @@ -38,6 +38,7 @@ export type { TileSetOptions } from './TileSet'; export { TileSet } from './TileSet'; // Scene/rendering nodes (advanced). The per-chunk drawable and the // per-backend renderers stay package-internal. +export { ImageLayerNode } from './ImageLayerNode'; export type { TileLayerNodeOptions } from './TileLayerNode'; export { TileLayerNode } from './TileLayerNode'; export type { TileMapNodeOptions } from './TileMapNode'; diff --git a/packages/exojs-tilemap/test/ImageLayerNode.test.ts b/packages/exojs-tilemap/test/ImageLayerNode.test.ts new file mode 100644 index 00000000..2d195969 --- /dev/null +++ b/packages/exojs-tilemap/test/ImageLayerNode.test.ts @@ -0,0 +1,351 @@ +import { RepeatingSprite, type Texture } from '@codexo/exojs'; +import { describe, expect, it, vi } from 'vitest'; + +import { ImageLayer, type ImageLayerOptions } from '../src/ImageLayer'; +import { ImageLayerNode } from '../src/ImageLayerNode'; + +// ── helpers ──────────────────────────────────────────────────────────── + +function fakeTexture(width = 64, height = 64): Texture { + return { + width, + height, + flipY: false, + uid: 0, + label: 'test', + destroy: vi.fn(), + destroyed: false, + } as unknown as Texture; +} + +function makeLayer(opts: Partial = {}): ImageLayer { + return new ImageLayer({ + id: opts.id ?? 1, + image: opts.image ?? 'bg.png', + texture: opts.texture === undefined ? fakeTexture() : opts.texture, + ...opts, + }); +} + +/** + * A minimal stand-in for `RenderPlanBuilder`, mirroring exactly what + * `Container._collectContent` and the drawable-child collect path read: + * - `view.center` — the parallax patch source, + * - `view.getBounds()` — the repeat-coverage span (Rectangle-like), + * - `view.updateId` — the retained-plan revision key, + * - `_isViewCullSuppressed: true` — makes the child `_collect` skip the + * `inView` frustum test and go straight to `emitNode` (no cull machinery), + * - `emitNode` / `_peekCurrentScopeEntries` — the no-slot capture bookkeeping, + * - `backend` — stored verbatim by the retained-plan cache commit. + */ +function mockBuilder(options: { + center?: { x: number; y: number }; + bounds?: { x: number; y: number; width: number; height: number }; +} = {}): unknown { + const center = options.center ?? { x: 0, y: 0 }; + const bounds = options.bounds ?? { x: 0, y: 0, width: 0, height: 0 }; + + return { + _isViewCullSuppressed: true, + backend: {}, + view: { + updateId: 1, + center, + getBounds: () => bounds, + }, + emitNode(): void {}, + _peekCurrentScopeEntries: (): readonly unknown[] => [], + }; +} + +function collect(node: ImageLayerNode, builder: unknown): void { + (node as unknown as { _collectContent(b: unknown): void })._collectContent(builder); +} + +function spriteOf(node: ImageLayerNode): RepeatingSprite { + return node.children[0] as RepeatingSprite; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ImageLayerNode — construction +// ═══════════════════════════════════════════════════════════════════════ + +describe('ImageLayerNode construction', () => { + it('renders nothing for a null texture (no children)', () => { + const node = new ImageLayerNode(makeLayer({ texture: null })); + + expect(node.children).toHaveLength(0); + }); + + it('creates one repeating-sprite child sized to the image for a plain layer', () => { + const node = new ImageLayerNode(makeLayer({ texture: fakeTexture(64, 48) })); + + expect(node.children).toHaveLength(1); + const sprite = spriteOf(node); + expect(sprite).toBeInstanceOf(RepeatingSprite); + // Natural image size, positioned at the node-local origin. + expect(sprite.width).toBe(64); + expect(sprite.height).toBe(48); + expect(sprite.x).toBe(0); + expect(sprite.y).toBe(0); + }); + + it('positions the node at the layer pixel offset', () => { + const node = new ImageLayerNode(makeLayer({ offsetX: 64, offsetY: -32 })); + + expect(node.x).toBe(64); + expect(node.y).toBe(-32); + }); + + it('applies visible/opacity/tint statically from the layer', () => { + const hidden = new ImageLayerNode(makeLayer({ visible: false })); + expect(hidden.visible).toBe(false); + + const faded = new ImageLayerNode(makeLayer({ opacity: 0.5 })); + expect(spriteOf(faded).tint.a).toBeCloseTo(0.5, 6); + + const tinted = new ImageLayerNode(makeLayer({ tintColor: 0xff0000 })); + const tint = spriteOf(tinted).tint; + expect(tint.r).toBe(0xff); + expect(tint.g).toBe(0x00); + expect(tint.b).toBe(0x00); + }); + + it('exposes the source layer', () => { + const layer = makeLayer(); + expect(new ImageLayerNode(layer).layer).toBe(layer); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// ImageLayerNode — parallax (mirrors nodes.test.ts TileLayerNode cases) +// ═══════════════════════════════════════════════════════════════════════ + +describe('ImageLayerNode parallax', () => { + it('initial position is the layer offset (not parallax-shifted)', () => { + const node = new ImageLayerNode( + makeLayer({ offsetX: 10, offsetY: 20, parallaxX: 0.5, parallaxY: 0.5 }), + ); + + // Construction must NOT apply a parallax shift — the shift is render-time only. + expect(node.x).toBe(10); + expect(node.y).toBe(20); + }); + + it('position is restored to the base offset after _collectContent', () => { + const node = new ImageLayerNode( + makeLayer({ offsetX: 10, offsetY: 20, parallaxX: 0.5, parallaxY: 0.5 }), + ); + + collect(node, mockBuilder({ center: { x: 100, y: 200 } })); + + // After the call the node position must be restored to the base offset. + expect(node.x).toBe(10); + expect(node.y).toBe(20); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// ImageLayerNode — repeat coverage (the core math) +// ═══════════════════════════════════════════════════════════════════════ + +describe('ImageLayerNode repeat coverage', () => { + it('repeatX: child spans the view with a period-aligned origin', () => { + // offsetX 10, parallaxX 1, image 64 wide; view bounds { x: -130, width: 300 }. + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(64, 64), offsetX: 10, repeatX: true }), + ); + + collect(node, mockBuilder({ bounds: { x: -130, y: 0, width: 300, height: 200 } })); + + // parallaxX 1 → nodeX = offsetX = 10 (no patch). + // localViewMin = viewBounds.x - nodeX = -130 - 10 = -140 + // startLocal = floor(-140/64)*64 = floor(-2.1875)*64 = -3*64 = -192 + // periods = ceil((-140 + 300 - (-192))/64) = ceil(352/64) = ceil(5.5) = 6 + // child.x = -192, child.width = 6*64 = 384 + const sprite = spriteOf(node); + expect(sprite.x).toBe(-192); + expect(sprite.width).toBe(384); + }); + + it('repeatX with parallax: pattern anchor follows the patched origin', () => { + // parallaxX 0.5, center.x 100 → patched nodeX = 10 + 100*(1-0.5) = 60. + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(64, 64), offsetX: 10, parallaxX: 0.5, repeatX: true }), + ); + + collect(node, mockBuilder({ + center: { x: 100, y: 0 }, + bounds: { x: -130, y: 0, width: 300, height: 200 }, + })); + + // nodeX = 10 + 100*(1 - 0.5) = 60 + // localViewMin = -130 - 60 = -190 + // startLocal = floor(-190/64)*64 = floor(-2.96875)*64 = -3*64 = -192 + // periods = ceil((-190 + 300 - (-192))/64) = ceil(302/64) = ceil(4.71875) = 5 + // child.x = -192, child.width = 5*64 = 320 + const sprite = spriteOf(node); + expect(sprite.x).toBe(-192); + expect(sprite.width).toBe(320); + }); + + it('repeat coverage holds for negative view coordinates', () => { + // offsetX 5, parallaxX 1, image 48 wide; view bounds { x: -300, width: 220 }. + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(48, 48), offsetX: 5, repeatX: true }), + ); + + collect(node, mockBuilder({ bounds: { x: -300, y: 0, width: 220, height: 100 } })); + + // nodeX = 5 + // localViewMin = -300 - 5 = -305 + // startLocal = floor(-305/48)*48 = floor(-6.3541…)*48 = -7*48 = -336 + // periods = ceil((-305 + 220 - (-336))/48) = ceil(251/48) = ceil(5.229…) = 6 + // child.x = -336, child.width = 6*48 = 288 + const sprite = spriteOf(node); + expect(sprite.x).toBe(-336); + expect(sprite.width).toBe(288); + }); + + it('non-repeating axis keeps natural image size and local 0', () => { + // repeatX only; Y must stay natural (imgH) at local 0. + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(64, 48), offsetX: 10, repeatX: true }), + ); + + collect(node, mockBuilder({ bounds: { x: -130, y: 55, width: 300, height: 200 } })); + + const sprite = spriteOf(node); + // Y axis is not repeating → natural height, origin unchanged. + expect(sprite.y).toBe(0); + expect(sprite.height).toBe(48); + }); + + it('repeatY: child spans the view vertically with a period-aligned origin', () => { + // repeatY only; offsetY 10, parallaxY 1, image 64 tall; view bounds { y: -130, height: 300 }. + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(64, 64), offsetY: 10, repeatY: true }), + ); + + collect(node, mockBuilder({ bounds: { x: 0, y: -130, width: 200, height: 300 } })); + + // Mirror of the repeatX derivation on the Y axis: + // localViewMin = -130 - 10 = -140 → startLocal = -192 → periods = 6 → height 384 + const sprite = spriteOf(node); + expect(sprite.y).toBe(-192); + expect(sprite.height).toBe(384); + // X axis not repeating → natural width at local 0. + expect(sprite.x).toBe(0); + expect(sprite.width).toBe(64); + }); + + it('repeatX && repeatY: both axes covered simultaneously', () => { + // offsetX 10, offsetY 5, parallax 1, image 64×64; view bounds { x: -130, y: -70, width: 300, height: 220 }. + const node = new ImageLayerNode( + makeLayer({ + texture: fakeTexture(64, 64), + offsetX: 10, + offsetY: 5, + repeatX: true, + repeatY: true, + }), + ); + + collect(node, mockBuilder({ bounds: { x: -130, y: -70, width: 300, height: 220 } })); + + // X axis (nodeX = offsetX = 10): + // localViewMin = -130 - 10 = -140 + // startLocal = floor(-140/64)*64 = floor(-2.1875)*64 = -3*64 = -192 + // periods = ceil((-140 + 300 - (-192))/64) = ceil(352/64) = 6 + // child.x = -192, child.width = 6*64 = 384 + // + // Y axis (nodeY = offsetY = 5): + // localViewMin = -70 - 5 = -75 + // startLocal = floor(-75/64)*64 = floor(-1.171875)*64 = -2*64 = -128 + // periods = ceil((-75 + 220 - (-128))/64) = ceil(273/64) = ceil(4.265625) = 5 + // child.y = -128, child.height = 5*64 = 320 + const sprite = spriteOf(node); + expect(sprite.x).toBe(-192); + expect(sprite.width).toBe(384); + expect(sprite.y).toBe(-128); + expect(sprite.height).toBe(320); + }); + + it('repeat layer disables cullable on the node', () => { + expect(new ImageLayerNode(makeLayer({ repeatX: true })).cullable).toBe(false); + expect(new ImageLayerNode(makeLayer({ repeatY: true })).cullable).toBe(false); + }); + + it('plain layer stays cullable', () => { + expect(new ImageLayerNode(makeLayer()).cullable).toBe(true); + }); + + it('non-repeat parallax layer disables cullable on the node', () => { + expect(new ImageLayerNode(makeLayer({ parallaxX: 0.5 })).cullable).toBe(false); + expect(new ImageLayerNode(makeLayer({ parallaxY: 0.5 })).cullable).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// ImageLayerNode — repeat coverage cache +// ═══════════════════════════════════════════════════════════════════════ + +describe('ImageLayerNode repeat coverage cache', () => { + it('skips the resize/reposition on a second collect with unchanged view bounds', () => { + const node = new ImageLayerNode( + makeLayer({ texture: fakeTexture(64, 64), offsetX: 10, repeatX: true }), + ); + const builder = mockBuilder({ bounds: { x: -130, y: 0, width: 300, height: 200 } }); + + collect(node, builder); + + const sprite = spriteOf(node); + const setSizeSpy = vi.spyOn(sprite, 'setSize'); + const setPositionSpy = vi.spyOn(sprite, 'setPosition'); + + // Same view span and (unpatched, since parallax is 1) origin as the first + // collect — the cache comparison in `_updateRepeatCoverage` should hit and + // skip rebuilding the child's geometry entirely. + collect(node, builder); + + expect(setSizeSpy).not.toHaveBeenCalled(); + expect(setPositionSpy).not.toHaveBeenCalled(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// ImageLayerNode — pixelSnapMode +// ═══════════════════════════════════════════════════════════════════════ + +describe('ImageLayerNode pixelSnapMode', () => { + it('defaults to none and forwards a valid mode to the sprite', () => { + const node = new ImageLayerNode(makeLayer()); + expect(node.pixelSnapMode).toBe('none'); + + node.pixelSnapMode = 'geometry'; + + expect(node.pixelSnapMode).toBe('geometry'); + expect(spriteOf(node).pixelSnapMode).toBe('geometry'); + }); + + it('rejects an invalid mode and leaves the prior mode unchanged', () => { + const node = new ImageLayerNode(makeLayer()); + node.pixelSnapMode = 'position'; + + expect(() => { + (node as unknown as { pixelSnapMode: string }).pixelSnapMode = 'bogus'; + }).toThrow(); + + expect(node.pixelSnapMode).toBe('position'); + expect(spriteOf(node).pixelSnapMode).toBe('position'); + }); + + it('accepts pixelSnapMode on a null-texture node (no drawable to forward to)', () => { + const node = new ImageLayerNode(makeLayer({ texture: null })); + + expect(() => { + node.pixelSnapMode = 'geometry'; + }).not.toThrow(); + expect(node.pixelSnapMode).toBe('geometry'); + }); +}); diff --git a/packages/exojs-tilemap/test/view.test.ts b/packages/exojs-tilemap/test/view.test.ts index bb335830..9f26e679 100644 --- a/packages/exojs-tilemap/test/view.test.ts +++ b/packages/exojs-tilemap/test/view.test.ts @@ -3,6 +3,8 @@ import { TextureRegion } from '@codexo/exojs'; import { type Texture } from '@codexo/exojs'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { ImageLayer, type ImageLayerOptions } from '../src/ImageLayer'; +import { ImageLayerNode } from '../src/ImageLayerNode'; import { TileLayer } from '../src/TileLayer'; import { TileLayerNode } from '../src/TileLayerNode'; import { TileMap } from '../src/TileMap'; @@ -64,6 +66,15 @@ function makeLayer(tileset: TileSet, opts: LayerOpts = {}): TileLayer { return layer; } +function makeImageLayer(opts: Partial = {}): ImageLayer { + return new ImageLayer({ + id: opts.id ?? 100, + image: opts.image ?? 'bg.png', + texture: opts.texture === undefined ? fakeTexture() : opts.texture, + ...opts, + }); +} + function fillLayer(layer: TileLayer, tileset: TileSet): TileLayer { for (let ty = 0; ty < layer.height; ty++) { for (let tx = 0; tx < layer.width; tx++) { @@ -725,6 +736,116 @@ describe('TileMapView across multiple maps', () => { }); }); +// ═══════════════════════════════════════════════════════════════════════ +// TileMapView — image layer nodes +// ═══════════════════════════════════════════════════════════════════════ + +describe('TileMapView image layer nodes', () => { + function makeImageMap(imageLayers: readonly ImageLayer[]): { map: TileMap; tileset: TileSet } { + const tileset = makeTileset(); + const map = new TileMap({ + name: 'imaged', + width: 4, + height: 4, + tileWidth: 32, + tileHeight: 32, + tilesets: [tileset], + layers: [fillLayer(makeLayer(tileset, { id: 1, name: 'ground' }), tileset)], + imageLayers, + }); + + return { map, tileset }; + } + + it('creates one ImageLayerNode per image layer, in insertion order', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const clouds = makeImageLayer({ id: 11, name: 'clouds' }); + const { map } = makeImageMap([bg, clouds]); + const view = map.createView(); + + expect(view.imageLayerNodes).toHaveLength(2); + expect(view.imageLayerNodes[0]).toBeInstanceOf(ImageLayerNode); + expect(view.imageLayerNodes.map(node => node.layer.id)).toEqual([10, 11]); + expect(view.imageLayerNodes[0]!.layer).toBe(bg); + expect(view.imageLayerNodes[1]!.layer).toBe(clouds); + }); + + it('an image-layer-less map yields an empty imageLayerNodes list', () => { + const { map } = makeWorldMap(); + const view = map.createView(); + + expect(view.imageLayerNodes).toEqual([]); + }); + + it('getImageLayerNodeById returns the canonical node for an image layer id, or undefined', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const { map } = makeImageMap([bg]); + const view = map.createView(); + + expect(view.getImageLayerNodeById(10)!.layer).toBe(bg); + expect(view.getImageLayerNodeById(10)).toBe(view.imageLayerNodes[0]); + expect(view.getImageLayerNodeById(999)).toBeUndefined(); + }); + + it('getImageLayerNodeByName returns the node for a unique image layer name, or undefined', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const clouds = makeImageLayer({ id: 11, name: 'clouds' }); + const { map } = makeImageMap([bg, clouds]); + const view = map.createView(); + + expect(view.getImageLayerNodeByName('bg')!.layer).toBe(bg); + expect(view.getImageLayerNodeByName('clouds')!.layer).toBe(clouds); + expect(view.getImageLayerNodeByName('missing')).toBeUndefined(); + }); + + it('getImageLayerNodeByName throws for an ambiguous (duplicate) image layer name', () => { + const a = makeImageLayer({ id: 10, name: 'dup' }); + const b = makeImageLayer({ id: 11, name: 'dup' }); + const { map } = makeImageMap([a, b]); + const view = map.createView(); + + expect(() => view.getImageLayerNodeByName('dup')).toThrow(/ambiguous/); + expect(() => view.getImageLayerNodeByName('dup')).toThrow(/by id/); + }); + + it('destroy() destroys image layer nodes and detaches them from an application parent', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const { map } = makeImageMap([bg]); + const view = map.createView(); + const worldRoot = new Container(); + const node = view.getImageLayerNodeById(10)!; + + worldRoot.addChild(node); + view.destroy(); + + expect(node.destroyed).toBe(true); + expect(node.parent).toBeNull(); + expect(worldRoot.children).toHaveLength(0); + expect(view.imageLayerNodes).toHaveLength(0); + }); + + it('pixelSnapMode cascades to image layer nodes', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const { map } = makeImageMap([bg]); + const view = map.createView(); + const node = view.getImageLayerNodeById(10)!; + + expect(node.pixelSnapMode).toBe('none'); + + view.pixelSnapMode = 'geometry'; + + expect(node.pixelSnapMode).toBe('geometry'); + }); + + it('band definitions still reject image-layer ids and names as unknown selectors', () => { + const bg = makeImageLayer({ id: 10, name: 'bg' }); + const { map } = makeImageMap([bg]); + + expect(() => map.createView({ bands: { b: [10] } })).toThrow(/no layer with id 10/); + expect(() => map.createView({ bands: { b: ['bg'] } })).toThrow(/no layer named "bg"/); + }); +}); + // ═══════════════════════════════════════════════════════════════════════ // TileMapBand — transform // ═══════════════════════════════════════════════════════════════════════ diff --git a/site/src/content/api/image-layer-node.json b/site/src/content/api/image-layer-node.json new file mode 100644 index 00000000..3ecde022 --- /dev/null +++ b/site/src/content/api/image-layer-node.json @@ -0,0 +1,3584 @@ +{ + "title": "ImageLayerNode", + "description": "A scene node that renders one Tiled ImageLayer as a single RepeatingSprite wrapped in a Container. The layer's pixel `offset` becomes this node's position; `parallax` is resolved per frame in _collectContent by patching the node position against the camera centre (exactly like import('./TileLayerNode').TileLayerNode), then restoring it. When `repeatX`/`repeatY` is set the wrapped sprite is grown each frame to cover the visible view with a period-aligned origin, so the pattern tiles seamlessly and scrolls with parallax. A repeat axis or a `parallax` factor other than `1` both make this node's rendered position and/or size camera-dependent at collect time, so its static bounds cannot be culled against — such a node opts out of view culling (`cullable = false`). `visible`, `opacity`, and `tintColor` are applied **once at construction**: ImageLayer is immutable, so there is nothing to re-sync per frame. A layer whose `texture` is `null` (image failed to load / not yet available) produces an empty node with no children — it occupies its offset in the scene graph but draws nothing. The node references — but never owns — the ImageLayer and its Loader-owned texture: destroy frees the wrapped sprite but leaves the layer and texture intact. Image-layer nodes are **not** band-selectable; an application parents them into the scene directly wherever the image belongs in draw order. For a `repeatX`/`repeatY` node, bounds and hit-test queries reflect the coverage geometry computed by the last _collectContent call (since that geometry is recomputed per collect, not maintained incrementally), which is also why such a node force-disables `cullable`. A repeating or parallax node also re-sizes its wrapped sprite as the camera crosses period boundaries, which content-dirties an enclosing retained group the same way a streamed layer does — give it the same treatment (its own `RetainedContainer`, or none).", + "symbol": "ImageLayerNode", + "kind": "class", + "subsystem": "tilemap", + "importPath": "@codexo/exojs-tilemap", + "tier": "advanced", + "memberCount": 97, + "counts": { + "constructors": 1, + "methods": 44, + "properties": 39, + "events": 13 + }, + "sections": [ + { + "id": "import", + "title": "Import", + "members": [], + "paragraphs": [ + "A scene node that renders one Tiled ImageLayer as a single RepeatingSprite wrapped in a Container.", + "The layer's pixel `offset` becomes this node's position; `parallax` is resolved per frame in _collectContent by patching the node position against the camera centre (exactly like import('./TileLayerNode').TileLayerNode), then restoring it. When `repeatX`/`repeatY` is set the wrapped sprite is grown each frame to cover the visible view with a period-aligned origin, so the pattern tiles seamlessly and scrolls with parallax. A repeat axis or a `parallax` factor other than `1` both make this node's rendered position and/or size camera-dependent at collect time, so its static bounds cannot be culled against — such a node opts out of view culling (`cullable = false`).", + "`visible`, `opacity`, and `tintColor` are applied **once at construction**: ImageLayer is immutable, so there is nothing to re-sync per frame. A layer whose `texture` is `null` (image failed to load / not yet available) produces an empty node with no children — it occupies its offset in the scene graph but draws nothing.", + "The node references — but never owns — the ImageLayer and its Loader-owned texture: destroy frees the wrapped sprite but leaves the layer and texture intact. Image-layer nodes are **not** band-selectable; an application parents them into the scene directly wherever the image belongs in draw order.", + "For a `repeatX`/`repeatY` node, bounds and hit-test queries reflect the coverage geometry computed by the last _collectContent call (since that geometry is recomputed per collect, not maintained incrementally), which is also why such a node force-disables `cullable`. A repeating or parallax node also re-sizes its wrapped sprite as the camera crosses period boundaries, which content-dirties an enclosing retained group the same way a streamed layer does — give it the same treatment (its own `RetainedContainer`, or none)." + ], + "importLine": "import { ImageLayerNode } from '@codexo/exojs-tilemap'", + "sourceLink": null + }, + { + "id": "constructors", + "title": "Constructors", + "members": [ + { + "name": "new", + "signature": "new(layer: ImageLayer): ImageLayerNode", + "signatureTokens": [ + { + "text": "new", + "kind": "keyword" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "layer", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ImageLayer", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ImageLayerNode", + "kind": "type" + } + ], + "params": [ + { + "name": "layer", + "type": "ImageLayer", + "optional": false + } + ], + "returnType": "ImageLayerNode", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "methods", + "title": "Methods", + "members": [ + { + "name": "_invalidateBoundsCascade", + "signature": "_invalidateBoundsCascade(): void", + "signatureTokens": [ + { + "text": "_invalidateBoundsCascade", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Mark own Bounds dirty AND propagate up to Container ancestors' Bounds." + }, + { + "name": "_invalidateSubtreeTransform", + "signature": "_invalidateSubtreeTransform(): void", + "signatureTokens": [ + { + "text": "_invalidateSubtreeTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Mark own GlobalTransform + Bounds dirty. Descendants detect staleness lazily via the parent-version compare in getGlobalTransform()/getBounds() — no eager subtree walk." + }, + { + "name": "_updateOrigin", + "signature": "_updateOrigin(): void", + "signatureTokens": [ + { + "text": "_updateOrigin", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "Re-derive origin from the fractional anchor and the CURRENT local bounds. Uses local (untransformed) bounds on purpose: the transform multiplies the origin by scale itself, so deriving from world bounds would double-apply scale whenever the anchor is set after scaling. Subclasses whose local bounds change after construction (e.g. a sprite switching to a texture sub-frame) must call this to keep an anchored node anchored." + }, + { + "name": "addChild", + "signature": "addChild(children: RenderNode[]): this", + "signatureTokens": [ + { + "text": "addChild", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "children", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "children", + "type": "RenderNode[]", + "optional": false + } + ], + "returnType": "this", + "description": "Append one or more children to the end of the child list. Each child is detached from its previous parent (if any) before being added." + }, + { + "name": "addChildAt", + "signature": "addChildAt(child: RenderNode, index: number): this", + "signatureTokens": [ + { + "text": "addChildAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "child", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "index", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "child", + "type": "RenderNode", + "optional": false + }, + { + "name": "index", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "Insert child at index in the child list. The child is detached from any previous parent first. Throws if index is out of bounds. Self-as-child is a no-op." + }, + { + "name": "addFilter", + "signature": "addFilter(filter: Filter): this", + "signatureTokens": [ + { + "text": "addFilter", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "filter", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Filter", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "filter", + "type": "Filter", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "blur", + "signature": "blur(): this", + "signatureTokens": [ + { + "text": "blur", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Release keyboard focus from this node if it currently holds it." + }, + { + "name": "clearFilters", + "signature": "clearFilters(): this", + "signatureTokens": [ + { + "text": "clearFilters", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + }, + { + "name": "collidesWith", + "signature": "collidesWith(target: Collidable): CollisionResponse | null", + "signatureTokens": [ + { + "text": "collidesWith", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "target", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Collidable", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CollisionResponse", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [ + { + "name": "target", + "type": "Collidable", + "optional": false + } + ], + "returnType": "CollisionResponse | null", + "description": "Compute a full CollisionResponse between this shape and target. Returns null in two cases: - the shapes do not overlap, **or** - the specific shape-pair combination does not support response generation (e.g. Line against any shape, Ellipse against Ellipse or Polygon). Use intersectsWith for a universal boolean overlap check that works across all supported shape pairs." + }, + { + "name": "contains", + "signature": "contains(x: number, y: number): boolean", + "signatureTokens": [ + { + "text": "contains", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "boolean", + "description": "Hit-test the world-space point (x, y) against this node. For world-axis-aligned nodes (own and inherited rotation a multiple of 90° and no skew) the AABB equals the oriented box, so the cheap getBounds test is exact. For rotated or skewed nodes the point is mapped back into local space with the inverse of the global transform and tested against the untransformed getLocalBounds — i.e. a true oriented-box test. This is the exact inverse of the forward map that getBounds and the renderer use to place the node's corners, so picking matches the rendered quad instead of over-reporting hits in the empty AABB corners of a rotated node." + }, + { + "name": "destroy", + "signature": "destroy(): void", + "signatureTokens": [ + { + "text": "destroy", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [], + "returnType": "void", + "description": "" + }, + { + "name": "focus", + "signature": "focus(): this", + "signatureTokens": [ + { + "text": "focus", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Request keyboard focus for this node through its owning focus service." + }, + { + "name": "getBounds", + "signature": "getBounds(): Rectangle", + "signatureTokens": [ + { + "text": "getBounds", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + } + ], + "params": [], + "returnType": "Rectangle", + "description": "Axis-aligned bounding box of this node in its GLOBAL-transform space. That is world space for ordinary nodes, but GROUP-LOCAL space for nodes inside an engaged RetainedContainer transform group (the group matrix is applied on the GPU, not here) — this is deliberate and matches the rendering convention. For a true world-space extent of such a node, lift this rect by the group's getWorldTransform matrix." + }, + { + "name": "getChildAt", + "signature": "getChildAt(index: number): RenderNode", + "signatureTokens": [ + { + "text": "getChildAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "index", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + } + ], + "params": [ + { + "name": "index", + "type": "number", + "optional": false + } + ], + "returnType": "RenderNode", + "description": "" + }, + { + "name": "getChildIndex", + "signature": "getChildIndex(child: RenderNode): number", + "signatureTokens": [ + { + "text": "getChildIndex", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "child", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [ + { + "name": "child", + "type": "RenderNode", + "optional": false + } + ], + "returnType": "number", + "description": "" + }, + { + "name": "getGlobalTransform", + "signature": "getGlobalTransform(): Matrix", + "signatureTokens": [ + { + "text": "getGlobalTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Matrix", + "kind": "type" + } + ], + "params": [], + "returnType": "Matrix", + "description": "" + }, + { + "name": "getLocalBounds", + "signature": "getLocalBounds(): Rectangle", + "signatureTokens": [ + { + "text": "getLocalBounds", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + } + ], + "params": [], + "returnType": "Rectangle", + "description": "" + }, + { + "name": "getNormals", + "signature": "getNormals(): Vector[]", + "signatureTokens": [ + { + "text": "getNormals", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": "Vector[]", + "description": "Return the outward-facing edge normals used by the SAT solver. The array should be cached and reused across calls." + }, + { + "name": "getTransform", + "signature": "getTransform(): Matrix", + "signatureTokens": [ + { + "text": "getTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Matrix", + "kind": "type" + } + ], + "params": [], + "returnType": "Matrix", + "description": "" + }, + { + "name": "getWorldTransform", + "signature": "getWorldTransform(): Matrix", + "signatureTokens": [ + { + "text": "getWorldTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Matrix", + "kind": "type" + } + ], + "params": [], + "returnType": "Matrix", + "description": "The node's TRUE world-space transform, composed through every transform-group boundary (RetainedContainer) in the ancestor chain. getGlobalTransform deliberately stops at the nearest engaged boundary (descendants resolve group-RELATIVE transforms; the renderer multiplies the group matrix back in on the GPU), so it is the right space for rendering but the wrong one for spatial queries. Use THIS accessor whenever a real world position/orientation is needed — picking, spatial audio, physics, world-space math against nodes outside the group. Without any engaged boundary ancestor it returns the exact getGlobalTransform matrix (same instance, no extra work). With one, it lazily caches groupLocal × groupWorld and revalidates on read via version/stamp compares — including runtime space flips such as RetainedContainer's deep-barrier sub-branch escape (F13/R3)." + }, + { + "name": "intersectsWith", + "signature": "intersectsWith(target: Collidable): boolean", + "signatureTokens": [ + { + "text": "intersectsWith", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "target", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Collidable", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "target", + "type": "Collidable", + "optional": false + } + ], + "returnType": "boolean", + "description": "Test whether this shape overlaps target using a fast boolean algorithm (no penetration depth or normal computed). Prefer this over collidesWith when only the yes/no result is needed." + }, + { + "name": "invalidateCache", + "signature": "invalidateCache(): this", + "signatureTokens": [ + { + "text": "invalidateCache", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + }, + { + "name": "invalidateContent", + "signature": "invalidateContent(): this", + "signatureTokens": [ + { + "text": "invalidateContent", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "Mark this node's visual content dirty without going through a standard setter — e.g. a custom Drawable subclass backed by externally mutable data (the pattern TileChunkNode in @codexo/exojs-tilemap already uses via its own _chunk.revision compare). Call this after mutating such state so the Track-B retained-plan skip does not serve a stale frame for this node." + }, + { + "name": "inView", + "signature": "inView(view: View): boolean", + "signatureTokens": [ + { + "text": "inView", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "view", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "View", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [ + { + "name": "view", + "type": "View", + "optional": false + } + ], + "returnType": "boolean", + "description": "" + }, + { + "name": "move", + "signature": "move(x: number, y: number): this", + "signatureTokens": [ + { + "text": "move", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "project", + "signature": "project(axis: Vector, result: Interval): Interval", + "signatureTokens": [ + { + "text": "project", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "axis", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Vector", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "result", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Interval", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Interval", + "kind": "type" + } + ], + "params": [ + { + "name": "axis", + "type": "Vector", + "optional": false + }, + { + "name": "result", + "type": "Interval", + "optional": false + } + ], + "returnType": "Interval", + "description": "Project this shape onto axis and write the scalar min/max into interval. Used internally by the SAT solver." + }, + { + "name": "removeChild", + "signature": "removeChild(child: RenderNode): this", + "signatureTokens": [ + { + "text": "removeChild", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "child", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "child", + "type": "RenderNode", + "optional": false + } + ], + "returnType": "this", + "description": "Remove child from this container. No-op if not present." + }, + { + "name": "removeChildAt", + "signature": "removeChildAt(index: number): this", + "signatureTokens": [ + { + "text": "removeChildAt", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "index", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "index", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "removeChildren", + "signature": "removeChildren(begin: number, end: number): this", + "signatureTokens": [ + { + "text": "removeChildren", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "begin", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "end", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "begin", + "type": "number", + "optional": false + }, + { + "name": "end", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "Remove children in the half-open range [begin, end). Defaults to the entire child list. Throws if the range is invalid." + }, + { + "name": "removeFilter", + "signature": "removeFilter(filter: Filter): this", + "signatureTokens": [ + { + "text": "removeFilter", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "filter", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Filter", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "filter", + "type": "Filter", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "render", + "signature": "render(backend: RenderBackend): this", + "signatureTokens": [ + { + "text": "render", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "backend", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderBackend", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "backend", + "type": "RenderBackend", + "optional": false + } + ], + "returnType": "this", + "description": "Raw rendering entry point. Direct backend access — bypasses the RenderPlan pipeline machinery. Prefer the high-level RenderingContext.render path via the owning RenderingContext wherever possible." + }, + { + "name": "rotate", + "signature": "rotate(degrees: number): this", + "signatureTokens": [ + { + "text": "rotate", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "degrees", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "degrees", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setAnchor", + "signature": "setAnchor(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setAnchor", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setChildIndex", + "signature": "setChildIndex(child: RenderNode, index: number): this", + "signatureTokens": [ + { + "text": "setChildIndex", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "child", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "index", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "child", + "type": "RenderNode", + "optional": false + }, + { + "name": "index", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setOrigin", + "signature": "setOrigin(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setOrigin", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setPosition", + "signature": "setPosition(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setPosition", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setRotation", + "signature": "setRotation(degrees: number): this", + "signatureTokens": [ + { + "text": "setRotation", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "degrees", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "degrees", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setScale", + "signature": "setScale(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setScale", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "setSkew", + "signature": "setSkew(x: number, y: number): this", + "signatureTokens": [ + { + "text": "setSkew", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "x", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "y", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "x", + "type": "number", + "optional": false + }, + { + "name": "y", + "type": "number", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "swapChildren", + "signature": "swapChildren(firstChild: RenderNode, secondChild: RenderNode): this", + "signatureTokens": [ + { + "text": "swapChildren", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "firstChild", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ", ", + "kind": "punctuation" + }, + { + "text": "secondChild", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [ + { + "name": "firstChild", + "type": "RenderNode", + "optional": false + }, + { + "name": "secondChild", + "type": "RenderNode", + "optional": false + } + ], + "returnType": "this", + "description": "" + }, + { + "name": "updateBounds", + "signature": "updateBounds(): this", + "signatureTokens": [ + { + "text": "updateBounds", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + }, + { + "name": "updateParentTransform", + "signature": "updateParentTransform(): this", + "signatureTokens": [ + { + "text": "updateParentTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + }, + { + "name": "updateTransform", + "signature": "updateTransform(): this", + "signatureTokens": [ + { + "text": "updateTransform", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "this", + "kind": "keyword" + } + ], + "params": [], + "returnType": "this", + "description": "" + }, + { + "name": "setInternalSpriteFactory", + "signature": "setInternalSpriteFactory(factory: () => RenderNodeSpriteLike | null): void", + "signatureTokens": [ + { + "text": "setInternalSpriteFactory", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "factory", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": ") => ", + "kind": "punctuation" + }, + { + "text": "RenderNodeSpriteLike", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "void", + "kind": "keyword" + } + ], + "params": [ + { + "name": "factory", + "type": "() => RenderNodeSpriteLike | null", + "optional": false + } + ], + "returnType": "void", + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "properties", + "title": "Properties", + "members": [ + { + "name": "collisionType", + "signature": "collisionType: CollisionType", + "signatureTokens": [ + { + "text": "collisionType", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "CollisionType", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "cursor", + "signature": "cursor: string | null", + "signatureTokens": [ + { + "text": "cursor", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "draggable", + "signature": "draggable: boolean", + "signatureTokens": [ + { + "text": "draggable", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When true and interactive is also true, this node will be automatically repositioned to follow the pointer during a drag gesture. The framework captures the pointer offset at drag-start so the node doesn't snap to the cursor position. Both interactive and draggable must be set for dragging to work — a draggable but non-interactive node will never receive pointerdown and therefore cannot start a drag." + }, + { + "name": "flags", + "signature": "flags: Flags", + "signatureTokens": [ + { + "text": "flags", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Flags", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "SceneNodeTransformFlags", + "kind": "type" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "focusable", + "signature": "focusable: boolean", + "signatureTokens": [ + { + "text": "focusable", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When true, this node can receive keyboard focus — via focus, Tab traversal, or app.focus.focus(node) — and is delivered key events through onKeyDown / onKeyUp while focused." + }, + { + "name": "name", + "signature": "name: string | null", + "signatureTokens": [ + { + "text": "name", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Optional human-readable identity for this node. Defaults to null. Purely a label the engine never interprets: useful for debugging, find-by-name lookups, prefab references, and as a stable key when merging serialized state back onto an existing tree. Not required to be unique." + }, + { + "name": "tabIndex", + "signature": "tabIndex: number", + "signatureTokens": [ + { + "text": "tabIndex", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Tab-traversal order among focusable nodes in the same focus scope. Lower values are visited first; equal values keep document (tree) order." + }, + { + "name": "anchor", + "signature": "anchor: ObservableVector", + "signatureTokens": [ + { + "text": "anchor", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ObservableVector", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Normalized anchor in 0..1 along each axis that derives origin from the current bounds size. (0, 0) = top-left, (0.5, 0.5) = center, (1, 1) = bottom-right. Updates origin whenever the anchor or the local bounds change." + }, + { + "name": "bottom", + "signature": "bottom: number", + "signatureTokens": [ + { + "text": "bottom", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "cacheAsBitmap", + "signature": "cacheAsBitmap: boolean", + "signatureTokens": [ + { + "text": "cacheAsBitmap", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "children", + "signature": "children: RenderNode[]", + "signatureTokens": [ + { + "text": "children", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "clip", + "signature": "clip: boolean", + "signatureTokens": [ + { + "text": "clip", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When true, descendants are geometrically clipped to clipShape. Unlike mask (which is alpha/visibility masking), clip is a hard geometric boundary: - clipShape === null — clip to this node's world-space bounds (getBounds), using the GPU scissor fast path. - clipShape is a Rectangle — clip to that world-space rectangle via scissor. - clipShape is a Geometry — clip to the geometry's silhouette via the stencil buffer (WebGL2). Only fragments inside the shape survive. Clipping wraps the node's final (filtered/masked) output and acts as a render barrier: draw commands are never reordered or batched across the clip boundary." + }, + { + "name": "clipShape", + "signature": "clipShape: Rectangle | Geometry | null", + "signatureTokens": [ + { + "text": "clipShape", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "Geometry", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Clip region used when clip is true. A Rectangle (or null for the node's bounds) maps to the scissor fast path; a Geometry maps to the stencil path. Has no effect while clip is false." + }, + { + "name": "cullable", + "signature": "cullable: boolean", + "signatureTokens": [ + { + "text": "cullable", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When false, this node is never culled by the viewport check and is always considered in-view. Defaults to true." + }, + { + "name": "cullArea", + "signature": "cullArea: Rectangle | null", + "signatureTokens": [ + { + "text": "cullArea", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Rectangle", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "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." + }, + { + "name": "destroyed", + "signature": "destroyed: boolean", + "signatureTokens": [ + { + "text": "destroyed", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "true once destroy has run on this node. A destroyed node has released its pooled resources (transform/bounds), renders nothing, and must not be reused or re-attached. The render plan skips a destroyed node and — for a RetainedContainer — evicts it from any cached fragment so a child destroyed without removeChild cannot be replayed stale." + }, + { + "name": "filters", + "signature": "filters: readonly Filter[]", + "signatureTokens": [ + { + "text": "filters", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "Filter", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "height", + "signature": "height: number", + "signatureTokens": [ + { + "text": "height", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "interactive", + "signature": "interactive: boolean", + "signatureTokens": [ + { + "text": "interactive", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "isAlignedBox", + "signature": "isAlignedBox: boolean", + "signatureTokens": [ + { + "text": "isAlignedBox", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "layer", + "signature": "layer: ImageLayer", + "signatureTokens": [ + { + "text": "layer", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ImageLayer", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "The runtime layer this node renders." + }, + { + "name": "left", + "signature": "left: number", + "signatureTokens": [ + { + "text": "left", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "mask", + "signature": "mask: MaskSource", + "signatureTokens": [ + { + "text": "mask", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "MaskSource", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "The mask source that controls visibility of this node's render output. See MaskSource for accepted source types and their semantics. Setting to null removes any active mask. Setting a RenderNode that is this is rejected (a node cannot mask itself); other cycles (mask of mask of self) are not detected." + }, + { + "name": "origin", + "signature": "origin: ObservableVector", + "signatureTokens": [ + { + "text": "origin", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ObservableVector", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "parent", + "signature": "parent: Container | null", + "signatureTokens": [ + { + "text": "parent", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Container", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "null", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "pixelSnapMode", + "signature": "pixelSnapMode: PixelSnapMode", + "signatureTokens": [ + { + "text": "pixelSnapMode", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "PixelSnapMode", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "Render-only pixel-snap mode forwarded to the wrapped RepeatingSprite (which snaps its rendered origin, and — in 'geometry' mode for axis-aligned transforms — its repeat-segment boundaries, to the render target's device-pixel grid). Purely visual: the layer offset, parallax, and repeat coverage are never changed. For a null-texture layer there is no drawable to forward to, so the mode is simply stored. Setting the current value is a no-op; an invalid value throws and leaves the prior mode unchanged." + }, + { + "name": "position", + "signature": "position: ObservableVector", + "signatureTokens": [ + { + "text": "position", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ObservableVector", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "preserveDrawOrder", + "signature": "preserveDrawOrder: boolean", + "signatureTokens": [ + { + "text": "preserveDrawOrder", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "When true, material-aware overlap reordering is disabled for this node's draw-order scope. Draw commands are submitted in exact document order (after scope-local z-sorting), preserving the painter's guarantee irrespective of material compatibility or AABB safety analysis. Adjacency coalescing of consecutive same-material draws still applies; it does not change visual output order." + }, + { + "name": "right", + "signature": "right: number", + "signatureTokens": [ + { + "text": "right", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "rotation", + "signature": "rotation: number", + "signatureTokens": [ + { + "text": "rotation", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Rotation angle in degrees. Wraps via trimRotation on assignment." + }, + { + "name": "scale", + "signature": "scale: ObservableVector", + "signatureTokens": [ + { + "text": "scale", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ObservableVector", + "kind": "type" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "skewX", + "signature": "skewX: number", + "signatureTokens": [ + { + "text": "skewX", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Horizontal skew angle in degrees. Shears the node along the X axis (positive values lean the top edge right). Combines correctly with rotation and scale." + }, + { + "name": "skewY", + "signature": "skewY: number", + "signatureTokens": [ + { + "text": "skewY", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "Vertical skew angle in degrees. Shears the node along the Y axis (positive values lean the left edge downward). Combines correctly with rotation and scale." + }, + { + "name": "top", + "signature": "top: number", + "signatureTokens": [ + { + "text": "top", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "visible", + "signature": "visible: boolean", + "signatureTokens": [ + { + "text": "visible", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "boolean", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "width", + "signature": "width: number", + "signatureTokens": [ + { + "text": "width", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "x", + "signature": "x: number", + "signatureTokens": [ + { + "text": "x", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "y", + "signature": "y: number", + "signatureTokens": [ + { + "text": "y", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "zIndex", + "signature": "zIndex: number", + "signatureTokens": [ + { + "text": "zIndex", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "events", + "title": "Events", + "members": [ + { + "name": "onBlur", + "signature": "onBlur: Signal<[RenderNode]>", + "signatureTokens": [ + { + "text": "onBlur", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired when this node loses keyboard focus." + }, + { + "name": "onDrag", + "signature": "onDrag: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onDrag", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired on every pointer-move while this node is being dragged. Does not bubble." + }, + { + "name": "onDragEnd", + "signature": "onDragEnd: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onDragEnd", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired when the drag gesture ends (pointer-up or cancel). Does not bubble." + }, + { + "name": "onDragStart", + "signature": "onDragStart: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onDragStart", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired once when a drag gesture begins on this node. Does not bubble." + }, + { + "name": "onFocus", + "signature": "onFocus: Signal<[RenderNode]>", + "signatureTokens": [ + { + "text": "onFocus", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "RenderNode", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired when this node gains keyboard focus." + }, + { + "name": "onKeyDown", + "signature": "onKeyDown: Signal<[KeyEvent]>", + "signatureTokens": [ + { + "text": "onKeyDown", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "KeyEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired for each key pressed while this node holds focus." + }, + { + "name": "onKeyUp", + "signature": "onKeyUp: Signal<[KeyEvent]>", + "signatureTokens": [ + { + "text": "onKeyUp", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "KeyEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "Fired for each key released while this node holds focus." + }, + { + "name": "onPointerDown", + "signature": "onPointerDown: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerDown", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "onPointerMove", + "signature": "onPointerMove: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerMove", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "onPointerOut", + "signature": "onPointerOut: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerOut", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "onPointerOver", + "signature": "onPointerOver: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerOver", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "onPointerTap", + "signature": "onPointerTap: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerTap", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + }, + { + "name": "onPointerUp", + "signature": "onPointerUp: Signal<[InteractionEvent]>", + "signatureTokens": [ + { + "text": "onPointerUp", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "Signal", + "kind": "type" + }, + { + "text": "<", + "kind": "punctuation" + }, + { + "text": "[", + "kind": "punctuation" + }, + { + "text": "InteractionEvent", + "kind": "type" + }, + { + "text": "]", + "kind": "punctuation" + }, + { + "text": ">", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "" + } + ], + "paragraphs": [], + "importLine": null, + "sourceLink": null + }, + { + "id": "source", + "title": "Source", + "members": [], + "paragraphs": [], + "importLine": null, + "sourceLink": { + "label": "packages/exojs-tilemap/src/ImageLayerNode.ts", + "href": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/ImageLayerNode.ts" + } + } + ], + "sourcePath": "packages/exojs-tilemap/src/ImageLayerNode.ts", + "sourceUrl": "https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-tilemap/src/ImageLayerNode.ts" +} diff --git a/site/src/content/api/tile-map-node.json b/site/src/content/api/tile-map-node.json index e2b8d7e5..530ae5b2 100644 --- a/site/src/content/api/tile-map-node.json +++ b/site/src/content/api/tile-map-node.json @@ -1,6 +1,6 @@ { "title": "TileMapNode", - "description": "A convenience scene node that renders a whole TileMap as a Container of one TileLayerNode per tile layer, in map layer order (back-to-front by document order). `TileMapNode` owns **only** its layer nodes — never application actors. Use it for the simple, non-interleaved case (no actors between layers); for actor interleaving, place individual `TileLayerNode`s into your own scene graph instead. The node references — but never owns — the TileMap: destroying the node frees its layer/chunk nodes and their cached GPU geometry, while the `TileMap` data and Loader-owned tileset textures survive (free them via `TileMap.destroy()` / `Loader.destroy()` respectively). Layers added to or removed from the map after construction are reflected only after TileMapNode.refreshLayers.", + "description": "A convenience scene node that renders a whole TileMap as a Container of one TileLayerNode per tile layer, in map layer order (back-to-front by document order). `TileMapNode` owns **only** its layer nodes — never application actors. Use it for the simple, non-interleaved case (no actors between layers); for actor interleaving, place individual `TileLayerNode`s into your own scene graph instead. The node references — but never owns — the TileMap: destroying the node frees its layer/chunk nodes and their cached GPU geometry, while the `TileMap` data and Loader-owned tileset textures survive (free them via `TileMap.destroy()` / `Loader.destroy()` respectively). Layers added to or removed from the map after construction are reflected only after TileMapNode.refreshLayers. `TileMapNode` renders only tile layers; for a map with image layers, compose `TileMapView` and its import('./ImageLayerNode').ImageLayerNodes instead.", "symbol": "TileMapNode", "kind": "class", "subsystem": "tilemap", @@ -22,7 +22,8 @@ "A convenience scene node that renders a whole TileMap as a Container of one TileLayerNode per tile layer, in map layer order (back-to-front by document order).", "`TileMapNode` owns **only** its layer nodes — never application actors. Use it for the simple, non-interleaved case (no actors between layers); for actor interleaving, place individual `TileLayerNode`s into your own scene graph instead.", "The node references — but never owns — the TileMap: destroying the node frees its layer/chunk nodes and their cached GPU geometry, while the `TileMap` data and Loader-owned tileset textures survive (free them via `TileMap.destroy()` / `Loader.destroy()` respectively).", - "Layers added to or removed from the map after construction are reflected only after TileMapNode.refreshLayers." + "Layers added to or removed from the map after construction are reflected only after TileMapNode.refreshLayers.", + "`TileMapNode` renders only tile layers; for a map with image layers, compose `TileMapView` and its import('./ImageLayerNode').ImageLayerNodes instead." ], "importLine": "import { TileMapNode } from '@codexo/exojs-tilemap'", "sourceLink": null diff --git a/site/src/content/api/tile-map-view.json b/site/src/content/api/tile-map-view.json index 2ca93621..89bd6659 100644 --- a/site/src/content/api/tile-map-view.json +++ b/site/src/content/api/tile-map-view.json @@ -1,16 +1,16 @@ { "title": "TileMapView", - "description": "Groups a TileMap's layers into independently placeable scene nodes so application actors can be interleaved **between** tile layers. A view produces exactly one canonical TileLayerNode per map layer (stable identity, map document order) and, optionally, named TileMapBands grouping subsets of those nodes. The application parents the bands / layer nodes wherever it wants — typically as siblings of its own actor containers: ```ts const view = map.createView({ bands: { ground: ['background', 'ground'], roof: ['roofs'] } }); worldRoot.addChild(view.band('ground'), actors, view.band('roof')); // or without bands: worldRoot.addChild(view.getLayerNodeById(groundId)!, actors, view.getLayerNodeById(roofId)!); ``` **Actors are application-owned siblings.** A `TileMapView` never adopts or destroys actors. The view is a helper, not a scene node — it does not own the world container, the TileMap, the TileLayers, or tileset textures. **Ownership:** the view owns its generated layer nodes and bands. A layer assigned to a band is owned by that band; an unbanded layer is owned by the view directly. TileMapView.destroy destroys every band and layer node (detaching them from their application parents) but leaves actors, the map, its layers, and Loader-owned textures untouched. There is no map-replacement mutation API: to swap maps, destroy the old view, construct a new one, and re-parent its bands — the actor tree is never involved.", + "description": "Groups a TileMap's layers into independently placeable scene nodes so application actors can be interleaved **between** tile layers. A view produces exactly one canonical TileLayerNode per map layer (stable identity, map document order) and, optionally, named TileMapBands grouping subsets of those nodes. The application parents the bands / layer nodes wherever it wants — typically as siblings of its own actor containers: ```ts const view = map.createView({ bands: { ground: ['background', 'ground'], roof: ['roofs'] } }); worldRoot.addChild(view.band('ground'), actors, view.band('roof')); // or without bands: worldRoot.addChild(view.getLayerNodeById(groundId)!, actors, view.getLayerNodeById(roofId)!); ``` **Actors are application-owned siblings.** A `TileMapView` never adopts or destroys actors. The view is a helper, not a scene node — it does not own the world container, the TileMap, the TileLayers, or tileset textures. **Ownership:** the view owns its generated layer nodes and bands. A layer assigned to a band is owned by that band; an unbanded layer is owned by the view directly. TileMapView.destroy destroys every band and layer node (detaching them from their application parents) but leaves actors, the map, its layers, and Loader-owned textures untouched. There is no map-replacement mutation API: to swap maps, destroy the old view, construct a new one, and re-parent its bands — the actor tree is never involved. **Image layers.** A view also produces exactly one canonical ImageLayerNode per TileMap.imageLayers entry (stable identity, map document order), reachable through imageLayerNodes, getImageLayerNodeById, and getImageLayerNodeByName. The view owns these nodes the same way it owns unbanded tile-layer nodes — the application parents each one wherever the image belongs in draw order, and TileMapView.destroy destroys them and detaches them from their application parents. Image layers are **not** selectable in TileMapViewOptions.bands: a band definition only ever reorders its own members to map document order, and image layers do not share a combined document position with tile layers — mixing them into one band's selector list could not resolve a single, unambiguous render order without silently guessing at an interleaving. Interleave an image layer with tile layers or bands by parenting getImageLayerNodeById / getImageLayerNodeByName directly, the same way actors are interleaved.", "symbol": "TileMapView", "kind": "class", "subsystem": "tilemap", "importPath": "@codexo/exojs-tilemap", "tier": "advanced", - "memberCount": 12, + "memberCount": 15, "counts": { "constructors": 1, - "methods": 6, - "properties": 5, + "methods": 8, + "properties": 6, "events": 0 }, "sections": [ @@ -23,7 +23,8 @@ "A view produces exactly one canonical TileLayerNode per map layer (stable identity, map document order) and, optionally, named TileMapBands grouping subsets of those nodes. The application parents the bands / layer nodes wherever it wants — typically as siblings of its own actor containers:", "```ts const view = map.createView({ bands: { ground: ['background', 'ground'], roof: ['roofs'] } }); worldRoot.addChild(view.band('ground'), actors, view.band('roof')); // or without bands: worldRoot.addChild(view.getLayerNodeById(groundId)!, actors, view.getLayerNodeById(roofId)!); ```", "**Actors are application-owned siblings.** A `TileMapView` never adopts or destroys actors. The view is a helper, not a scene node — it does not own the world container, the TileMap, the TileLayers, or tileset textures.", - "**Ownership:** the view owns its generated layer nodes and bands. A layer assigned to a band is owned by that band; an unbanded layer is owned by the view directly. TileMapView.destroy destroys every band and layer node (detaching them from their application parents) but leaves actors, the map, its layers, and Loader-owned textures untouched. There is no map-replacement mutation API: to swap maps, destroy the old view, construct a new one, and re-parent its bands — the actor tree is never involved." + "**Ownership:** the view owns its generated layer nodes and bands. A layer assigned to a band is owned by that band; an unbanded layer is owned by the view directly. TileMapView.destroy destroys every band and layer node (detaching them from their application parents) but leaves actors, the map, its layers, and Loader-owned textures untouched. There is no map-replacement mutation API: to swap maps, destroy the old view, construct a new one, and re-parent its bands — the actor tree is never involved.", + "**Image layers.** A view also produces exactly one canonical ImageLayerNode per TileMap.imageLayers entry (stable identity, map document order), reachable through imageLayerNodes, getImageLayerNodeById, and getImageLayerNodeByName. The view owns these nodes the same way it owns unbanded tile-layer nodes — the application parents each one wherever the image belongs in draw order, and TileMapView.destroy destroys them and detaches them from their application parents. Image layers are **not** selectable in TileMapViewOptions.bands: a band definition only ever reorders its own members to map document order, and image layers do not share a combined document position with tile layers — mixing them into one band's selector list could not resolve a single, unambiguous render order without silently guessing at an interleaving. Interleave an image layer with tile layers or bands by parenting getImageLayerNodeById / getImageLayerNodeByName directly, the same way actors are interleaved." ], "importLine": "import { TileMapView } from '@codexo/exojs-tilemap'", "sourceLink": null @@ -187,7 +188,117 @@ ], "params": [], "returnType": "void", - "description": "Destroy the view: every band and generated layer node is destroyed (and detached from its application parent), freeing cached chunk geometry. Idempotent. Application actors, sibling content, the TileMap, its TileLayers, and Loader-owned tileset textures all survive." + "description": "Destroy the view: every band, generated tile-layer node, and generated ImageLayerNode is destroyed (and detached from its application parent), freeing cached chunk geometry. Idempotent. Application actors, sibling content, the TileMap, its TileLayers and image layers, and Loader-owned textures all survive." + }, + { + "name": "getImageLayerNodeById", + "signature": "getImageLayerNodeById(id: number): ImageLayerNode | undefined", + "signatureTokens": [ + { + "text": "getImageLayerNodeById", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "id", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "number", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ImageLayerNode", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "undefined", + "kind": "keyword" + } + ], + "params": [ + { + "name": "id", + "type": "number", + "optional": false + } + ], + "returnType": "ImageLayerNode | undefined", + "description": "The canonical image layer node for the image layer with the given **id**, or undefined. Ids are authoritative and unique — this is the unambiguous lookup. The returned node may be reparented into the caller's own containers; the view still tracks it for destruction." + }, + { + "name": "getImageLayerNodeByName", + "signature": "getImageLayerNodeByName(name: string): ImageLayerNode | undefined", + "signatureTokens": [ + { + "text": "getImageLayerNodeByName", + "kind": "name" + }, + { + "text": "(", + "kind": "punctuation" + }, + { + "text": "name", + "kind": "param" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "string", + "kind": "keyword" + }, + { + "text": ")", + "kind": "punctuation" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "ImageLayerNode", + "kind": "type" + }, + { + "text": " | ", + "kind": "punctuation" + }, + { + "text": "undefined", + "kind": "keyword" + } + ], + "params": [ + { + "name": "name", + "type": "string", + "optional": false + } + ], + "returnType": "ImageLayerNode | undefined", + "description": "The canonical image layer node for the image layer with the given **name**, or undefined if no image layer has that name. Prefer getImageLayerNodeById when you have the id." }, { "name": "getLayerNodeById", @@ -377,7 +488,7 @@ ], "params": [], "returnType": "this", - "description": "Rebuild the view after **structural** map changes (layers added to or removed from the map). Ordinary tile edits and chunk creation/removal do NOT need this — those are handled by chunk revisions and TileLayerNode.refresh respectively. - Removed layers: their generated layer node is detached and destroyed. - Unchanged layers: keep their existing layer node (stable identity). - Added layers: a new layer node is created and assigned to the first band whose definition selects it (by id, or by a currently-unambiguous name), otherwise owned directly by the view. - Every band's children are re-ordered to map document order. Application actors are never touched, and bands keep their placement in the application scene graph." + "description": "Rebuild the view after **structural** map changes (layers added to or removed from the map). Ordinary tile edits and chunk creation/removal do NOT need this — those are handled by chunk revisions and TileLayerNode.refresh respectively. - Removed layers: their generated layer node is detached and destroyed. - Unchanged layers: keep their existing layer node (stable identity). - Added layers: a new layer node is created and assigned to the first band whose definition selects it (by id, or by a currently-unambiguous name), otherwise owned directly by the view. - Every band's children are re-ordered to map document order. Application actors are never touched, and bands keep their placement in the application scene graph. Image-layer nodes are constructed once, at view construction, and are **not** reconciled by this method: a subsequent import('./TileMap').TileMap.removeImageLayer call on the map is not reflected in imageLayerNodes (full image-layer reconcile is deferred)." } ], "paragraphs": [], @@ -442,6 +553,39 @@ "returnType": null, "description": "Whether this view has been destroyed." }, + { + "name": "imageLayerNodes", + "signature": "imageLayerNodes: readonly ImageLayerNode[]", + "signatureTokens": [ + { + "text": "imageLayerNodes", + "kind": "name" + }, + { + "text": ": ", + "kind": "punctuation" + }, + { + "text": "readonly", + "kind": "keyword" + }, + { + "text": " ", + "kind": "punctuation" + }, + { + "text": "ImageLayerNode", + "kind": "type" + }, + { + "text": "[]", + "kind": "punctuation" + } + ], + "params": [], + "returnType": null, + "description": "All canonical image layer nodes, one per map image layer, in map document order." + }, { "name": "layers", "signature": "layers: readonly TileLayerNode[]", diff --git a/site/src/content/guide/rendering/infinite-maps.mdx b/site/src/content/guide/rendering/infinite-maps.mdx index 61e88860..3a26fa15 100644 --- a/site/src/content/guide/rendering/infinite-maps.mdx +++ b/site/src/content/guide/rendering/infinite-maps.mdx @@ -271,7 +271,7 @@ From there it is the same `ChunkStreamer` loop as the procedural providers — t ## Performance notes - **Chunk loads are budgeted.** A camera moving faster than `maxChunkLoadsPerFrame` can keep up with will show brief pop-in at the frontier before the band fills. That is the budget doing its job — spreading load cost across frames instead of spiking one. For fast movers, raise `loadRadius` so chunks are requested further ahead of the visible edge, and raise the per-frame budget to fill them faster. -- **Mind the retained tier.** A streamed layer mutates its structure every time a chunk is adopted or evicted. If it lives inside a `RetainedContainer` shared with unrelated static content, every chunk boundary the camera crosses drops and re-captures the _whole_ group — you pay the retention bookkeeping for content that never changed. Give a streamed layer its own [`RetainedContainer`](/ExoJS/en/guide/rendering/retained-containers/), or none at all. See the [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) chapter for the full rule. +- **Mind the retained tier.** A streamed layer mutates its structure every time a chunk is adopted or evicted. If it lives inside a `RetainedContainer` shared with unrelated static content, every chunk boundary the camera crosses drops and re-captures the _whole_ group — you pay the retention bookkeeping for content that never changed. Give a streamed layer its own [`RetainedContainer`](/ExoJS/en/guide/rendering/retained-containers/), or none at all. See the [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) chapter for the full rule. A repeating or parallax `ImageLayerNode` re-sizes its wrapped sprite as the camera crosses period boundaries too, content-dirtying an enclosing retained group the same way — give it the same treatment. ## Where to go next