From 23b311a6b0a6953ca331e0abc6cc88ea7ca99984 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 18:22:44 +0200 Subject: [PATCH 1/8] test(tiled): chunk-source round trip through a real ChunkStreamer incl. negative coordinates Extends the Slice-3 infinite-map suite with an end-to-end test that drives TiledMap.toTileMap() -> getChunkSource() -> a real ChunkStreamer, asserting resolved tiles at both negative and positive tile coordinates re-sliced from 16x16 on-disk Tiled chunks onto the runtime 32x32 grid. Also strengthens the existing bounded/unbounded assertions: an infinite layer's width/height are asserted undefined, and a finite layer's bounded/width/height are asserted explicitly alongside its already-covered getChunkSource() === undefined. --- packages/exojs-tiled/test/toTileMap.test.ts | 53 +++++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/exojs-tiled/test/toTileMap.test.ts b/packages/exojs-tiled/test/toTileMap.test.ts index 5f355753..c48bacf9 100644 --- a/packages/exojs-tiled/test/toTileMap.test.ts +++ b/packages/exojs-tiled/test/toTileMap.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { type AssetLoaderContext,Texture } from '@codexo/exojs'; -import { type TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; +import { type AssetLoaderContext,Texture, View } from '@codexo/exojs'; +import { ChunkStreamer, type TileLayer, TileMap, TileSet } from '@codexo/exojs-tilemap'; import { describe, expect, it,vi } from 'vitest'; import { loadTiledMap } from '../src/loadTiledMap'; @@ -364,15 +364,17 @@ describe('TiledMap.toTileMap() — infinite maps', () => { expect(map.getChunkSource(1)).toBeUndefined(); }); - it('does not throw for infinite:true, and converts the layer as unbounded', async () => { + it('does not throw for infinite:true, and converts the layer as unbounded with width/height undefined', async () => { const { context } = makeInfiniteMapContext([{ x: 0, y: 0, width: 16, height: 16, data: new Array(256).fill(1) }]); const map = await loadTiledMap('inf.tmj', context); const runtime = map.toTileMap(); const layer = runtime.getTileLayer('Ground')!; expect(layer.bounded).toBe(false); + expect(layer.width).toBeUndefined(); + expect(layer.height).toBeUndefined(); }); - it('getChunkSource returns undefined for a non-chunked (finite) layer id', async () => { + it('getChunkSource returns undefined for a non-chunked (finite) layer id, whose layer stays bounded', async () => { const { context } = makeContext({ 'finite.tmj': { type: 'map', version: '1.10', orientation: 'orthogonal', width: 2, height: 1, @@ -385,7 +387,11 @@ describe('TiledMap.toTileMap() — infinite maps', () => { }, }); const map = await loadTiledMap('finite.tmj', context); - map.toTileMap(); + const runtime = map.toTileMap(); + const layer = runtime.getTileLayer('Ground')!; + expect(layer.bounded).toBe(true); + expect(layer.width).toBe(2); + expect(layer.height).toBe(1); expect(map.getChunkSource(1)).toBeUndefined(); }); @@ -462,6 +468,43 @@ describe('TiledMap.toTileMap() — infinite maps', () => { const { unpackTile } = await import('@codexo/exojs-tilemap'); expect(unpackTile(payload!.tiles[0])).toMatchObject({ localTileId: 0, transform: { flipX: true, flipY: false, diagonal: false } }); }); + + it('round-trips through a real ChunkStreamer, resolving both negative and positive tile coordinates', async () => { + // On-disk chunk A at (x=-16,y=-16): gid 1 -> localTileId 0, fills tile + // rect [-16,0) x [-16,0) (runtime chunk (-1,-1)'s tile rect is the wider + // [-32,0) x [-32,0), so this only covers part of it). + // On-disk chunk B at (x=0,y=0): gid 2 -> localTileId 1, fills tile rect + // [0,16) x [0,16) (part of runtime chunk (0,0)'s [0,32) x [0,32)). + const { context } = makeInfiniteMapContext([ + { x: -16, y: -16, width: 16, height: 16, data: new Array(256).fill(1) }, + { x: 0, y: 0, width: 16, height: 16, data: new Array(256).fill(2) }, + ]); + const map = await loadTiledMap('inf.tmj', context); + const runtime = map.toTileMap(); + const layer = runtime.getTileLayer('Ground')!; + const source = map.getChunkSource(1)!; + + // tileWidth=16, so a view centered on the origin with a 4x4px extent has + // bounds [-2,2] x [-2,2] -> topLeftTile=(-1,-1) (floor(-2/16)), bottomRightTile=(0,0) + // (floor(2/16)) -> topLeftChunk=(-1,-1), bottomRightChunk=(0,0) (chunkWidth/Height + // default to 32). The streamer's first (unbudgeted) update() loads that + // core range plus its default loadRadius padding, which always covers at + // least those two chunks. + const view = new View(0, 0, 4, 4); + const streamer = new ChunkStreamer(layer, source, view); + + streamer.update(); + + // Negative tile coordinates, inside on-disk chunk A (gid 1 -> localTileId 0). + expect(layer.getTileAt(-16, -16)).toMatchObject({ localTileId: 0 }); + expect(layer.getTileAt(-1, -1)).toMatchObject({ localTileId: 0 }); + // Positive tile coordinates, inside on-disk chunk B (gid 2 -> localTileId 1). + expect(layer.getTileAt(0, 0)).toMatchObject({ localTileId: 1 }); + expect(layer.getTileAt(15, 15)).toMatchObject({ localTileId: 1 }); + // Inside runtime chunk (-1,-1) but outside on-disk chunk A's tile rect + // (which only covers ty/tx in [-16,-1]) -> untouched, empty cell. + expect(layer.getTileAt(-32, -32)).toBeNull(); + }); }); describe('TiledMap.toTileMap() — object layers', () => { From 0209a3ff4d552ff820a90cb891da4f7b18abc2be Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 18:27:43 +0200 Subject: [PATCH 2/8] test(tilemap): pin ChunkStreamer destroy/in-flight-resolve race behavior --- .../exojs-tilemap/test/ChunkStreamer.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/exojs-tilemap/test/ChunkStreamer.test.ts b/packages/exojs-tilemap/test/ChunkStreamer.test.ts index 6d090abf..f9a3947a 100644 --- a/packages/exojs-tilemap/test/ChunkStreamer.test.ts +++ b/packages/exojs-tilemap/test/ChunkStreamer.test.ts @@ -410,3 +410,58 @@ describe('ChunkStreamer.destroy()', () => { expect([...layer.loadedChunks()]).toHaveLength(0); }); }); + +describe('ChunkStreamer.destroy() — async in-flight-resolve race', () => { + it('a promise resolving after destroy() does not resurrect the chunk', async () => { + const tileset = makeTileset(); + const layer = makeUnboundedLayer(tileset); // chunkSize=4, tileSize=16 + // Same single-chunk-in-range idiom as the "rejected coordinate is + // retried" test above: a tiny view entirely inside chunk (0,0)'s pixel + // extent, with loadRadius/unloadRadius both 0, so exactly one request + // is in flight and there is no ambiguity about which coordinate it is. + const view = new View(32, 32, 2, 2); + let resolvePayload!: (payload: ChunkPayload | null) => void; + const pending = new Promise(resolve => { resolvePayload = resolve; }); + const source: ChunkSource = { getChunk: () => pending }; + const streamer = new ChunkStreamer(layer, source, view, { loadRadius: 0, unloadRadius: 0 }); + + streamer.update(); // issues the (0,0) request; still pending + streamer.destroy(); // destroy while the request is in flight + + resolvePayload({ width: 4, height: 4, tiles: new Uint32Array(16) }); + await expect(pending).resolves.toBeDefined(); + await Promise.resolve(); // flush the .then() microtask + + expect(layer.getChunk(0, 0)).toBeUndefined(); // not resurrected after destroy + expect(streamer.residentCount).toBe(0); + }); + + it('destroy() after a resolve already landed still evicts the chunk and leaves a consistent empty state', async () => { + const tileset = makeTileset(); + const layer = makeUnboundedLayer(tileset); + const view = new View(32, 32, 2, 2); + let resolvePayload!: (payload: ChunkPayload | null) => void; + const pending = new Promise(resolve => { resolvePayload = resolve; }); + const source: ChunkSource = { getChunk: () => pending }; + const streamer = new ChunkStreamer(layer, source, view, { loadRadius: 0, unloadRadius: 0 }); + + streamer.update(); // issues the (0,0) request + resolvePayload({ width: 4, height: 4, tiles: new Uint32Array(16) }); + await pending; + await Promise.resolve(); // flush the .then() microtask — chunk is now adopted + expect(layer.getChunk(0, 0)).toBeDefined(); + expect(streamer.residentCount).toBe(1); + + streamer.destroy(); + + expect(layer.getChunk(0, 0)).toBeUndefined(); + expect(streamer.residentCount).toBe(0); + + // A follow-up update() must stay a no-op — no leftover in-flight/resident + // bookkeeping from the pre-destroy adoption resurrects anything. + view.setCenter(1000, 1000); + expect(() => { streamer.update(); }).not.toThrow(); + expect(streamer.residentCount).toBe(0); + expect([...layer.loadedChunks()]).toHaveLength(0); + }); +}); From dd6a993c1b82b71fdd23690302395080829b69b5 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 18:33:31 +0200 Subject: [PATCH 3/8] fix(tiled): reject misaligned chunks in infinite-map layers instead of silently corrupting indices --- packages/exojs-tiled/src/TiledMap.ts | 11 +++++++++++ packages/exojs-tiled/test/toTileMap.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/exojs-tiled/src/TiledMap.ts b/packages/exojs-tiled/src/TiledMap.ts index 66244fd2..a4d312ed 100644 --- a/packages/exojs-tiled/src/TiledMap.ts +++ b/packages/exojs-tiled/src/TiledMap.ts @@ -365,6 +365,10 @@ function populateTileLayer( * @throws {TiledFormatError} if the layer's on-disk chunks don't all share * the same width/height (Tiled always emits a uniform size in * practice; this is a defensive guard against malformed input). + * @throws {TiledFormatError} if an on-disk chunk's `x`/`y` isn't aligned to + * the on-disk chunk grid (Tiled always emits aligned chunks; a + * misaligned chunk would otherwise silently corrupt the re-sliced + * tile indices instead of failing loudly). */ function buildTiledChunkSource( layer: TiledTileLayer, @@ -387,6 +391,13 @@ function buildTiledChunkSource( `non-uniform infinite-map chunk size is not supported (expected ${onDiskWidth}x${onDiskHeight}, got ${chunk.width}x${chunk.height})`, ); } + if (chunk.x % onDiskWidth !== 0 || chunk.y % onDiskHeight !== 0) { + throw new TiledFormatError( + source, + `layers/${layer.name}/chunks`, + `misaligned infinite-map chunk in layer ${layer.id} at (${chunk.x}, ${chunk.y}) is not a multiple of the on-disk chunk size (${onDiskWidth}x${onDiskHeight})`, + ); + } index.set(`${chunk.x},${chunk.y}`, chunk); } diff --git a/packages/exojs-tiled/test/toTileMap.test.ts b/packages/exojs-tiled/test/toTileMap.test.ts index c48bacf9..814c3919 100644 --- a/packages/exojs-tiled/test/toTileMap.test.ts +++ b/packages/exojs-tiled/test/toTileMap.test.ts @@ -414,6 +414,18 @@ describe('TiledMap.toTileMap() — infinite maps', () => { expect(() => map.toTileMap()).toThrow(/non-uniform/); }); + it('throws TiledFormatError for a chunk whose x is not aligned to the on-disk chunk width', async () => { + const { context } = makeInfiniteMapContext([ + { x: 0, y: 0, width: 16, height: 16, data: new Array(256).fill(1) }, + { x: 8, y: 0, width: 16, height: 16, data: new Array(256).fill(1) }, + ]); + const map = await loadTiledMap('inf.tmj', context); + expect(() => map.toTileMap()).toThrow(TiledFormatError); + expect(() => map.toTileMap()).toThrow(/misaligned/); + expect(() => map.toTileMap()).toThrow(/layer 1/); + expect(() => map.toTileMap()).toThrow(/\(8, ?0\)/); + }); + it('getChunk returns null when no on-disk chunk overlaps the query', async () => { const { context } = makeInfiniteMapContext([{ x: 0, y: 0, width: 16, height: 16, data: new Array(256).fill(1) }]); const map = await loadTiledMap('inf.tmj', context); From 1f10b177faeca31fc9d2fb1cc0866a1876967db9 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 21:00:49 +0200 Subject: [PATCH 4/8] docs(examples): infinite Tiled map streaming demo (.tmj through getChunkSource) Add drift-fields.tmj, a hand-authored infinite Tiled map (two chunked tile layers, 16x16 on-disk chunks including negative coordinates) and the tiled-infinite-map example: TiledMap.toTileMap() + getChunkSource() feed one ChunkStreamer per layer around a free-flying WASD camera, with no procedural sampler involved. Wire the example into the catalog and into the "Streaming a Tiled infinite map" guide section. --- examples/assets/json/maps/drift-fields.tmj | 3751 +++++++++++++++++ examples/examples.json | 17 + examples/tilemap/tiled-infinite-map.js | 117 + examples/tilemap/tiled-infinite-map.ts | 136 + .../content/guide/rendering/infinite-maps.mdx | 3 + site/src/lib/guide-structure.ts | 2 +- 6 files changed, 4025 insertions(+), 1 deletion(-) create mode 100644 examples/assets/json/maps/drift-fields.tmj create mode 100644 examples/tilemap/tiled-infinite-map.js create mode 100644 examples/tilemap/tiled-infinite-map.ts diff --git a/examples/assets/json/maps/drift-fields.tmj b/examples/assets/json/maps/drift-fields.tmj new file mode 100644 index 00000000..e027910e --- /dev/null +++ b/examples/assets/json/maps/drift-fields.tmj @@ -0,0 +1,3751 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.10.2", + "orientation": "orthogonal", + "renderorder": "right-down", + "width": 0, + "height": 0, + "tilewidth": 64, + "tileheight": 64, + "infinite": true, + "layers": [ + { + "id": 1, + "name": "Ground", + "type": "tilelayer", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "chunks": [ + { + "x": -16, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29 + ] + }, + { + "x": 0, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24 + ] + }, + { + "x": 16, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19 + ] + }, + { + "x": -16, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 87, + 87, + 87, + 87, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 87, + 87, + 87, + 87, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 87, + 87, + 87, + 87, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 87, + 87, + 87, + 87, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24 + ] + }, + { + "x": 0, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 29, + 29, + 29, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24 + ] + }, + { + "x": 16, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19 + ] + }, + { + "x": -16, + "y": 16, + "width": 16, + "height": 16, + "data": [ + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19 + ] + }, + { + "x": 0, + "y": 16, + "width": 16, + "height": 16, + "data": [ + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 24, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19, + 19 + ] + } + ] + }, + { + "id": 2, + "name": "Props", + "type": "tilelayer", + "visible": true, + "opacity": 1, + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "chunks": [ + { + "x": -16, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "x": 0, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "x": -16, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "x": 0, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "x": 16, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "x": 0, + "y": 16, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 87, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + ] + } + ], + "tilesets": [ + { + "firstgid": 1, + "name": "map-pack", + "image": "../../demo/vendor/kenney/map-pack/Tilesheet/mapPack_tilesheet.png", + "imagewidth": 1088, + "imageheight": 768, + "tilewidth": 64, + "tileheight": 64, + "columns": 17, + "tilecount": 204, + "spacing": 0, + "margin": 0 + } + ] +} diff --git a/examples/examples.json b/examples/examples.json index 732bc3b9..673a5501 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1911,6 +1911,23 @@ "capabilities": [ "keyboard" ] + }, + { + "slug": "tiled-infinite-map", + "path": "tilemap/tiled-infinite-map.js", + "language": "typescript", + "title": "Tiled Infinite Map Streaming", + "description": "Stream a hand-authored Tiled infinite .tmj map: TiledMap.toTileMap() converts each chunked tile layer to an unbounded TileLayer, and a ChunkStreamer per layer pulls chunks from TiledMap.getChunkSource() around a free-flying WASD camera.", + "backend": "core", + "tags": [ + "tilemap", + "tiled", + "streaming" + ], + "level": "advanced", + "capabilities": [ + "keyboard" + ] } ], "tweens-animation": [ diff --git a/examples/tilemap/tiled-infinite-map.js b/examples/tilemap/tiled-infinite-map.js new file mode 100644 index 00000000..b6070370 --- /dev/null +++ b/examples/tilemap/tiled-infinite-map.js @@ -0,0 +1,117 @@ +// Auto-generated from tiled-infinite-map.ts — edit the .ts source, not this file. +import { Application, Asset, Color, Keyboard, Scene, View } from '@codexo/exojs'; +import { tiledExtension } from '@codexo/exojs-tiled'; +import { ChunkStreamer, TileMapNode } from '@codexo/exojs-tilemap'; +import { mountControls } from '@examples/runtime'; +// A hand-authored Tiled `.tmj` infinite map, streamed the same way the +// procedural-terrain examples stream generated worlds: TiledMap.toTileMap() +// converts every chunked ("infinite") tile layer to an unbounded runtime +// TileLayer and builds a ChunkSource for it as a side effect; getChunkSource +// hands that source to a ChunkStreamer — one per chunked layer — ticked from +// a free-flying WASD camera with no bounds. +// +// drift-fields.tmj (examples/assets/json/maps/) is a small island cluster: a +// "Ground" tile layer (8 on-disk 16x16 chunks — a sand beach ring, a rock +// outcrop near the origin, a snow patch tucked into the interior, and an open +// "bay" where one corner chunk was left unauthored) and a sparser "Props" +// tile layer (6 chunks of scattered boulder/gem decoration) stacked on top. +// Everywhere neither layer has an on-disk chunk, the map shows nothing — +// the clear color behind it reads as open water. +const TILE = 64; +const MOVE_SPEED = 480; +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unauthored/unloaded chunks + // tiledExtension depends on tilemapExtension, so registering it alone is + // enough for both loading (.tmj) and rendering (TileMapNode). + extensions: [tiledExtension], + loader: { + basePath: 'assets/', + }, +}); +class TiledInfiniteMapScene extends Scene { + camera; + mapNode; + groundStreamer; + propsStreamer; + moveX = 0; + moveY = 0; + hudTimer = 0; + hud; + async init() { + const source = await this.loader.load(Asset.kind('tiledMap', 'json/maps/drift-fields.tmj')); + const runtimeMap = source.toTileMap(); + this.mapNode = new TileMapNode(runtimeMap); + const ground = runtimeMap.getTileLayer('Ground'); + const props = runtimeMap.getTileLayer('Props'); + if (!ground || !props) { + throw new Error('drift-fields.tmj is missing its "Ground" or "Props" tile layer'); + } + // getChunkSource is a side effect of the toTileMap() call above — it + // returns undefined for a finite (data-based) layer, and one + // ChunkSource per chunked ("infinite") layer otherwise. + const groundSource = source.getChunkSource(ground.id); + const propsSource = source.getChunkSource(props.id); + if (!groundSource || !propsSource) { + throw new Error('drift-fields.tmj\'s "Ground"/"Props" layers are not chunked — is "infinite" true?'); + } + const { width, height } = app.canvas; + // Free camera: moved directly by WASD, not following any actor — no + // setBounds, since an unbounded map has no edges to clamp to. + this.camera = new View(0, 0, width, height); + this.groundStreamer = new ChunkStreamer(ground, groundSource, this.camera); + this.propsStreamer = new ChunkStreamer(props, propsSource, this.camera); + this.setupInput(); + this.setupHud(); + } + setupInput() { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) + this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) + this.moveY = 0; + }); + } + setupHud() { + this.hud = mountControls({ + title: 'Tiled Infinite Map Streaming', + controls: [{ keys: 'WASD', action: 'fly the free camera across the streamed map' }], + status: '', + hint: '"Ground" and "Props" each stream through their own ChunkStreamer, sourced from TiledMap.getChunkSource(layer.id) — Tiled\'s on-disk 16x16 chunks are re-sliced onto the runtime chunk grid on demand, one requested chunk at a time.', + }); + } + update(delta) { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + this.camera.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + this.groundStreamer.update(); + this.propsStreamer.update(); + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.camera.center.x / TILE); + const ty = Math.floor(this.camera.center.y / TILE); + this.hud.setStatus(`ground ${this.groundStreamer.residentCount} chunks · props ${this.propsStreamer.residentCount} chunks · tile ${tx}, ${ty}`); + } + } + draw(context) { + context.backend.clear(); + context.render(this.mapNode, { view: this.camera }); + } +} +app.start(new TiledInfiniteMapScene()); diff --git a/examples/tilemap/tiled-infinite-map.ts b/examples/tilemap/tiled-infinite-map.ts new file mode 100644 index 00000000..67caf34a --- /dev/null +++ b/examples/tilemap/tiled-infinite-map.ts @@ -0,0 +1,136 @@ +import { Application, Asset, Color, Keyboard, type RenderingContext, Scene, type Time, View } from '@codexo/exojs'; +import { tiledExtension } from '@codexo/exojs-tiled'; +import { ChunkStreamer, TileMapNode } from '@codexo/exojs-tilemap'; +import { mountControls } from '@examples/runtime'; + +// A hand-authored Tiled `.tmj` infinite map, streamed the same way the +// procedural-terrain examples stream generated worlds: TiledMap.toTileMap() +// converts every chunked ("infinite") tile layer to an unbounded runtime +// TileLayer and builds a ChunkSource for it as a side effect; getChunkSource +// hands that source to a ChunkStreamer — one per chunked layer — ticked from +// a free-flying WASD camera with no bounds. +// +// drift-fields.tmj (examples/assets/json/maps/) is a small island cluster: a +// "Ground" tile layer (8 on-disk 16x16 chunks — a sand beach ring, a rock +// outcrop near the origin, a snow patch tucked into the interior, and an open +// "bay" where one corner chunk was left unauthored) and a sparser "Props" +// tile layer (6 chunks of scattered boulder/gem decoration) stacked on top. +// Everywhere neither layer has an on-disk chunk, the map shows nothing — +// the clear color behind it reads as open water. + +const TILE = 64; +const MOVE_SPEED = 480; + +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unauthored/unloaded chunks + // tiledExtension depends on tilemapExtension, so registering it alone is + // enough for both loading (.tmj) and rendering (TileMapNode). + extensions: [tiledExtension], + loader: { + basePath: 'assets/', + }, +}); + +class TiledInfiniteMapScene extends Scene { + private camera!: View; + private mapNode!: TileMapNode; + private groundStreamer!: ChunkStreamer; + private propsStreamer!: ChunkStreamer; + private moveX = 0; + private moveY = 0; + private hudTimer = 0; + private hud!: ReturnType; + + override async init(): Promise { + const source = await this.loader.load(Asset.kind('tiledMap', 'json/maps/drift-fields.tmj')); + const runtimeMap = source.toTileMap(); + + this.mapNode = new TileMapNode(runtimeMap); + + const ground = runtimeMap.getTileLayer('Ground'); + const props = runtimeMap.getTileLayer('Props'); + + if (!ground || !props) { + throw new Error('drift-fields.tmj is missing its "Ground" or "Props" tile layer'); + } + + // getChunkSource is a side effect of the toTileMap() call above — it + // returns undefined for a finite (data-based) layer, and one + // ChunkSource per chunked ("infinite") layer otherwise. + const groundSource = source.getChunkSource(ground.id); + const propsSource = source.getChunkSource(props.id); + + if (!groundSource || !propsSource) { + throw new Error('drift-fields.tmj\'s "Ground"/"Props" layers are not chunked — is "infinite" true?'); + } + + const { width, height } = app.canvas; + + // Free camera: moved directly by WASD, not following any actor — no + // setBounds, since an unbounded map has no edges to clamp to. + this.camera = new View(0, 0, width, height); + + this.groundStreamer = new ChunkStreamer(ground, groundSource, this.camera); + this.propsStreamer = new ChunkStreamer(props, propsSource, this.camera); + + this.setupInput(); + this.setupHud(); + } + + private setupInput(): void { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) this.moveY = 0; + }); + } + + private setupHud(): void { + this.hud = mountControls({ + title: 'Tiled Infinite Map Streaming', + controls: [{ keys: 'WASD', action: 'fly the free camera across the streamed map' }], + status: '', + hint: '"Ground" and "Props" each stream through their own ChunkStreamer, sourced from TiledMap.getChunkSource(layer.id) — Tiled\'s on-disk 16x16 chunks are re-sliced onto the runtime chunk grid on demand, one requested chunk at a time.', + }); + } + + override update(delta: Time): void { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + + this.camera.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + + this.groundStreamer.update(); + this.propsStreamer.update(); + + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.camera.center.x / TILE); + const ty = Math.floor(this.camera.center.y / TILE); + this.hud.setStatus( + `ground ${this.groundStreamer.residentCount} chunks · props ${this.propsStreamer.residentCount} chunks · tile ${tx}, ${ty}`, + ); + } + } + + override draw(context: RenderingContext): void { + context.backend.clear(); + context.render(this.mapNode, { view: this.camera }); + } +} + +app.start(new TiledInfiniteMapScene()); diff --git a/site/src/content/guide/rendering/infinite-maps.mdx b/site/src/content/guide/rendering/infinite-maps.mdx index 3f1b86c4..61e88860 100644 --- a/site/src/content/guide/rendering/infinite-maps.mdx +++ b/site/src/content/guide/rendering/infinite-maps.mdx @@ -266,6 +266,8 @@ if (terrain) { From there it is the same `ChunkStreamer` loop as the procedural providers — the only difference is where the tiles come from. + + ## 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. @@ -275,5 +277,6 @@ From there it is the same `ChunkStreamer` loop as the procedural providers — t - The [Infinite Procedural Terrain](/ExoJS/en/examples/tilemap/infinite-terrain/) example is the sync provider end to end — fly an endless value-noise world and reroll the seed. - The [Worker-Streamed Terrain](/ExoJS/en/examples/tilemap/worker-streamed-terrain/) example toggles between the sync and worker providers under a tunable sample cost, so you can watch the main thread hitch or stay smooth. +- The [Tiled Infinite Map Streaming](/ExoJS/en/examples/tilemap/tiled-infinite-map/) example streams a hand-authored Tiled map instead of procedural terrain — a free-flying camera with two independently chunked layers. - [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) covers the group-invalidation rule a streamed layer interacts with. - [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) covers loading `.tmj` maps and `toTileMap()` in full. diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index cd9f3089..feaad691 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -409,7 +409,7 @@ const RAW_PARTS: ReadonlyArray = [ 'move expensive sampling off the main thread with createWorkerSampledChunkSource', ], prerequisites: ['assets/tiled-maps'], - examples: ['tilemap/infinite-terrain', 'tilemap/worker-streamed-terrain'], + examples: ['tilemap/infinite-terrain', 'tilemap/worker-streamed-terrain', 'tilemap/tiled-infinite-map'], apiLinks: ['tile-map', 'tile-layer', 'chunk-streamer', 'chunk-source', 'tilemap-functions', 'tiled-map'], }, ], From 5d8c600444a200bbd9ae995cf7c918b646a3a182 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 21:11:46 +0200 Subject: [PATCH 5/8] chore(lint): cover scripts/**/*.mjs Extend the Node/config-files/scripts eslint block to glob scripts/**/*.mjs alongside scripts/**/*.ts, so the 5 .mjs scripts stop crashing the linter on the first type-aware rule. Add a scoped globals.browser override for webgpu-probe.mjs, whose page.evaluate() callbacks execute in the browser page rather than the Node process. Drop one dead assignment in generate-demo-audio-loops.mjs surfaced by the now-working lint pass. --- eslint.config.ts | 19 +++++++++++++++++-- scripts/generate-demo-audio-loops.mjs | 1 - 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 73184721..f9585cfa 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1238,11 +1238,11 @@ export default defineConfig([ // given a real tsconfig program, since these files intentionally sit // outside any typed program. { - files: ['*.config.ts', 'rollup.config.ts', 'jest.config.ts', 'eslint.config.ts', 'scripts/**/*.ts'], + files: ['*.config.ts', 'rollup.config.ts', 'jest.config.ts', 'eslint.config.ts', 'scripts/**/*.ts', 'scripts/**/*.mjs'], ...tseslint.configs.disableTypeChecked, }, { - files: ['*.config.ts', 'rollup.config.ts', 'jest.config.ts', 'eslint.config.ts', 'scripts/**/*.ts'], + files: ['*.config.ts', 'rollup.config.ts', 'jest.config.ts', 'eslint.config.ts', 'scripts/**/*.ts', 'scripts/**/*.mjs'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', @@ -1266,6 +1266,21 @@ export default defineConfig([ }, }, + // scripts/webgpu-probe.mjs runs as a Node process that drives a Playwright + // page, but several of its callbacks are passed to `page.evaluate()` and + // execute inside the browser page instead — so the same file legitimately + // references both Node and browser globals. Layer `globals.browser` on top + // of the Node/scripts block above just for this file, rather than widening + // browser globals onto every `scripts/**` file. + { + files: ['scripts/webgpu-probe.mjs'], + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, + // create-exo-app: a Node CLI scaffolder with its own tsconfig. Console output is // the tool's primary interface, so no-console is allowed here. { diff --git a/scripts/generate-demo-audio-loops.mjs b/scripts/generate-demo-audio-loops.mjs index 91eb3b3b..b7ab10e7 100644 --- a/scripts/generate-demo-audio-loops.mjs +++ b/scripts/generate-demo-audio-loops.mjs @@ -55,7 +55,6 @@ function writeWav(samples) { buf.write('data', o); o += 4; buf.writeUInt32LE(dataSize, o); - o += 4; for (let i = 0; i < samples.length; i++) { buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(samples[i] * 32767))), 44 + i * 2); } From 07e505fdefdbd6442a0a234080a7a5c0724387af Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 21:11:52 +0200 Subject: [PATCH 6/8] test(ci): make select-lanes assertions lane-addition-proof Whole-object toEqual(areas) assertions required editing every call site whenever a new lane area was added. Switch them to toMatchObject so each scenario still pins its known area values explicitly, but a future area addition doesn't retroactively fail unrelated scenarios. Verified by temporarily adding a 5th boolean area to selectAreas()'s return value: all 28 existing tests still passed before reverting the experiment. --- test/ci/select-lanes.test.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/ci/select-lanes.test.ts b/test/ci/select-lanes.test.ts index 684ca325..c0ec2c58 100644 --- a/test/ci/select-lanes.test.ts +++ b/test/ci/select-lanes.test.ts @@ -16,7 +16,7 @@ const decide = (...files: readonly string[]) => { describe('CI lane selection — engine/site areas', () => { it('tilemap SOURCE change runs every engine lane (unit, coverage, package-verify, all browsers) and site', () => { const { areas, lanes } = decide('packages/exojs-tilemap/src/TileMap.ts'); - expect(areas).toEqual({ engine: true, site: true, audioFx: false, tilemapWorker: true }); + expect(areas).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: true }); expect(lanes.browserAudio).toBe(false); expect(lanes.unit).toBe(true); expect(lanes.coverage).toBe(true); @@ -40,7 +40,7 @@ describe('CI lane selection — engine/site areas', () => { it('tiled SOURCE change runs engine + package validation + browser lanes', () => { const { areas, lanes } = decide('packages/exojs-tiled/src/TiledMap.ts'); - expect(areas).toEqual({ engine: true, site: true, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: false }); expect(lanes.unit).toBe(true); expect(lanes.packageVerify).toBe(true); expect(lanes.browserWebgl2).toBe(true); @@ -50,7 +50,7 @@ describe('CI lane selection — engine/site areas', () => { it('physics SOURCE change runs every engine lane and site', () => { const { areas, lanes } = decide('packages/exojs-physics/src/PhysicsWorld.ts'); - expect(areas).toEqual({ engine: true, site: true, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: false }); expect(lanes.unit).toBe(true); expect(lanes.coverage).toBe(true); expect(lanes.packageVerify).toBe(true); @@ -68,7 +68,7 @@ describe('CI lane selection — engine/site areas', () => { it('package README-only change is docs/site, NOT engine (no unit/coverage/package-verify/browser)', () => { const { areas, lanes } = decide('packages/exojs-tilemap/README.md'); - expect(areas).toEqual({ engine: false, site: true, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: false, site: true, audioFx: false, tilemapWorker: false }); expect(lanes.unit).toBe(false); expect(lanes.coverage).toBe(false); expect(lanes.packageVerify).toBe(false); @@ -82,8 +82,8 @@ describe('CI lane selection — engine/site areas', () => { }); it('package LICENSE / CHANGELOG changes are docs/site, NOT engine', () => { - expect(selectAreas(['packages/exojs-tiled/LICENSE'])).toEqual({ engine: false, site: true, audioFx: false, tilemapWorker: false }); - expect(selectAreas(['packages/exojs-particles/CHANGELOG.md'])).toEqual({ engine: false, site: true, audioFx: false, tilemapWorker: false }); + expect(selectAreas(['packages/exojs-tiled/LICENSE'])).toMatchObject({ engine: false, site: true, audioFx: false, tilemapWorker: false }); + expect(selectAreas(['packages/exojs-particles/CHANGELOG.md'])).toMatchObject({ engine: false, site: true, audioFx: false, tilemapWorker: false }); }); it('the ROOT changelog gates the engine lane (release version-coherence tests read it)', () => { @@ -92,7 +92,7 @@ describe('CI lane selection — engine/site areas', () => { it('core engine SOURCE change keeps existing behavior (engine lanes, no site)', () => { const { areas, lanes } = decide('src/rendering/Drawable.ts'); - expect(areas).toEqual({ engine: true, site: false, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: true, site: false, audioFx: false, tilemapWorker: false }); expect(lanes.browserAudio).toBe(false); expect(lanes.unit).toBe(true); expect(lanes.browserWebgl2).toBe(true); @@ -102,7 +102,7 @@ describe('CI lane selection — engine/site areas', () => { it('site-only change runs site build but NOT engine/browser/package lanes', () => { const { areas, lanes } = decide('site/src/pages/index.astro'); - expect(areas).toEqual({ engine: false, site: true, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: false, site: true, audioFx: false, tilemapWorker: false }); expect(lanes.unit).toBe(false); expect(lanes.browserWebgl2).toBe(false); expect(lanes.browserWebgpu).toBe(false); @@ -111,19 +111,19 @@ describe('CI lane selection — engine/site areas', () => { }); it('workflow change triggers broad validation (engine + site)', () => { - expect(selectAreas(['.github/workflows/ci.yml'])).toEqual({ engine: true, site: true, audioFx: true, tilemapWorker: true }); - expect(selectAreas(['.github/workflows/_ci-checks.yml'])).toEqual({ engine: true, site: true, audioFx: true, tilemapWorker: true }); + expect(selectAreas(['.github/workflows/ci.yml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); + expect(selectAreas(['.github/workflows/_ci-checks.yml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); }); it('lockfile / workspace-topology change triggers broad validation (engine + site)', () => { const lock = decide('pnpm-lock.yaml'); - expect(lock.areas).toEqual({ engine: true, site: true, audioFx: true, tilemapWorker: true }); + expect(lock.areas).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); expect(lock.lanes.unit).toBe(true); expect(lock.lanes.packageVerify).toBe(true); expect(lock.lanes.siteBuild).toBe(true); expect(lock.lanes.browserAudio).toBe(true); expect(lock.lanes.browserTilemapWorker).toBe(true); - expect(selectAreas(['pnpm-workspace.yaml'])).toEqual({ engine: true, site: true, audioFx: true, tilemapWorker: true }); + expect(selectAreas(['pnpm-workspace.yaml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); }); it('shared exojs-config package source change triggers engine lanes (affects every build/test)', () => { @@ -153,7 +153,7 @@ describe('CI lane selection — engine/site areas', () => { it('negative: a root docs-only change selects no engine and no site lanes', () => { const { areas, lanes } = decide('README.md'); - expect(areas).toEqual({ engine: false, site: false, audioFx: false, tilemapWorker: false }); + expect(areas).toMatchObject({ engine: false, site: false, audioFx: false, tilemapWorker: false }); expect(lanes.browserAudio).toBe(false); expect(lanes.unit).toBe(false); expect(lanes.browserWebgpu).toBe(false); @@ -165,7 +165,7 @@ describe('CI lane selection — engine/site areas', () => { }); it('handles Windows backslash separators and blank/whitespace entries', () => { - expect(selectAreas(['packages\\exojs-tilemap\\src\\TileMap.ts', '', ' '])).toEqual({ engine: true, site: true, audioFx: false, tilemapWorker: true }); + expect(selectAreas(['packages\\exojs-tilemap\\src\\TileMap.ts', '', ' '])).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: true }); }); }); @@ -195,7 +195,7 @@ describe('CI lane selection — PR #119 regression', () => { it('selects every lane that PR #119 wrongly skipped', () => { const { areas, lanes } = decide(...PR_119_FILES); - expect(areas).toEqual({ engine: true, site: true, audioFx: false, tilemapWorker: true }); + expect(areas).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: true }); // Previously skipped — must now run: expect(lanes.unit).toBe(true); expect(lanes.coverage).toBe(true); From c935137ca1a0ae69cec83e1d9f743b62ba26d7b6 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 21:12:41 +0200 Subject: [PATCH 7/8] chore(examples): derive webWorkers capability sync-example-capabilities.ts listed webWorkers in its ordering array but had no derivation rule, so worker-based examples were only ever tagged with the input capabilities they also happened to use. Add a rule: source matching createWorkerSampledChunkSource( or new Worker( implies webWorkers. Re-running the sync adds webWorkers to worker-streamed-terrain only; no other catalog entry changes. --- examples/examples.json | 3 ++- scripts/sync-example-capabilities.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/examples.json b/examples/examples.json index 673a5501..a7e31d65 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1909,7 +1909,8 @@ ], "level": "advanced", "capabilities": [ - "keyboard" + "keyboard", + "webWorkers" ] }, { diff --git a/scripts/sync-example-capabilities.ts b/scripts/sync-example-capabilities.ts index e72ea881..15885adf 100644 --- a/scripts/sync-example-capabilities.ts +++ b/scripts/sync-example-capabilities.ts @@ -99,6 +99,10 @@ function deriveCapabilities(source: string, slug: string, sectionSlug: string): caps.add('audio'); } + if (/\bcreateWorkerSampledChunkSource\(|\bnew Worker\(/.test(source)) { + caps.add('webWorkers'); + } + // Stable order for diff readability. const order: Capability[] = [ 'webgl2', From 302dfebebbbbff3c48c4ff651608350697b91d99 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 21:18:24 +0200 Subject: [PATCH 8/8] style: format Prettier flagged test/ci/select-lanes.test.ts (unwrapped toMatchObject call) from the select-lanes lane-addition-proof commit. --- test/ci/select-lanes.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/ci/select-lanes.test.ts b/test/ci/select-lanes.test.ts index c0ec2c58..dec57219 100644 --- a/test/ci/select-lanes.test.ts +++ b/test/ci/select-lanes.test.ts @@ -165,7 +165,12 @@ describe('CI lane selection — engine/site areas', () => { }); it('handles Windows backslash separators and blank/whitespace entries', () => { - expect(selectAreas(['packages\\exojs-tilemap\\src\\TileMap.ts', '', ' '])).toMatchObject({ engine: true, site: true, audioFx: false, tilemapWorker: true }); + expect(selectAreas(['packages\\exojs-tilemap\\src\\TileMap.ts', '', ' '])).toMatchObject({ + engine: true, + site: true, + audioFx: false, + tilemapWorker: true, + }); }); });