@codexo/exojs-tilemap 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +33 -26
  2. package/dist/esm/ChunkSource.d.ts +33 -0
  3. package/dist/esm/ChunkSource.d.ts.map +1 -0
  4. package/dist/esm/ChunkStreamer.d.ts +98 -0
  5. package/dist/esm/ChunkStreamer.d.ts.map +1 -0
  6. package/dist/esm/ChunkStreamer.js +201 -0
  7. package/dist/esm/ChunkStreamer.js.map +1 -0
  8. package/dist/esm/ImageLayer.d.ts +11 -3
  9. package/dist/esm/ImageLayer.d.ts.map +1 -0
  10. package/dist/esm/ImageLayer.js +71 -64
  11. package/dist/esm/ImageLayer.js.map +1 -1
  12. package/dist/esm/ImageLayerNode.d.ts +89 -0
  13. package/dist/esm/ImageLayerNode.d.ts.map +1 -0
  14. package/dist/esm/ImageLayerNode.js +205 -0
  15. package/dist/esm/ImageLayerNode.js.map +1 -0
  16. package/dist/esm/MapObject.d.ts +70 -0
  17. package/dist/esm/MapObject.d.ts.map +1 -0
  18. package/dist/esm/MapObject.js +34 -0
  19. package/dist/esm/MapObject.js.map +1 -0
  20. package/dist/esm/MapObjectSpawner.d.ts +133 -0
  21. package/dist/esm/MapObjectSpawner.d.ts.map +1 -0
  22. package/dist/esm/MapObjectSpawner.js +147 -0
  23. package/dist/esm/MapObjectSpawner.js.map +1 -0
  24. package/dist/esm/MapSpawnSession.d.ts +53 -0
  25. package/dist/esm/MapSpawnSession.d.ts.map +1 -0
  26. package/dist/esm/MapSpawnSession.js +88 -0
  27. package/dist/esm/MapSpawnSession.js.map +1 -0
  28. package/dist/esm/MapWorld.d.ts +134 -0
  29. package/dist/esm/MapWorld.d.ts.map +1 -0
  30. package/dist/esm/MapWorld.js +150 -0
  31. package/dist/esm/MapWorld.js.map +1 -0
  32. package/dist/esm/MapWorldRuntime.d.ts +182 -0
  33. package/dist/esm/MapWorldRuntime.d.ts.map +1 -0
  34. package/dist/esm/MapWorldRuntime.js +261 -0
  35. package/dist/esm/MapWorldRuntime.js.map +1 -0
  36. package/dist/esm/ObjectLayer.d.ts +93 -7
  37. package/dist/esm/ObjectLayer.d.ts.map +1 -0
  38. package/dist/esm/ObjectLayer.js +184 -167
  39. package/dist/esm/ObjectLayer.js.map +1 -1
  40. package/dist/esm/SampledChunkSource.d.ts +43 -0
  41. package/dist/esm/SampledChunkSource.d.ts.map +1 -0
  42. package/dist/esm/SampledChunkSource.js +48 -0
  43. package/dist/esm/SampledChunkSource.js.map +1 -0
  44. package/dist/esm/TileAnimator.d.ts +12 -2
  45. package/dist/esm/TileAnimator.d.ts.map +1 -0
  46. package/dist/esm/TileAnimator.js +154 -165
  47. package/dist/esm/TileAnimator.js.map +1 -1
  48. package/dist/esm/TileChunk.d.ts +30 -5
  49. package/dist/esm/TileChunk.d.ts.map +1 -0
  50. package/dist/esm/TileChunk.js +190 -188
  51. package/dist/esm/TileChunk.js.map +1 -1
  52. package/dist/esm/TileChunkNode.d.ts +18 -3
  53. package/dist/esm/TileChunkNode.d.ts.map +1 -0
  54. package/dist/esm/TileChunkNode.js +104 -85
  55. package/dist/esm/TileChunkNode.js.map +1 -1
  56. package/dist/esm/TileLayer.d.ts +104 -24
  57. package/dist/esm/TileLayer.d.ts.map +1 -0
  58. package/dist/esm/TileLayer.js +511 -469
  59. package/dist/esm/TileLayer.js.map +1 -1
  60. package/dist/esm/TileLayerNode.d.ts +52 -11
  61. package/dist/esm/TileLayerNode.d.ts.map +1 -0
  62. package/dist/esm/TileLayerNode.js +224 -166
  63. package/dist/esm/TileLayerNode.js.map +1 -1
  64. package/dist/esm/TileMap.d.ts +96 -31
  65. package/dist/esm/TileMap.d.ts.map +1 -0
  66. package/dist/esm/TileMap.js +401 -357
  67. package/dist/esm/TileMap.js.map +1 -1
  68. package/dist/esm/TileMapBand.d.ts +42 -29
  69. package/dist/esm/TileMapBand.d.ts.map +1 -0
  70. package/dist/esm/TileMapBand.js +151 -158
  71. package/dist/esm/TileMapBand.js.map +1 -1
  72. package/dist/esm/TileMapNode.d.ts +34 -18
  73. package/dist/esm/TileMapNode.d.ts.map +1 -0
  74. package/dist/esm/TileMapNode.js +122 -109
  75. package/dist/esm/TileMapNode.js.map +1 -1
  76. package/dist/esm/TileMapView.d.ts +108 -47
  77. package/dist/esm/TileMapView.d.ts.map +1 -0
  78. package/dist/esm/TileMapView.js +385 -338
  79. package/dist/esm/TileMapView.js.map +1 -1
  80. package/dist/esm/TileSet.d.ts +15 -6
  81. package/dist/esm/TileSet.d.ts.map +1 -0
  82. package/dist/esm/TileSet.js +161 -181
  83. package/dist/esm/TileSet.js.map +1 -1
  84. package/dist/esm/WangSet.d.ts +5 -4
  85. package/dist/esm/WangSet.d.ts.map +1 -0
  86. package/dist/esm/WangSet.js +70 -78
  87. package/dist/esm/WangSet.js.map +1 -1
  88. package/dist/esm/WorkerSampledChunkSource.d.ts +95 -0
  89. package/dist/esm/WorkerSampledChunkSource.d.ts.map +1 -0
  90. package/dist/esm/WorkerSampledChunkSource.js +117 -0
  91. package/dist/esm/WorkerSampledChunkSource.js.map +1 -0
  92. package/dist/esm/autoTile.d.ts +10 -5
  93. package/dist/esm/autoTile.d.ts.map +1 -0
  94. package/dist/esm/autoTile.js +164 -205
  95. package/dist/esm/autoTile.js.map +1 -1
  96. package/dist/esm/chunkGeometry.d.ts +8 -7
  97. package/dist/esm/chunkGeometry.d.ts.map +1 -0
  98. package/dist/esm/chunkGeometry.js +83 -94
  99. package/dist/esm/chunkGeometry.js.map +1 -1
  100. package/dist/esm/index.d.ts +1 -0
  101. package/dist/esm/index.d.ts.map +1 -0
  102. package/dist/esm/index.js +26 -15
  103. package/dist/esm/nodeBounds.d.ts +20 -0
  104. package/dist/esm/nodeBounds.d.ts.map +1 -0
  105. package/dist/esm/nodeBounds.js +40 -0
  106. package/dist/esm/nodeBounds.js.map +1 -0
  107. package/dist/esm/pixelSnap.d.ts +4 -3
  108. package/dist/esm/pixelSnap.d.ts.map +1 -0
  109. package/dist/esm/pixelSnap.js +18 -16
  110. package/dist/esm/pixelSnap.js.map +1 -1
  111. package/dist/esm/public.d.ts +23 -3
  112. package/dist/esm/public.d.ts.map +1 -0
  113. package/dist/esm/tileCollision.d.ts +190 -0
  114. package/dist/esm/tileCollision.d.ts.map +1 -0
  115. package/dist/esm/tileCollision.js +367 -0
  116. package/dist/esm/tileCollision.js.map +1 -0
  117. package/dist/esm/tileWord.d.ts +12 -0
  118. package/dist/esm/tileWord.d.ts.map +1 -0
  119. package/dist/esm/tileWord.js +16 -0
  120. package/dist/esm/tileWord.js.map +1 -0
  121. package/dist/esm/tilemapExtension.d.ts +4 -3
  122. package/dist/esm/tilemapExtension.d.ts.map +1 -0
  123. package/dist/esm/tilemapExtension.js +47 -47
  124. package/dist/esm/tilemapExtension.js.map +1 -1
  125. package/dist/esm/tilemapSerializers.d.ts +5 -4
  126. package/dist/esm/tilemapSerializers.d.ts.map +1 -0
  127. package/dist/esm/tilemapSerializers.js +48 -41
  128. package/dist/esm/tilemapSerializers.js.map +1 -1
  129. package/dist/esm/types.d.ts +34 -18
  130. package/dist/esm/types.d.ts.map +1 -0
  131. package/dist/esm/types.js +111 -120
  132. package/dist/esm/types.js.map +1 -1
  133. package/dist/esm/webgl2/WebGl2TileChunkRenderer.d.ts +50 -2
  134. package/dist/esm/webgl2/WebGl2TileChunkRenderer.d.ts.map +1 -0
  135. package/dist/esm/webgl2/WebGl2TileChunkRenderer.js +366 -332
  136. package/dist/esm/webgl2/WebGl2TileChunkRenderer.js.map +1 -1
  137. package/dist/esm/webgl2/shaders/tile-chunk.frag.js +6 -0
  138. package/dist/esm/webgl2/shaders/tile-chunk.frag.js.map +1 -0
  139. package/dist/esm/webgl2/shaders/tile-chunk.vert.js +6 -0
  140. package/dist/esm/webgl2/shaders/tile-chunk.vert.js.map +1 -0
  141. package/dist/esm/webgpu/WebGpuTileChunkRenderer.d.ts +70 -4
  142. package/dist/esm/webgpu/WebGpuTileChunkRenderer.d.ts.map +1 -0
  143. package/dist/esm/webgpu/WebGpuTileChunkRenderer.js +496 -383
  144. package/dist/esm/webgpu/WebGpuTileChunkRenderer.js.map +1 -1
  145. package/dist/esm/webgpu/shaders/tile-chunk.wgsl.js +6 -0
  146. package/dist/esm/webgpu/shaders/tile-chunk.wgsl.js.map +1 -0
  147. package/package.json +8 -15
  148. package/dist/esm/index.js.map +0 -1
  149. package/dist/esm/register.d.ts +0 -1
  150. package/dist/esm/register.js +0 -25
  151. package/dist/esm/register.js.map +0 -1
@@ -1,474 +1,516 @@
1
- import { TileChunk } from './TileChunk.js';
2
- import { validateNonNegativeInteger, validatePositiveInteger, validateInteger, tileToChunkCoord, tileToLocalInChunk, packTile, unpackTile } from './types.js';
1
+ import { packTile, tileToChunkCoord, tileToLocalInChunk, unpackTile, validateInteger, validateNonNegativeInteger, validatePairedDimensions, validatePositiveInteger } from "./types.js";
2
+ import { TileChunk } from "./TileChunk.js";
3
3
 
4
+ //#region src/TileLayer.ts
4
5
  const DEFAULT_CHUNK_SIZE = 32;
6
+ /** Resolve and validate the optional uniform parallax scale. */
7
+ const resolveParallaxScale = (value = 1) => {
8
+ if (!Number.isFinite(value) || value <= 0) throw new Error("TileLayer parallaxScale must be a positive finite number.");
9
+ return value;
10
+ };
5
11
  /**
6
- * A generic, format-independent tile layer with chunk-first storage.
7
- *
8
- * Tile data is stored in fixed-size {@link TileChunk}s indexed by signed
9
- * chunk coordinates. For finite maps, only chunks that intersect the layer
10
- * bounds exist.
11
- *
12
- * **Mutation must go through the layer's public APIs only.**
13
- * `setTileAt` / `clearTileAt` validate coordinates, tileset references,
14
- * and increment revision counters only when the stored value actually
15
- * changes. Direct chunk mutation is not supported — the layer owns chunk
16
- * storage and exposes only a {@link ReadonlyTileChunk} view.
17
- *
18
- * The layer is NOT a SceneNode — that integration lives in
19
- * {@link import('./TileLayerNode').TileLayerNode}.
20
- *
21
- * @advanced
22
- */
23
- class TileLayer {
24
- /** Stable unique ID within the map. */
25
- id;
26
- /** Display name (may not be unique). */
27
- name;
28
- /** Width in tiles. */
29
- width;
30
- /** Height in tiles. */
31
- height;
32
- /** Chunk width (tiles). */
33
- chunkWidth;
34
- /** Chunk height (tiles). */
35
- chunkHeight;
36
- /** Tile width in pixels. */
37
- tileWidth;
38
- /** Tile height in pixels. */
39
- tileHeight;
40
- /** Pixel width. */
41
- get pixelWidth() { return this.width * this.tileWidth; }
42
- /** Pixel height. */
43
- get pixelHeight() { return this.height * this.tileHeight; }
44
- /** Visibility flag (mutable). */
45
- visible;
46
- /** Opacity 0..1 (mutable). */
47
- opacity;
48
- /** Horizontal pixel offset (mutable). */
49
- offsetX;
50
- /** Vertical pixel offset (mutable). */
51
- offsetY;
52
- /**
53
- * Parallax scroll factor on the X axis.
54
- * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary.
55
- */
56
- parallaxX;
57
- /**
58
- * Parallax scroll factor on the Y axis.
59
- * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary.
60
- */
61
- parallaxY;
62
- /** Layer class/type string (Tiled `class`; may be empty). */
63
- class;
64
- /** Multiplicative layer tint as `0xRRGGBB`, or `null` for no tint. */
65
- tintColor;
66
- /** Immutable layer properties. */
67
- properties;
68
- /** The tilesets available to this layer (shared array reference). */
69
- tilesets;
70
- /** Chunk storage: chunkKey → mutable TileChunk (internal). */
71
- _chunks = new Map();
72
- /**
73
- * Monotonic layer revision counter.
74
- * Increments on every cell mutation that actually changes a stored value.
75
- * Does NOT increment on no-op writes or failed mutations.
76
- */
77
- _revision = 0;
78
- /** Whether the layer has been destroyed. */
79
- _destroyed = false;
80
- /**
81
- * @throws When dimensions, chunk size, or other options are invalid.
82
- */
83
- // eslint-disable-next-line complexity -- straight-line option validation + defaulting
84
- constructor(options) {
85
- validateNonNegativeInteger(options.id, 'layer.id');
86
- if (!options.name || typeof options.name !== 'string') {
87
- throw new Error('TileLayer name must be a non-empty string.');
88
- }
89
- validatePositiveInteger(options.width, 'layer.width');
90
- validatePositiveInteger(options.height, 'layer.height');
91
- validatePositiveInteger(options.tileWidth, 'layer.tileWidth');
92
- validatePositiveInteger(options.tileHeight, 'layer.tileHeight');
93
- const chunkWidth = options.chunkWidth ?? DEFAULT_CHUNK_SIZE;
94
- const chunkHeight = options.chunkHeight ?? DEFAULT_CHUNK_SIZE;
95
- validatePositiveInteger(chunkWidth, 'chunkWidth');
96
- validatePositiveInteger(chunkHeight, 'chunkHeight');
97
- if (!Array.isArray(options.tilesets)) {
98
- throw new Error('TileLayer tilesets must be an array.');
99
- }
100
- const opacity = options.opacity ?? 1;
101
- if (typeof opacity !== 'number' || opacity < 0 || opacity > 1) {
102
- throw new Error(`TileLayer opacity must be 0..1 (got ${opacity}).`);
103
- }
104
- const offsetX = options.offsetX ?? 0;
105
- const offsetY = options.offsetY ?? 0;
106
- if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
107
- throw new Error('TileLayer offset must be finite numbers.');
108
- }
109
- const parallaxX = options.parallaxX ?? 1;
110
- const parallaxY = options.parallaxY ?? 1;
111
- if (!Number.isFinite(parallaxX) || !Number.isFinite(parallaxY)) {
112
- throw new Error('TileLayer parallax must be finite numbers.');
113
- }
114
- this.id = options.id;
115
- this.name = options.name;
116
- this.width = options.width;
117
- this.height = options.height;
118
- this.chunkWidth = chunkWidth;
119
- this.chunkHeight = chunkHeight;
120
- this.tileWidth = options.tileWidth;
121
- this.tileHeight = options.tileHeight;
122
- this.tilesets = options.tilesets;
123
- this.visible = options.visible ?? true;
124
- this.opacity = opacity;
125
- this.offsetX = offsetX;
126
- this.offsetY = offsetY;
127
- this.parallaxX = parallaxX;
128
- this.parallaxY = parallaxY;
129
- this.class = options.class ?? '';
130
- this.tintColor = options.tintColor ?? null;
131
- this.properties = options.properties
132
- ? Object.freeze({ ...options.properties })
133
- : Object.freeze({});
134
- }
135
- // ── Bounds helpers ────────────────────────────────────────────────────
136
- /**
137
- * Check whether a tile coordinate lies within the layer bounds.
138
- * @advanced
139
- */
140
- inBounds(tx, ty) {
141
- return tx >= 0 && tx < this.width && ty >= 0 && ty < this.height;
142
- }
143
- /** Compute the range of chunk coordinates that intersect this layer. */
144
- chunkRange() {
145
- return {
146
- minCx: 0,
147
- minCy: 0,
148
- maxCx: Math.floor((this.width - 1) / this.chunkWidth),
149
- maxCy: Math.floor((this.height - 1) / this.chunkHeight),
150
- };
151
- }
152
- // ── Chunk keying ──────────────────────────────────────────────────────
153
- _chunkKey(cx, cy) {
154
- return `${cx},${cy}`;
155
- }
156
- /**
157
- * Get a readonly view of a chunk by signed chunk coordinates,
158
- * or undefined if it does not exist (never been touched).
159
- * @advanced
160
- */
161
- getChunk(cx, cy) {
162
- return this._chunks.get(this._chunkKey(cx, cy));
163
- }
164
- /**
165
- * Get or create a chunk at the given coordinates.
166
- * For finite layers, creation is only allowed within the valid chunk range.
167
- *
168
- * @internal Package-private: used by {@link setTileAt}, {@link fillRect},
169
- * and future adapter ingest. External users should not allocate
170
- * chunks use {@link setTileAt} to populate tiles.
171
- */
172
- _ensureChunk(cx, cy) {
173
- this._checkDestroyed();
174
- const key = this._chunkKey(cx, cy);
175
- let chunk = this._chunks.get(key);
176
- if (!chunk) {
177
- const range = this.chunkRange();
178
- if (cx < range.minCx || cx > range.maxCx || cy < range.minCy || cy > range.maxCy) {
179
- throw new Error(`Chunk (${cx}, ${cy}) outside layer chunk range ` +
180
- `[${range.minCx}..${range.maxCx}, ${range.minCy}..${range.maxCy}].`);
181
- }
182
- // Compute the actual tile dimensions for this chunk (edge chunks may be smaller).
183
- const startTx = cx * this.chunkWidth;
184
- const startTy = cy * this.chunkHeight;
185
- const cw = Math.min(this.chunkWidth, this.width - startTx);
186
- const ch = Math.min(this.chunkHeight, this.height - startTy);
187
- chunk = new TileChunk(cx, cy, cw, ch);
188
- this._chunks.set(key, chunk);
189
- }
190
- return chunk;
191
- }
192
- /**
193
- * Iterate over all loaded chunks in deterministic (cy, cx) ascending order.
194
- * Returns readonly chunk views callers cannot mutate storage.
195
- * @advanced
196
- */
197
- loadedChunks() {
198
- const entries = [...this._chunks.values()];
199
- entries.sort((a, b) => a.cy - b.cy || a.cx - b.cx);
200
- return entries[Symbol.iterator]();
201
- }
202
- // ── Tile queries ──────────────────────────────────────────────────────
203
- /**
204
- * Get the raw packed tile word at (tx, ty). Returns 0 for empty or out-of-bounds.
205
- * @advanced
206
- */
207
- getRawTileAt(tx, ty) {
208
- validateInteger(tx, 'tx');
209
- validateInteger(ty, 'ty');
210
- if (!this.inBounds(tx, ty))
211
- return 0;
212
- const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
213
- const chunk = this._chunks.get(this._chunkKey(cx, cy));
214
- if (!chunk)
215
- return 0;
216
- const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
217
- return chunk.getRawAt(lx, ly);
218
- }
219
- /**
220
- * Query a resolved tile at (tx, ty). Returns null for empty or out-of-bounds.
221
- * @advanced
222
- */
223
- getTileAt(tx, ty) {
224
- const packed = this.getRawTileAt(tx, ty);
225
- if (packed === 0)
226
- return null;
227
- const decoded = unpackTile(packed);
228
- if (!decoded)
229
- return null;
230
- if (decoded.tilesetIndex >= this.tilesets.length)
231
- return null;
232
- const tileset = this.tilesets[decoded.tilesetIndex];
233
- if (decoded.localTileId >= tileset.tileCount)
234
- return null;
235
- return {
236
- tileset,
237
- localTileId: decoded.localTileId,
238
- transform: decoded.transform,
239
- };
240
- }
241
- // ── Mutation ──────────────────────────────────────────────────────────
242
- /**
243
- * Validate a tile reference against the layer's tilesets.
244
- * Returns the packed form or throws.
245
- */
246
- _validateTileRef(tile) {
247
- if (!tile?.tileset) {
248
- throw new Error('setTileAt requires a valid ResolvedTile.');
249
- }
250
- const tilesetIndex = this.tilesets.indexOf(tile.tileset);
251
- if (tilesetIndex === -1) {
252
- throw new Error(`Tileset "${tile.tileset.name}" is not available to layer "${this.name}".`);
253
- }
254
- if (tile.localTileId < 0 || tile.localTileId >= tile.tileset.tileCount) {
255
- throw new Error(`localTileId ${tile.localTileId} out of range for tileset "${tile.tileset.name}" ` +
256
- `(max ${tile.tileset.tileCount - 1}).`);
257
- }
258
- return packTile(tilesetIndex, tile.localTileId, tile.transform);
259
- }
260
- /**
261
- * Set a tile at the given tile coordinates.
262
- * No-op if the effective value is unchanged.
263
- * @throws If coordinates are out of bounds or the tile reference is invalid.
264
- * @advanced
265
- */
266
- setTileAt(tx, ty, tile) {
267
- this._checkDestroyed();
268
- validateInteger(tx, 'tx');
269
- validateInteger(ty, 'ty');
270
- if (!this.inBounds(tx, ty)) {
271
- throw new Error(`setTileAt (${tx}, ${ty}) out of bounds [0..${this.width - 1}, 0..${this.height - 1}].`);
272
- }
273
- const packed = this._validateTileRef(tile);
274
- const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
275
- const chunk = this._ensureChunk(cx, cy);
276
- const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
277
- if (chunk._setRawAt(lx, ly, packed)) {
278
- this._revision++;
279
- }
280
- }
281
- /**
282
- * Clear (erase) the tile at the given coordinates.
283
- * No-op if the cell is already empty.
284
- * @throws If coordinates are out of bounds.
285
- * @advanced
286
- */
287
- clearTileAt(tx, ty) {
288
- this._checkDestroyed();
289
- validateInteger(tx, 'tx');
290
- validateInteger(ty, 'ty');
291
- if (!this.inBounds(tx, ty)) {
292
- throw new Error(`clearTileAt (${tx}, ${ty}) out of bounds [0..${this.width - 1}, 0..${this.height - 1}].`);
293
- }
294
- const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
295
- const chunk = this._chunks.get(this._chunkKey(cx, cy));
296
- if (!chunk)
297
- return; // no chunk = already empty
298
- const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
299
- if (chunk._setRawAt(lx, ly, 0)) {
300
- this._revision++;
301
- }
302
- }
303
- // ── Bulk fill ─────────────────────────────────────────────────────────
304
- /**
305
- * Fill a rectangular region with a tile.
306
- * @advanced
307
- */
308
- fillRect(x, y, w, h, tile) {
309
- this._checkDestroyed();
310
- const packed = this._validateTileRef(tile);
311
- let changed = false;
312
- for (let ty = y; ty < y + h; ty++) {
313
- for (let tx = x; tx < x + w; tx++) {
314
- if (!this.inBounds(tx, ty))
315
- continue;
316
- const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
317
- const chunk = this._ensureChunk(cx, cy);
318
- const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
319
- if (chunk._setRawAt(lx, ly, packed)) {
320
- changed = true;
321
- }
322
- }
323
- }
324
- if (changed)
325
- this._revision++;
326
- }
327
- /**
328
- * Clear a rectangular region.
329
- * @advanced
330
- */
331
- clearRect(x, y, w, h) {
332
- this._checkDestroyed();
333
- let changed = false;
334
- for (let ty = y; ty < y + h; ty++) {
335
- for (let tx = x; tx < x + w; tx++) {
336
- if (!this.inBounds(tx, ty))
337
- continue;
338
- const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
339
- const chunk = this._chunks.get(this._chunkKey(cx, cy));
340
- if (!chunk)
341
- continue;
342
- const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
343
- if (chunk._setRawAt(lx, ly, 0)) {
344
- changed = true;
345
- }
346
- }
347
- }
348
- if (changed)
349
- this._revision++;
350
- }
351
- // ── Iteration ─────────────────────────────────────────────────────────
352
- /**
353
- * Iterate non-empty tiles within a rectangular region in row-major order.
354
- * Yields (tx, ty, resolvedTile) tuples. Skips empty cells.
355
- * @advanced
356
- */
357
- *tilesInRect(x, y, w, h) {
358
- const startCx = Math.floor(x / this.chunkWidth);
359
- const endCx = Math.floor((x + w - 1) / this.chunkWidth);
360
- const startCy = Math.floor(y / this.chunkHeight);
361
- const endCy = Math.floor((y + h - 1) / this.chunkHeight);
362
- for (let cy = startCy; cy <= endCy; cy++) {
363
- for (let cx = startCx; cx <= endCx; cx++) {
364
- const chunk = this._chunks.get(this._chunkKey(cx, cy));
365
- if (!chunk || chunk.empty)
366
- continue;
367
- const chunkStartTx = cx * this.chunkWidth;
368
- const chunkStartTy = cy * this.chunkHeight;
369
- const minLx = Math.max(0, x - chunkStartTx);
370
- const maxLx = Math.min(chunk.width - 1, (x + w - 1) - chunkStartTx);
371
- const minLy = Math.max(0, y - chunkStartTy);
372
- const maxLy = Math.min(chunk.height - 1, (y + h - 1) - chunkStartTy);
373
- for (let ly = minLy; ly <= maxLy; ly++) {
374
- for (let lx = minLx; lx <= maxLx; lx++) {
375
- const packed = chunk.getRawAt(lx, ly);
376
- if (packed === 0)
377
- continue;
378
- const decoded = unpackTile(packed);
379
- if (!decoded)
380
- continue;
381
- if (decoded.tilesetIndex >= this.tilesets.length)
382
- continue;
383
- const tileset = this.tilesets[decoded.tilesetIndex];
384
- if (decoded.localTileId >= tileset.tileCount)
385
- continue;
386
- yield {
387
- tx: chunkStartTx + lx,
388
- ty: chunkStartTy + ly,
389
- tile: {
390
- tileset,
391
- localTileId: decoded.localTileId,
392
- transform: decoded.transform,
393
- },
394
- };
395
- }
396
- }
397
- }
398
- }
399
- }
400
- // ── Coordinate conversion ─────────────────────────────────────────────
401
- /**
402
- * Convert a tile coordinate to the pixel position of its top-left corner
403
- * in the layer's local space.
404
- * @advanced
405
- */
406
- tileToPixel(tx, ty) {
407
- return {
408
- x: tx * this.tileWidth + this.offsetX,
409
- y: ty * this.tileHeight + this.offsetY,
410
- };
411
- }
412
- /**
413
- * Convert a local pixel position to the tile coordinate that contains it.
414
- * Uses `floor`, so a point exactly on a tile boundary maps to the tile.
415
- * May return coordinates outside layer bounds.
416
- * @advanced
417
- */
418
- pixelToTile(px, py) {
419
- return {
420
- tx: Math.floor((px - this.offsetX) / this.tileWidth),
421
- ty: Math.floor((py - this.offsetY) / this.tileHeight),
422
- };
423
- }
424
- // ── Revision / lifecycle ──────────────────────────────────────────────
425
- /**
426
- * Monotonic layer revision counter.
427
- * Increments on every cell mutation that changes a stored value.
428
- * No-op writes and failed mutations do NOT increment.
429
- * @advanced
430
- */
431
- get revision() {
432
- return this._revision;
433
- }
434
- /** Whether the layer has been destroyed. */
435
- get destroyed() {
436
- return this._destroyed;
437
- }
438
- _checkDestroyed() {
439
- if (this._destroyed) {
440
- throw new Error(`TileLayer "${this.name}" has been destroyed.`);
441
- }
442
- }
443
- /**
444
- * Destroy this layer: clear chunk storage and mark destroyed.
445
- * Does NOT destroy tileset textures or external resources.
446
- * Idempotent.
447
- */
448
- destroy() {
449
- if (this._destroyed)
450
- return;
451
- this._destroyed = true;
452
- this._chunks.clear();
453
- }
454
- /**
455
- * Total number of non-empty tiles across all chunks.
456
- * Walk is cheap for dense layers; sparse layers benefit from empty-chunk fast path.
457
- * @advanced
458
- */
459
- countNonEmptyTiles() {
460
- let count = 0;
461
- for (const chunk of this._chunks.values()) {
462
- for (let ly = 0; ly < chunk.height; ly++) {
463
- for (let lx = 0; lx < chunk.width; lx++) {
464
- if (chunk.getRawAt(lx, ly) !== 0)
465
- count++;
466
- }
467
- }
468
- }
469
- return count;
470
- }
471
- }
12
+ * Validate a {@link TileLayerOptions} bag and resolve its optional fields
13
+ * (chunk size, opacity, offset, parallax) to concrete defaults.
14
+ * @throws When dimensions, chunk size, or other options are invalid.
15
+ */
16
+ const validateTileLayerOptions = (options) => {
17
+ validateNonNegativeInteger(options.id, "layer.id");
18
+ if (!options.name || typeof options.name !== "string") throw new Error("TileLayer name must be a non-empty string.");
19
+ validatePairedDimensions(options.width, options.height, "TileLayer", "layer");
20
+ validatePositiveInteger(options.tileWidth, "layer.tileWidth");
21
+ validatePositiveInteger(options.tileHeight, "layer.tileHeight");
22
+ const chunkWidth = options.chunkWidth ?? DEFAULT_CHUNK_SIZE;
23
+ const chunkHeight = options.chunkHeight ?? DEFAULT_CHUNK_SIZE;
24
+ validatePositiveInteger(chunkWidth, "chunkWidth");
25
+ validatePositiveInteger(chunkHeight, "chunkHeight");
26
+ if (!Array.isArray(options.tilesets)) throw new Error("TileLayer tilesets must be an array.");
27
+ const opacity = options.opacity ?? 1;
28
+ if (typeof opacity !== "number" || opacity < 0 || opacity > 1) throw new Error(`TileLayer opacity must be 0..1 (got ${opacity}).`);
29
+ const offsetX = options.offsetX ?? 0;
30
+ const offsetY = options.offsetY ?? 0;
31
+ if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) throw new Error("TileLayer offset must be finite numbers.");
32
+ const parallaxX = options.parallaxX ?? 1;
33
+ const parallaxY = options.parallaxY ?? 1;
34
+ if (!Number.isFinite(parallaxX) || !Number.isFinite(parallaxY)) throw new Error("TileLayer parallax must be finite numbers.");
35
+ return {
36
+ chunkWidth,
37
+ chunkHeight,
38
+ opacity,
39
+ offsetX,
40
+ offsetY,
41
+ parallaxX,
42
+ parallaxY,
43
+ parallaxScale: resolveParallaxScale(options.parallaxScale)
44
+ };
45
+ };
46
+ /**
47
+ * A generic, format-independent tile layer with chunk-first storage.
48
+ *
49
+ * Tile data is stored in fixed-size {@link TileChunk}s indexed by signed
50
+ * chunk coordinates. For finite maps, only chunks that intersect the layer
51
+ * bounds exist.
52
+ *
53
+ * **Mutation must go through the layer's public APIs only.**
54
+ * `setTileAt` / `clearTileAt` validate coordinates, tileset references,
55
+ * and increment revision counters only when the stored value actually
56
+ * changes. Direct chunk mutation is not supported - the layer owns chunk
57
+ * storage and exposes only a {@link ReadonlyTileChunk} view.
58
+ *
59
+ * The layer is NOT a SceneNode - that integration lives in
60
+ * {@link import('./TileLayerNode').TileLayerNode}.
61
+ *
62
+ * @advanced
63
+ */
64
+ var TileLayer = class {
65
+ /** Stable unique ID within the map. */
66
+ id;
67
+ /** Display name (may not be unique). */
68
+ name;
69
+ /** Width in tiles, or `undefined` if unbounded. */
70
+ width;
71
+ /** Height in tiles, or `undefined` if unbounded. */
72
+ height;
73
+ /** Chunk width (tiles). */
74
+ chunkWidth;
75
+ /** Chunk height (tiles). */
76
+ chunkHeight;
77
+ /** Tile width in pixels. */
78
+ tileWidth;
79
+ /** Tile height in pixels. */
80
+ tileHeight;
81
+ /** `true` if this layer has a fixed width/height; `false` if unbounded. */
82
+ get bounded() {
83
+ return this.width !== void 0 && this.height !== void 0;
84
+ }
85
+ /** Pixel width, or `undefined` if unbounded. */
86
+ get pixelWidth() {
87
+ return this.width === void 0 ? void 0 : this.width * this.tileWidth;
88
+ }
89
+ /** Pixel height, or `undefined` if unbounded. */
90
+ get pixelHeight() {
91
+ return this.height === void 0 ? void 0 : this.height * this.tileHeight;
92
+ }
93
+ /** Visibility flag (mutable). */
94
+ visible;
95
+ /** Opacity 0..1 (mutable). */
96
+ opacity;
97
+ /** Horizontal pixel offset (mutable). */
98
+ offsetX;
99
+ /** Vertical pixel offset (mutable). */
100
+ offsetY;
101
+ /**
102
+ * Parallax scroll factor on the X axis.
103
+ * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary.
104
+ */
105
+ parallaxX;
106
+ /**
107
+ * Parallax scroll factor on the Y axis.
108
+ * `1.0` = full camera speed, `0.5` = half speed, `0.0` = stationary.
109
+ */
110
+ parallaxY;
111
+ /** Uniform scale applied around the layer origin during parallax rendering. */
112
+ parallaxScale;
113
+ /** Layer class/type string (Tiled `class`; may be empty). */
114
+ class;
115
+ /** Multiplicative layer tint as `0xRRGGBB`, or `null` for no tint. */
116
+ tintColor;
117
+ /** Immutable layer properties. */
118
+ properties;
119
+ /** The tilesets available to this layer (shared array reference). */
120
+ tilesets;
121
+ /** Chunk storage: chunkKey → mutable TileChunk (internal). */
122
+ _chunks = /* @__PURE__ */ new Map();
123
+ /** Package-internal structural (adopt/evict) listeners. */
124
+ _structuralListeners = null;
125
+ /**
126
+ * Monotonic layer revision counter.
127
+ * Increments on every cell mutation that actually changes a stored value.
128
+ * Does NOT increment on no-op writes or failed mutations.
129
+ */
130
+ _revision = 0;
131
+ /** Whether the layer has been destroyed. */
132
+ _destroyed = false;
133
+ /**
134
+ * @throws When dimensions, chunk size, or other options are invalid.
135
+ */
136
+ constructor(options) {
137
+ const { chunkWidth, chunkHeight, opacity, offsetX, offsetY, parallaxX, parallaxY, parallaxScale } = validateTileLayerOptions(options);
138
+ this.id = options.id;
139
+ this.name = options.name;
140
+ this.width = options.width;
141
+ this.height = options.height;
142
+ this.chunkWidth = chunkWidth;
143
+ this.chunkHeight = chunkHeight;
144
+ this.tileWidth = options.tileWidth;
145
+ this.tileHeight = options.tileHeight;
146
+ this.tilesets = options.tilesets;
147
+ this.visible = options.visible ?? true;
148
+ this.opacity = opacity;
149
+ this.offsetX = offsetX;
150
+ this.offsetY = offsetY;
151
+ this.parallaxX = parallaxX;
152
+ this.parallaxY = parallaxY;
153
+ this.parallaxScale = parallaxScale;
154
+ this.class = options.class ?? "";
155
+ this.tintColor = options.tintColor ?? null;
156
+ this.properties = options.properties ? Object.freeze({ ...options.properties }) : Object.freeze({});
157
+ }
158
+ /**
159
+ * Check whether a tile coordinate lies within the layer bounds.
160
+ * Always `true` for an unbounded layer.
161
+ * @advanced
162
+ */
163
+ inBounds(tx, ty) {
164
+ if (this.width === void 0 || this.height === void 0) return true;
165
+ return tx >= 0 && tx < this.width && ty >= 0 && ty < this.height;
166
+ }
167
+ /**
168
+ * Compute the range of chunk coordinates that intersect this layer, or
169
+ * `null` if the layer is unbounded (any signed chunk coordinate is valid).
170
+ */
171
+ chunkRange() {
172
+ if (this.width === void 0 || this.height === void 0) return null;
173
+ return {
174
+ minCx: 0,
175
+ minCy: 0,
176
+ maxCx: Math.floor((this.width - 1) / this.chunkWidth),
177
+ maxCy: Math.floor((this.height - 1) / this.chunkHeight)
178
+ };
179
+ }
180
+ _chunkKey(cx, cy) {
181
+ return `${cx},${cy}`;
182
+ }
183
+ /**
184
+ * Get a readonly view of a chunk by signed chunk coordinates,
185
+ * or undefined if it does not exist (never been touched).
186
+ * @advanced
187
+ */
188
+ getChunk(cx, cy) {
189
+ return this._chunks.get(this._chunkKey(cx, cy));
190
+ }
191
+ /**
192
+ * Get or create a chunk at the given coordinates.
193
+ * For a bounded layer, creation is only allowed within the valid chunk
194
+ * range and edge chunks are clamped to the layer's remaining size. For an
195
+ * unbounded layer, any signed chunk coordinate is accepted and every
196
+ * chunk is full-size ({@link chunkWidth} × {@link chunkHeight}).
197
+ *
198
+ * @internal Package-private: used by {@link setTileAt}, {@link fillRect},
199
+ * and future adapter ingest. External users should not allocate
200
+ * chunks - use {@link setTileAt} to populate tiles.
201
+ */
202
+ _ensureChunk(cx, cy) {
203
+ this._checkDestroyed();
204
+ const key = this._chunkKey(cx, cy);
205
+ let chunk = this._chunks.get(key);
206
+ if (!chunk) {
207
+ const range = this.chunkRange();
208
+ let cw = this.chunkWidth;
209
+ let ch = this.chunkHeight;
210
+ if (range !== null && this.width !== void 0 && this.height !== void 0) {
211
+ if (cx < range.minCx || cx > range.maxCx || cy < range.minCy || cy > range.maxCy) throw new Error(`Chunk (${cx}, ${cy}) outside layer chunk range [${range.minCx}..${range.maxCx}, ${range.minCy}..${range.maxCy}].`);
212
+ const startTx = cx * this.chunkWidth;
213
+ const startTy = cy * this.chunkHeight;
214
+ cw = Math.min(this.chunkWidth, this.width - startTx);
215
+ ch = Math.min(this.chunkHeight, this.height - startTy);
216
+ }
217
+ chunk = new TileChunk(cx, cy, cw, ch);
218
+ this._chunks.set(key, chunk);
219
+ }
220
+ return chunk;
221
+ }
222
+ /**
223
+ * Install a chunk-provider-supplied payload directly, bypassing per-tile
224
+ * `setTileAt` cost. Overwrites any existing chunk at `(cx, cy)`. Always
225
+ * bumps the layer revision (installing a chunk is always a structural
226
+ * change, even if the payload happens to be all-zero).
227
+ *
228
+ * Does not validate `(cx, cy)` against {@link chunkRange} - callers
229
+ * (chunk-streaming controllers) are trusted to request coordinates within
230
+ * whatever range they intend to manage.
231
+ *
232
+ * @internal Package-private: for future chunk-provider/streaming use.
233
+ */
234
+ _adoptChunk(cx, cy, payload) {
235
+ this._checkDestroyed();
236
+ const chunk = new TileChunk(cx, cy, payload.width, payload.height, payload.tiles);
237
+ this._chunks.set(this._chunkKey(cx, cy), chunk);
238
+ this._revision++;
239
+ this._notifyStructural({
240
+ cx,
241
+ cy,
242
+ chunk
243
+ });
244
+ }
245
+ /**
246
+ * Evict a chunk from storage, freeing it for garbage collection. No-op if
247
+ * no chunk is loaded at `(cx, cy)`.
248
+ *
249
+ * @returns `true` if a chunk was found and removed, `false` otherwise.
250
+ * @internal Package-private: for future chunk-streaming eviction use.
251
+ */
252
+ _evictChunk(cx, cy) {
253
+ this._checkDestroyed();
254
+ const key = this._chunkKey(cx, cy);
255
+ if (!this._chunks.delete(key)) return false;
256
+ this._revision++;
257
+ this._notifyStructural({
258
+ cx,
259
+ cy,
260
+ chunk: null
261
+ });
262
+ return true;
263
+ }
264
+ /**
265
+ * Register a callback invoked synchronously whenever a chunk is installed
266
+ * ({@link _adoptChunk}) or evicted ({@link _evictChunk}). Package-internal:
267
+ * {@link import('./TileLayerNode').TileLayerNode} subscribes to keep its
268
+ * chunk-node children in sync with chunk-provider-driven adopt/evict calls
269
+ * (e.g. from {@link import('./ChunkStreamer').ChunkStreamer}) without
270
+ * needing a full {@link import('./TileLayerNode').TileLayerNode.refresh}.
271
+ * @internal
272
+ */
273
+ _addStructuralListener(listener) {
274
+ (this._structuralListeners ??= /* @__PURE__ */ new Set()).add(listener);
275
+ }
276
+ /**
277
+ * Unregister a listener added via {@link _addStructuralListener} (node destroy).
278
+ * @internal
279
+ */
280
+ _removeStructuralListener(listener) {
281
+ this._structuralListeners?.delete(listener);
282
+ }
283
+ _notifyStructural(event) {
284
+ if (this._structuralListeners === null) return;
285
+ for (const listener of this._structuralListeners) listener(event);
286
+ }
287
+ /**
288
+ * Iterate over all loaded chunks in deterministic (cy, cx) ascending order.
289
+ * Returns readonly chunk views - callers cannot mutate storage.
290
+ * @advanced
291
+ */
292
+ loadedChunks() {
293
+ const entries = [...this._chunks.values()];
294
+ entries.sort((a, b) => a.cy - b.cy || a.cx - b.cx);
295
+ return entries[Symbol.iterator]();
296
+ }
297
+ /**
298
+ * Get the raw packed tile word at (tx, ty). Returns 0 for empty or out-of-bounds.
299
+ * @advanced
300
+ */
301
+ getRawTileAt(tx, ty) {
302
+ validateInteger(tx, "tx");
303
+ validateInteger(ty, "ty");
304
+ if (!this.inBounds(tx, ty)) return 0;
305
+ const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
306
+ const chunk = this._chunks.get(this._chunkKey(cx, cy));
307
+ if (!chunk) return 0;
308
+ const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
309
+ return chunk.getRawAt(lx, ly);
310
+ }
311
+ /**
312
+ * Query a resolved tile at (tx, ty). Returns null for empty or out-of-bounds.
313
+ * @advanced
314
+ */
315
+ getTileAt(tx, ty) {
316
+ const packed = this.getRawTileAt(tx, ty);
317
+ if (packed === 0) return null;
318
+ const decoded = unpackTile(packed);
319
+ if (!decoded) return null;
320
+ if (decoded.tilesetIndex >= this.tilesets.length) return null;
321
+ const tileset = this.tilesets[decoded.tilesetIndex];
322
+ if (decoded.localTileId >= tileset.tileCount) return null;
323
+ return {
324
+ tileset,
325
+ localTileId: decoded.localTileId,
326
+ transform: decoded.transform
327
+ };
328
+ }
329
+ /**
330
+ * Validate a tile reference against the layer's tilesets.
331
+ * Returns the packed form or throws.
332
+ */
333
+ _validateTileRef(tile) {
334
+ if (!tile?.tileset) throw new Error("setTileAt requires a valid ResolvedTile.");
335
+ const tilesetIndex = this.tilesets.indexOf(tile.tileset);
336
+ if (tilesetIndex === -1) throw new Error(`Tileset "${tile.tileset.name}" is not available to layer "${this.name}".`);
337
+ if (tile.localTileId < 0 || tile.localTileId >= tile.tileset.tileCount) throw new Error(`localTileId ${tile.localTileId} out of range for tileset "${tile.tileset.name}" (max ${tile.tileset.tileCount - 1}).`);
338
+ return packTile(tilesetIndex, tile.localTileId, tile.transform);
339
+ }
340
+ /**
341
+ * Set a tile at the given tile coordinates.
342
+ * No-op if the effective value is unchanged.
343
+ * @throws If coordinates are out of bounds or the tile reference is invalid.
344
+ * @advanced
345
+ */
346
+ setTileAt(tx, ty, tile) {
347
+ this._checkDestroyed();
348
+ validateInteger(tx, "tx");
349
+ validateInteger(ty, "ty");
350
+ if (!this.inBounds(tx, ty)) {
351
+ const boundsMsg = this.width !== void 0 && this.height !== void 0 ? `[0..${this.width - 1}, 0..${this.height - 1}]` : "[unbounded]";
352
+ throw new Error(`setTileAt (${tx}, ${ty}) out of bounds ${boundsMsg}.`);
353
+ }
354
+ const packed = this._validateTileRef(tile);
355
+ const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
356
+ const chunk = this._ensureChunk(cx, cy);
357
+ const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
358
+ if (chunk._setRawAt(lx, ly, packed)) this._revision++;
359
+ }
360
+ /**
361
+ * Clear (erase) the tile at the given coordinates.
362
+ * No-op if the cell is already empty.
363
+ * @throws If coordinates are out of bounds.
364
+ * @advanced
365
+ */
366
+ clearTileAt(tx, ty) {
367
+ this._checkDestroyed();
368
+ validateInteger(tx, "tx");
369
+ validateInteger(ty, "ty");
370
+ if (!this.inBounds(tx, ty)) {
371
+ const boundsMsg = this.width !== void 0 && this.height !== void 0 ? `[0..${this.width - 1}, 0..${this.height - 1}]` : "[unbounded]";
372
+ throw new Error(`clearTileAt (${tx}, ${ty}) out of bounds ${boundsMsg}.`);
373
+ }
374
+ const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
375
+ const chunk = this._chunks.get(this._chunkKey(cx, cy));
376
+ if (!chunk) return;
377
+ const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
378
+ if (chunk._setRawAt(lx, ly, 0)) this._revision++;
379
+ }
380
+ /**
381
+ * Fill a rectangular region with a tile.
382
+ * @advanced
383
+ */
384
+ fillRect(x, y, w, h, tile) {
385
+ this._checkDestroyed();
386
+ const packed = this._validateTileRef(tile);
387
+ let changed = false;
388
+ for (let ty = y; ty < y + h; ty++) for (let tx = x; tx < x + w; tx++) {
389
+ if (!this.inBounds(tx, ty)) continue;
390
+ const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
391
+ const chunk = this._ensureChunk(cx, cy);
392
+ const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
393
+ if (chunk._setRawAt(lx, ly, packed)) changed = true;
394
+ }
395
+ if (changed) this._revision++;
396
+ }
397
+ /**
398
+ * Clear a rectangular region.
399
+ * @advanced
400
+ */
401
+ clearRect(x, y, w, h) {
402
+ this._checkDestroyed();
403
+ let changed = false;
404
+ for (let ty = y; ty < y + h; ty++) for (let tx = x; tx < x + w; tx++) {
405
+ if (!this.inBounds(tx, ty)) continue;
406
+ const { cx, cy } = tileToChunkCoord(tx, ty, this.chunkWidth, this.chunkHeight);
407
+ const chunk = this._chunks.get(this._chunkKey(cx, cy));
408
+ if (!chunk) continue;
409
+ const { lx, ly } = tileToLocalInChunk(tx, ty, this.chunkWidth, this.chunkHeight);
410
+ if (chunk._setRawAt(lx, ly, 0)) changed = true;
411
+ }
412
+ if (changed) this._revision++;
413
+ }
414
+ /**
415
+ * Iterate non-empty tiles within a rectangular region in row-major order.
416
+ * Yields (tx, ty, resolvedTile) tuples. Skips empty cells.
417
+ * @advanced
418
+ */
419
+ *tilesInRect(x, y, w, h) {
420
+ const startCx = Math.floor(x / this.chunkWidth);
421
+ const endCx = Math.floor((x + w - 1) / this.chunkWidth);
422
+ const startCy = Math.floor(y / this.chunkHeight);
423
+ const endCy = Math.floor((y + h - 1) / this.chunkHeight);
424
+ for (let cy = startCy; cy <= endCy; cy++) for (let cx = startCx; cx <= endCx; cx++) {
425
+ const chunk = this._chunks.get(this._chunkKey(cx, cy));
426
+ if (!chunk || chunk.empty) continue;
427
+ const chunkStartTx = cx * this.chunkWidth;
428
+ const chunkStartTy = cy * this.chunkHeight;
429
+ const minLx = Math.max(0, x - chunkStartTx);
430
+ const maxLx = Math.min(chunk.width - 1, x + w - 1 - chunkStartTx);
431
+ const minLy = Math.max(0, y - chunkStartTy);
432
+ const maxLy = Math.min(chunk.height - 1, y + h - 1 - chunkStartTy);
433
+ for (let ly = minLy; ly <= maxLy; ly++) for (let lx = minLx; lx <= maxLx; lx++) {
434
+ const packed = chunk.getRawAt(lx, ly);
435
+ if (packed === 0) continue;
436
+ const decoded = unpackTile(packed);
437
+ if (!decoded) continue;
438
+ if (decoded.tilesetIndex >= this.tilesets.length) continue;
439
+ const tileset = this.tilesets[decoded.tilesetIndex];
440
+ if (decoded.localTileId >= tileset.tileCount) continue;
441
+ yield {
442
+ tx: chunkStartTx + lx,
443
+ ty: chunkStartTy + ly,
444
+ tile: {
445
+ tileset,
446
+ localTileId: decoded.localTileId,
447
+ transform: decoded.transform
448
+ }
449
+ };
450
+ }
451
+ }
452
+ }
453
+ /**
454
+ * Convert a tile coordinate to the pixel position of its top-left corner
455
+ * in the layer's local space.
456
+ * @advanced
457
+ */
458
+ tileToPixel(tx, ty) {
459
+ return {
460
+ x: tx * this.tileWidth + this.offsetX,
461
+ y: ty * this.tileHeight + this.offsetY
462
+ };
463
+ }
464
+ /**
465
+ * Convert a local pixel position to the tile coordinate that contains it.
466
+ * Uses `floor`, so a point exactly on a tile boundary maps to the tile.
467
+ * May return coordinates outside layer bounds.
468
+ * @advanced
469
+ */
470
+ pixelToTile(px, py) {
471
+ return {
472
+ tx: Math.floor((px - this.offsetX) / this.tileWidth),
473
+ ty: Math.floor((py - this.offsetY) / this.tileHeight)
474
+ };
475
+ }
476
+ /**
477
+ * Monotonic layer revision counter.
478
+ * Increments on every cell mutation that changes a stored value.
479
+ * No-op writes and failed mutations do NOT increment.
480
+ * @advanced
481
+ */
482
+ get revision() {
483
+ return this._revision;
484
+ }
485
+ /** Whether the layer has been destroyed. */
486
+ get destroyed() {
487
+ return this._destroyed;
488
+ }
489
+ _checkDestroyed() {
490
+ if (this._destroyed) throw new Error(`TileLayer "${this.name}" has been destroyed.`);
491
+ }
492
+ /**
493
+ * Destroy this layer: clear chunk storage and mark destroyed.
494
+ * Does NOT destroy tileset textures or external resources.
495
+ * Idempotent.
496
+ */
497
+ destroy() {
498
+ if (this._destroyed) return;
499
+ this._destroyed = true;
500
+ this._chunks.clear();
501
+ }
502
+ /**
503
+ * Total number of non-empty tiles across all chunks.
504
+ * Walk is cheap for dense layers; sparse layers benefit from empty-chunk fast path.
505
+ * @advanced
506
+ */
507
+ countNonEmptyTiles() {
508
+ let count = 0;
509
+ for (const chunk of this._chunks.values()) for (let ly = 0; ly < chunk.height; ly++) for (let lx = 0; lx < chunk.width; lx++) if (chunk.getRawAt(lx, ly) !== 0) count++;
510
+ return count;
511
+ }
512
+ };
472
513
 
514
+ //#endregion
473
515
  export { TileLayer };
474
- //# sourceMappingURL=TileLayer.js.map
516
+ //# sourceMappingURL=TileLayer.js.map