@eva/plugin-renderer-tilemap 2.1.0-beta.1 → 2.1.0-beta.10

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.
@@ -49,25 +49,32 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
49
49
  };
50
50
 
51
51
  /**
52
- * Phaser-style Static Tilemap 渲染组件(MVP)。
52
+ * Tilemap 渲染组件。
53
53
  *
54
- * 行为:
55
- * 1. `tileset` 资源加载一张 spritesheet 纹理;
56
- * 2. 每个 layer 对应一个 PixiJS Container;
57
- * 3. layer 内每个非零 tile 都创建一个 Sprite,texture 由原 tileset 切片得到;
58
- * 4. tile 在 layer Container 内的位置:`(col * tileW + offsetX, row * tileH + offsetY)`。
54
+ * v1 (Phaser-style, 静态): 通过 `tileset` + `layers[].data[][]` 渲染。
55
+ * v2 (Godot-style, chunked): 通过 `tilemapRef` + `layersV2[].cellData.chunks` 渲染。
59
56
  *
60
- * 不支持(Phase 2 视情况增加):tile flipping、动态修改 API、iso/hex、physics 碰撞、camera。
57
+ * System 通过有无 `tilemapRef` 判断走哪条路径。两条路径不共存于同一实例。
61
58
  */
62
59
  class Tilemap extends eva_js.Component {
63
60
  constructor() {
64
61
  super(...arguments);
62
+ // v1 fields
65
63
  this.tileset = '';
66
64
  this.tileWidth = 32;
67
65
  this.tileHeight = 32;
68
66
  this.tilesetSpacing = 0;
69
67
  this.tilesetMargin = 0;
70
68
  this.layers = [];
69
+ // v2 fields
70
+ this.tilemapRef = '';
71
+ // 显式 `= undefined`:被 TilemapSystem 的 @componentObserver 观察;
72
+ // init() 用 `Object.assign(this, obj)` 只 copy obj 里存在的 key,DSL v1 路径
73
+ // (tileset+layers) 不传 layersV2 时 own property 不存在,observer.ts:236 会打
74
+ // "prop layersV2 not in component: Tilemap, Can not observer" 并跳过响应式挂载,
75
+ // 后续赋值 tilemap.layersV2 也不会触发 System rebuild。加 `= undefined` 让 TS
76
+ // emit `this.layersV2 = void 0`,保证 own property 存在。
77
+ this.layersV2 = undefined;
71
78
  }
72
79
  init(obj) {
73
80
  if (obj)
@@ -94,88 +101,1176 @@ __decorate([
94
101
  __decorate([
95
102
  inspectorDecorator.type('number'),
96
103
  __metadata("design:type", Number)
97
- ], Tilemap.prototype, "tilesetMargin", void 0);
104
+ ], Tilemap.prototype, "tilesetMargin", void 0);
105
+ __decorate([
106
+ inspectorDecorator.type('string'),
107
+ __metadata("design:type", String)
108
+ ], Tilemap.prototype, "tilemapRef", void 0);
109
+
110
+ /**
111
+ * 与 libs/dsl/src/editor/chunk-codec.ts 同形的解码 helper。
112
+ * 在 plugin 内复制一份是为了避免 @eva/* 反向依赖 @ali/eva-dsl 或本仓 libs/dsl。
113
+ *
114
+ * 这里只需要解码 + 拆 cell;编码留给 DSL 编辑层。
115
+ */
116
+ const CHUNK_SIZE$2 = 16;
117
+ const CHUNK_CELL_COUNT = CHUNK_SIZE$2 * CHUNK_SIZE$2;
118
+ function unpackCell(packed) {
119
+ return {
120
+ sourceSlot: packed & 0xff,
121
+ col: (packed >>> 8) & 0xff,
122
+ row: (packed >>> 16) & 0xff,
123
+ altIdx: (packed >>> 24) & 0x1f,
124
+ flipH: ((packed >>> 29) & 1) === 1,
125
+ flipV: ((packed >>> 30) & 1) === 1,
126
+ transpose: ((packed >>> 31) & 1) === 1,
127
+ };
128
+ }
129
+ function isEmptyCellValue(packed) {
130
+ return (packed & 0xff) === 0;
131
+ }
132
+ function decodeChunk(blob) {
133
+ const bytes = base64Decode(blob.blob);
134
+ if (bytes.byteLength !== CHUNK_CELL_COUNT * 4) {
135
+ throw new Error(`decodeChunk: expected ${CHUNK_CELL_COUNT * 4} bytes, got ${bytes.byteLength}`);
136
+ }
137
+ const copy = new Uint8Array(bytes.byteLength);
138
+ copy.set(bytes);
139
+ return new Int32Array(copy.buffer);
140
+ }
141
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
142
+ function base64Decode(s) {
143
+ if (typeof atob === 'function') {
144
+ const bin = atob(s);
145
+ const out = new Uint8Array(bin.length);
146
+ for (let i = 0; i < bin.length; i++)
147
+ out[i] = bin.charCodeAt(i);
148
+ return out;
149
+ }
150
+ const bufCtor = globalThis.Buffer;
151
+ if (bufCtor) {
152
+ return new Uint8Array(bufCtor.from(s, 'base64'));
153
+ }
154
+ // manual fallback
155
+ const clean = s.replace(/[^A-Za-z0-9+/]/g, '');
156
+ const padding = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0;
157
+ const len = Math.floor((clean.length * 3) / 4) - padding;
158
+ const out = new Uint8Array(len);
159
+ let p = 0;
160
+ for (let i = 0; i < clean.length; i += 4) {
161
+ const v0 = BASE64_ALPHABET.indexOf(clean[i]);
162
+ const v1 = BASE64_ALPHABET.indexOf(clean[i + 1]);
163
+ const v2 = clean[i + 2] ? BASE64_ALPHABET.indexOf(clean[i + 2]) : 0;
164
+ const v3 = clean[i + 3] ? BASE64_ALPHABET.indexOf(clean[i + 3]) : 0;
165
+ const w = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
166
+ if (p < len)
167
+ out[p++] = (w >> 16) & 0xff;
168
+ if (p < len)
169
+ out[p++] = (w >> 8) & 0xff;
170
+ if (p < len)
171
+ out[p++] = w & 0xff;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * TileSet 外链 .tileset.json 在运行时的 minimal type shape。
178
+ *
179
+ * 为避免反向依赖 libs/dsl,这里独立声明;与 libs/dsl/src/types/tileset.ts 的 disk schema 同形。
180
+ */
181
+ function makeLoadedTileset(raw) {
182
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
183
+ const slotByIdMap = new Map();
184
+ // slot 0 reserved as "empty"; sources begin at slot 1
185
+ const sourcesBySlot = [];
186
+ // Detect cache collisions: same textureAsset with different regionSize/margins/separation
187
+ // will share frame texture cache slots downstream (cache key = textureAsset#col,row),
188
+ // producing visual artifacts where source A renders source B's frame. Warn so users see
189
+ // the issue instead of staring at swapped sprites.
190
+ const seenByAsset = new Map();
191
+ for (let i = 0; i < raw.sources.length; i++) {
192
+ const src = raw.sources[i];
193
+ sourcesBySlot.push(src);
194
+ slotByIdMap.set(src.id, i + 1);
195
+ if (src.kind === 'atlas' && src.textureAsset) {
196
+ const prev = seenByAsset.get(src.textureAsset);
197
+ if (prev) {
198
+ const sameRegion = prev.regionSize.width === src.regionSize.width &&
199
+ prev.regionSize.height === src.regionSize.height;
200
+ const sameMargins = ((_b = (_a = prev.margins) === null || _a === void 0 ? void 0 : _a.x) !== null && _b !== void 0 ? _b : 0) === ((_d = (_c = src.margins) === null || _c === void 0 ? void 0 : _c.x) !== null && _d !== void 0 ? _d : 0) &&
201
+ ((_f = (_e = prev.margins) === null || _e === void 0 ? void 0 : _e.y) !== null && _f !== void 0 ? _f : 0) === ((_h = (_g = src.margins) === null || _g === void 0 ? void 0 : _g.y) !== null && _h !== void 0 ? _h : 0);
202
+ const sameSep = ((_k = (_j = prev.separation) === null || _j === void 0 ? void 0 : _j.x) !== null && _k !== void 0 ? _k : 0) === ((_m = (_l = src.separation) === null || _l === void 0 ? void 0 : _l.x) !== null && _m !== void 0 ? _m : 0) &&
203
+ ((_p = (_o = prev.separation) === null || _o === void 0 ? void 0 : _o.y) !== null && _p !== void 0 ? _p : 0) === ((_r = (_q = src.separation) === null || _q === void 0 ? void 0 : _q.y) !== null && _r !== void 0 ? _r : 0);
204
+ if (!sameRegion || !sameMargins || !sameSep) {
205
+ if (typeof console !== 'undefined') {
206
+ console.warn(`[Tilemap] Atlas sources '${prev.sourceId}' and '${src.id}' share textureAsset='${src.textureAsset}' ` +
207
+ `but differ in regionSize/margins/separation. Frame texture cache may collide and render wrong frames. ` +
208
+ `Use distinct textureAssets per atlas source.`);
209
+ }
210
+ }
211
+ }
212
+ else {
213
+ seenByAsset.set(src.textureAsset, {
214
+ regionSize: src.regionSize,
215
+ margins: src.margins,
216
+ separation: src.separation,
217
+ sourceId: src.id,
218
+ });
219
+ }
220
+ }
221
+ }
222
+ return {
223
+ raw,
224
+ sourcesBySlot,
225
+ slotByIdMap,
226
+ tileWidth: raw.tileSize.width,
227
+ tileHeight: raw.tileSize.height,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * TileAnimation driver (Phase 3).
233
+ *
234
+ * 纯计算层:输入 TilesetDocumentRaw 的 atlas.tiles[].animation,以及当前
235
+ * 时间(performance.now() 风格的 ms);输出每个 tile 当前应处的 atlasCoords。
236
+ *
237
+ * Phase 3 v1 只支持 sync phase;randomStart 留接口,实现为按 sourceSlot+col+row
238
+ * 派生确定性 phase offset(避免 Date.now / Math.random,见 memory)。
239
+ *
240
+ * 运行时 System 在 update() 时调用 advance(now),拿到 dirtyTilesByKey,
241
+ * 然后只重建那些 tile 所在的 chunk;不每帧重建整个 layer。
242
+ */
243
+ class TileAnimationDriver {
244
+ constructor() {
245
+ this.animations = [];
246
+ this.lastFrameIdxByKey = new Map();
247
+ }
248
+ loadFromTileset(raw) {
249
+ var _a;
250
+ this.animations = [];
251
+ this.lastFrameIdxByKey.clear();
252
+ for (let slotIdx = 0; slotIdx < raw.sources.length; slotIdx++) {
253
+ const src = raw.sources[slotIdx];
254
+ if (src.kind !== 'atlas')
255
+ continue;
256
+ const slot = slotIdx + 1;
257
+ for (const tile of src.tiles) {
258
+ const anim = tile.animation;
259
+ if (!anim || !anim.frames || anim.frames.length === 0)
260
+ continue;
261
+ const frames = anim.frames.map((f) => {
262
+ var _a;
263
+ return ({
264
+ col: f.atlasCoords.col,
265
+ row: f.atlasCoords.row,
266
+ durationMs: anim.stepMs * ((_a = f.durationFactor) !== null && _a !== void 0 ? _a : 1),
267
+ });
268
+ });
269
+ const totalDurationMs = frames.reduce((s, f) => s + f.durationMs, 0);
270
+ if (totalDurationMs <= 0)
271
+ continue;
272
+ const phaseOffsetMs = anim.phase === 'randomStart'
273
+ ? deterministicPhaseOffset(slot, tile.atlasCoords.col, tile.atlasCoords.row, totalDurationMs)
274
+ : 0;
275
+ this.animations.push({
276
+ sourceSlot: slot,
277
+ col: tile.atlasCoords.col,
278
+ row: tile.atlasCoords.row,
279
+ frames,
280
+ totalDurationMs,
281
+ phase: (_a = anim.phase) !== null && _a !== void 0 ? _a : 'sync',
282
+ phaseOffsetMs,
283
+ });
284
+ }
285
+ }
286
+ }
287
+ get animationCount() {
288
+ return this.animations.length;
289
+ }
290
+ /** True when (slot,col,row) is the source tile of an animation in this driver. */
291
+ isAnimatedSource(slot, col, row) {
292
+ for (const a of this.animations) {
293
+ if (a.sourceSlot === slot && a.col === col && a.row === row)
294
+ return true;
295
+ }
296
+ return false;
297
+ }
298
+ advance(nowMs) {
299
+ const currentFrames = new Map();
300
+ const dirtyKeys = new Set();
301
+ for (const anim of this.animations) {
302
+ const key = `${anim.sourceSlot},${anim.col},${anim.row}`;
303
+ const t = ((nowMs + anim.phaseOffsetMs) % anim.totalDurationMs + anim.totalDurationMs) % anim.totalDurationMs;
304
+ let acc = 0;
305
+ let idx = 0;
306
+ for (; idx < anim.frames.length; idx++) {
307
+ acc += anim.frames[idx].durationMs;
308
+ if (t < acc)
309
+ break;
310
+ }
311
+ if (idx >= anim.frames.length)
312
+ idx = anim.frames.length - 1;
313
+ const frame = anim.frames[idx];
314
+ currentFrames.set(key, { col: frame.col, row: frame.row });
315
+ const prev = this.lastFrameIdxByKey.get(key);
316
+ if (prev !== idx) {
317
+ dirtyKeys.add(key);
318
+ this.lastFrameIdxByKey.set(key, idx);
319
+ }
320
+ }
321
+ return { currentFrames, dirtyKeys };
322
+ }
323
+ reset() {
324
+ this.lastFrameIdxByKey.clear();
325
+ }
326
+ }
327
+ /** 派生 phase offset。避免 Math.random / Date.now;只用 tile coordinates。 */
328
+ function deterministicPhaseOffset(slot, col, row, total) {
329
+ // Mulberry32-ish single iteration on a hashed seed
330
+ let h = slot * 73856093 ^ col * 19349663 ^ row * 83492791;
331
+ h = (h ^ (h >>> 13)) * 1274126177;
332
+ h = h ^ (h >>> 16);
333
+ const t = ((h >>> 0) / 0xffffffff);
334
+ return Math.floor(t * total);
335
+ }
336
+
337
+ /**
338
+ * Tilemap performance probes(Sprint C B3,关联 ADR-0013)。
339
+ *
340
+ * 提供 11 个 probe(11 计时器 / 计数器),host 可通过 installTilemapPerfProbes(game)
341
+ * 把它们注册到 ADR-0013 的 installPerfProbes 命令式 API 中。
342
+ *
343
+ * 当前不直接依赖 ADR-0013 的 perf 包(避免循环依赖),改为暴露 PerfProbeRegistry
344
+ * 接口,host 注入。这样:
345
+ * - Phase B 门禁:budgetCoverage 通过这 11 个 probe 计入
346
+ * - 测试场景:host 可注入 in-memory 收集器 verify metrics
347
+ */
348
+ /** Probe 名称常量,与 ADR-0018 §4.6 表对齐。 */
349
+ const TILEMAP_PROBE_NAMES = {
350
+ DIRTY_REBUILD_MS: 'tilemap.dirtyRebuild.ms',
351
+ CULL_CHECK_MS: 'tilemap.cullCheck.ms',
352
+ ANIM_TICK_MS: 'tilemap.animTick.ms',
353
+ DRAWCALLS_COUNT: 'tilemap.drawcalls.count',
354
+ DRAWCALLS_BY_ATLAS: 'tilemap.drawcalls.byAtlas',
355
+ PHYSICS_REBAKE_MS: 'tilemap.physicsRebake.ms',
356
+ GPU_UPLOAD_MS: 'tilemap.gpuUpload.ms',
357
+ GPU_UPLOAD_BYTES: 'tilemap.gpuUpload.bytes',
358
+ AUTOTILE_MS: 'tilemap.autotile.ms',
359
+ PATCH_APPLY_MS: 'tilemap.patchApply.ms',
360
+ DIRTY_PENDING: 'tilemap.dirty.pending',
361
+ DIRTY_FRAMES_BEHIND: 'tilemap.dirty.framesBehind',
362
+ BODIES_TOTAL: 'tilemap.bodies.total',
363
+ BODIES_CREATED: 'tilemap.bodies.created',
364
+ BODIES_DESTROYED: 'tilemap.bodies.destroyed',
365
+ /**
366
+ * ChunkRenderStrategy dispatch counters(P1-3, ADR-0018 §Phase 4).
367
+ * 每构建一个 chunk emit 一次,分别记录 sprite / mesh / mesh-降级-sprite 的次数。
368
+ */
369
+ STRATEGY_SPRITE_COUNT: 'tilemap.strategy.sprite.count',
370
+ STRATEGY_MESH_COUNT: 'tilemap.strategy.mesh.count',
371
+ STRATEGY_MESH_FALLBACK_COUNT: 'tilemap.strategy.meshFallback.count',
372
+ /**
373
+ * SceneCollection cell skip counter(P1-4, ADR-0018 §H2).
374
+ * 当 TileMap chunk 内含 sceneCollection source 的 cell 时,runtime 暂时跳过(不渲染),
375
+ * 每跳过一次累加 1。配合每条 record 一次性 console.warn,提示需要 prefab placeholder 实现。
376
+ */
377
+ SCENECOLLECTION_SKIPPED_COUNT: 'tilemap.sceneCollection.skipped.count',
378
+ /**
379
+ * Viewport culling hits counter(C-2, ADR-0018 §Phase 4)。
380
+ * 每次 buildChunksForLayer 跳过一个 off-screen chunk 时累加 1。
381
+ * 当前 record-level `cullingBoundsHint` 由 host 注入(可选),未注入时该 probe 永远为 0。
382
+ * 真正的 camera-driven viewport 接入留下 cycle(参考 RendererSystem.application.renderer.view)。
383
+ */
384
+ CULL_HITS_COUNT: 'tilemap.cull.hits',
385
+ /**
386
+ * Mode-switch failure counter(T-L2, Phase L)。
387
+ * 当 handleChange 内部 v1↔v2 mode 切换时,新 mode 的 asset 加载失败导致旧 mode 已 teardown
388
+ * 又 build 不出来的情况:record.mode='unknown',probe 累加 1。host 看到 >0 表示 tilemap 资源
389
+ * 异常,需要诊断 tilemapRef/tileset 引用是否有效。
390
+ */
391
+ MODE_SWITCH_FAILED_COUNT: 'tilemap.modeSwitch.failed',
392
+ /**
393
+ * Animation tick error counter(T-N1, Phase N)。
394
+ *
395
+ * update() 内每帧 advance 每个 animation driver。某个 driver advance / 后续 sprite swap
396
+ * throw 时,本 probe +1 并 console.error 之,但不影响其他 entity 的 tick(try/catch 局部
397
+ * 包裹,外层 try/finally 确保 ANIM_TICK_MS endTiming 一定被调)。host 看到该 probe >0 表示
398
+ * tilemap 有 entity 的 animation pipeline 异常,需要诊断。
399
+ */
400
+ ANIM_TICK_ERROR_COUNT: 'tilemap.animTick.error.count',
401
+ /**
402
+ * WebGL context lost counter(T-N2, Phase N)。
403
+ *
404
+ * Eva runtime 监听 canvas 的 `webglcontextlost`,触发时本 probe +1,并把 record-level
405
+ * `contextLost = true` 暂停 tilemap update tick;`webglcontextrestored` 时清掉该标志。
406
+ * host 看到 >0 即知 WebGL 异常,通常需要在 restored 后 force re-add tilemap component
407
+ * 重新建几何。
408
+ */
409
+ CONTEXT_LOST_COUNT: 'tilemap.context.lost',
410
+ };
411
+ /** 创建一个内存收集器(测试用)。 */
412
+ function createInMemoryProbeRegistry() {
413
+ const inFlight = new Map();
414
+ const timings = new Map();
415
+ const counts = new Map();
416
+ const gauges = new Map();
417
+ return {
418
+ beginTiming(name) {
419
+ inFlight.set(name, nowMs());
420
+ },
421
+ endTiming(name) {
422
+ const t0 = inFlight.get(name);
423
+ if (t0 === undefined)
424
+ return;
425
+ inFlight.delete(name);
426
+ const dur = nowMs() - t0;
427
+ if (!timings.has(name))
428
+ timings.set(name, []);
429
+ timings.get(name).push(dur);
430
+ },
431
+ count(name, delta = 1) {
432
+ var _a;
433
+ counts.set(name, ((_a = counts.get(name)) !== null && _a !== void 0 ? _a : 0) + delta);
434
+ },
435
+ gauge(name, value) {
436
+ gauges.set(name, value);
437
+ },
438
+ histogram(name, value) {
439
+ this.count(`${name}.histN`);
440
+ this.count(`${name}.histSum`, value);
441
+ },
442
+ timings,
443
+ counts,
444
+ gauges,
445
+ };
446
+ }
447
+ function nowMs() {
448
+ if (typeof performance !== 'undefined' && performance.now)
449
+ return performance.now();
450
+ // performance.now 不可用时返回 0(测试环境必有 performance,此分支几乎不走)
451
+ return 0;
452
+ }
453
+ /**
454
+ * Adapter:把 host 端 installPerfProbes 的 API 适配到 PerfProbeRegistry。
455
+ *
456
+ * ADR-0013 的 installPerfProbes 暴露 `game.perfProbes?.{begin,end,count,gauge}` 等
457
+ * 命名空间。这里只做转发,host 决定具体接入哪个版本。
458
+ */
459
+ function adaptGamePerfProbes(game) {
460
+ var _a, _b, _c, _d;
461
+ const p = game === null || game === void 0 ? void 0 : game.perfProbes;
462
+ if (!p)
463
+ return null;
464
+ return {
465
+ beginTiming: (_a = p.beginTiming) !== null && _a !== void 0 ? _a : (() => { }),
466
+ endTiming: (_b = p.endTiming) !== null && _b !== void 0 ? _b : (() => { }),
467
+ count: (_c = p.count) !== null && _c !== void 0 ? _c : (() => { }),
468
+ gauge: (_d = p.gauge) !== null && _d !== void 0 ? _d : (() => { }),
469
+ histogram: p.histogram,
470
+ };
471
+ }
472
+ /**
473
+ * Coverage 计算 — Phase B 门禁要求 budgetCoverage ≥ 0.95。
474
+ * 这里返回当前 hot-path 涉及的 probe 名字集合,host 用它对比 installPerfProbes 实际
475
+ * 注册的 probe set。
476
+ */
477
+ function getRequiredTilemapProbes() {
478
+ return Object.values(TILEMAP_PROBE_NAMES);
479
+ }
480
+
481
+ /**
482
+ * Chunk render strategy(Phase 4).
483
+ *
484
+ * 当前 system.ts 直接走 sprite-entity 路径(每非空 cell 一个 PIXI.Sprite),
485
+ * 简单可靠。Phase 4 引入 strategy 接口为后续 Mesh 路径预留位置:
486
+ * - sprite:Phase 1 MVP 路径,适合 < 4096 个 cell / chunk 数 < 30 的场景
487
+ * - mesh: 单 chunk 一次 draw call,适合 200×200 60% 填充等高密度
488
+ * - auto: 按 chunk 内非空 cell 数与 atlas 数动态选择
489
+ *
490
+ * 这里只 ship strategy interface + sprite 实现 + auto-pick 阈值;实际 PIXI.Mesh
491
+ * shader 在下一轮 cycle 落地(见 ADR-0018 §11 未决问题 2)。
492
+ */
493
+ /**
494
+ * 决定单个 chunk 走哪条渲染路径。
495
+ *
496
+ * 阈值来源于 ADR-0018 §10 风险登记册(Phase 4 P2-7 改造):
497
+ * - 单 chunk 非空 cell 数 >= 192 (75%) 且单 atlas → mesh
498
+ * - 多 atlas → sprite(等 texture-array 支持后再切 mesh)
499
+ * - 否则 sprite 简单稳定
500
+ */
501
+ function resolveChunkRenderStrategy(ctx) {
502
+ if (ctx.preference && ctx.preference !== 'auto')
503
+ return ctx.preference;
504
+ if (ctx.atlasesInChunk > 1)
505
+ return 'sprite';
506
+ if (ctx.nonEmptyCellsInChunk >= 192)
507
+ return 'mesh';
508
+ return 'sprite';
509
+ }
510
+ /**
511
+ * 估算单个 chunk 在 sprite 路径下的额外 PIXI 节点开销(用于 perf-probe)。
512
+ */
513
+ function estimateSpriteNodes(nonEmptyCellsInChunk) {
514
+ // 1 Container + N Sprites
515
+ return 1 + nonEmptyCellsInChunk;
516
+ }
517
+
518
+ /**
519
+ * ChunkMesh 渲染路径(Sprint C B1 — 框架已铺,shader 在 Cycle 2 落地)。
520
+ *
521
+ * Phase 4 真实 PIXI.Mesh + 自定义 vert/frag 在 PIXI v8 上需要 ProgramSource + GeometrySystem,
522
+ * 改动量较大,需要单独的 bench。本 cycle 先 ship 接口形态 + sprite-entity fallback,
523
+ * 让 ChunkRenderer 策略层可以选择"mesh-path",当 mesh 不可用时自动回退 sprite。
524
+ *
525
+ * 单一职责:
526
+ * - 描述 "一个 chunk 通过 Mesh 渲染" 的契约
527
+ * - 提供降级到 sprite 路径的 builder
528
+ *
529
+ * 真实 Mesh shader 实现见 ADR-0018 §Phase 4 — 在浏览器 PIXI WebGL 稳定后接入。
530
+ */
531
+ /**
532
+ * 创建一个 chunk 的 Mesh-path render container。
533
+ *
534
+ * 当前实现:返回 Container stub。Cycle 2 接入真实 PIXI.Mesh + 自定义 shader 时
535
+ * 替换内部实现即可,调用方接口不变(返回 Container,由 ChunkRenderer 加到 stage)。
536
+ *
537
+ * 接口稳定保证:
538
+ * - 返回 Container 必须有 children 数组(Container 默认即有)
539
+ * - 返回的 Container 在 destroy() 时正确释放纹理
540
+ */
541
+ function buildChunkMesh(_config) {
542
+ const container = new pixi_js.Container();
543
+ container.label = `chunk-mesh-stub-${_config.chunkKey}`;
544
+ // Mesh path 在 Cycle 2 实际接入。当前返回 empty container —— ChunkRenderer 会通过
545
+ // resolveChunkRenderStrategy 看到 mesh 不可用而降级到 sprite 路径。
546
+ return container;
547
+ }
548
+ /** 返回 true 表示当前环境支持 Mesh path(需要 WebGL2 + Program shader)。 */
549
+ function isMeshPathAvailable() {
550
+ // Cycle 2 落地后改为真实探测。当前一律 false,触发 sprite-entity 路径。
551
+ return false;
552
+ }
553
+ /**
554
+ * 估算单 chunk Mesh path 占用 GPU memory(单位 KB)。
555
+ * 用于 perf probes,Mesh path 上线后 budget 监控参考。
556
+ */
557
+ function estimateChunkMeshMemoryKB(config) {
558
+ // vertex buffer:每 cell 4 vertices × (xy + uv + flags) ≈ 32B/cell
559
+ // index buffer:6 indices/cell × 2B = 12B/cell
560
+ // cells = 256
561
+ const cellsBytes = 256 * (32 + 12);
562
+ return Math.ceil(cellsBytes / 1024);
563
+ }
98
564
 
99
565
  let TilemapSystem = class TilemapSystem extends pluginRenderer.Renderer {
100
566
  constructor() {
101
567
  super(...arguments);
102
568
  this.name = 'Tilemap';
103
569
  this.records = {};
570
+ /** Animation driver per Tilemap entity(只有 v2 路径有 animation,v1 不用)。 */
571
+ this.animationDrivers = new Map();
572
+ /**
573
+ * Perf probe sink (ADR-0013 + ADR-0018 §4.6).
574
+ * Resolved from `game.perfProbes` in init(); null when host did not install probes —
575
+ * all helper methods become no-ops, no overhead in production builds without perf.
576
+ */
577
+ this.probes = null;
578
+ /**
579
+ * T-N2 (Phase N):tab visibility / WebGL context loss state。
580
+ * - `isHidden`:document.visibilityState === 'hidden' 时为 true,update() early return
581
+ * - `contextLost`:webglcontextlost 事件触发为 true;webglcontextrestored 清回 false
582
+ * - listener 引用保留以便 destroy 时 detach(避免内存泄漏)
583
+ */
584
+ this.isHidden = false;
585
+ this.contextLost = false;
586
+ /** T-N2:已 install handlers 的 canvas 引用,destroy 时 detach 用。 */
587
+ this.canvasWithCtxListeners = null;
104
588
  }
105
589
  init() {
106
590
  this.renderSystem = this.game.getSystem(pluginRenderer.RendererSystem);
107
591
  this.renderSystem.rendererManager.register(this);
592
+ // Best-effort probe pickup; host may install perf hooks later (see ADR-0013).
593
+ this.probes = adaptGamePerfProbes(this.game);
594
+ // T-N2:浏览器环境下绑定 visibility / context-loss listener;
595
+ // node/jsdom 缺 document 时跳过(installVisibilityHandlers 自带早返)。
596
+ this.installVisibilityHandlers();
597
+ }
598
+ /**
599
+ * T-N2 (Phase N):安装 visibility / WebGL context-loss listener。
600
+ *
601
+ * 安装姿势:
602
+ * - visibility:`document.visibilitychange` → 更新 `isHidden`
603
+ * - context loss:`canvas.webglcontextlost` → 设 `contextLost=true` + probe;
604
+ * `canvas.webglcontextrestored` → 清回 false
605
+ *
606
+ * 失败容忍:任何环境异常(jsdom 不支持某些 listener / renderSystem 还没初始化 application)
607
+ * 都静默 catch,不让 init 因此崩溃。
608
+ */
609
+ installVisibilityHandlers() {
610
+ var _a, _b, _c;
611
+ if (typeof document === 'undefined')
612
+ return; // node / jsdom 跳过
613
+ try {
614
+ this.visibilityListener = () => {
615
+ this.isHidden = !!document.hidden;
616
+ };
617
+ document.addEventListener('visibilitychange', this.visibilityListener);
618
+ }
619
+ catch (_d) {
620
+ /* ignore — 某些 jsdom 环境的 addEventListener 可能行为异常 */
621
+ }
622
+ // WebGL context lost/restored — canvas 来自 renderSystem.application.canvas
623
+ try {
624
+ const canvas = (_c = (_b = (_a = this.renderSystem) === null || _a === void 0 ? void 0 : _a.application) === null || _b === void 0 ? void 0 : _b.canvas) !== null && _c !== void 0 ? _c : null;
625
+ if (canvas && typeof canvas.addEventListener === 'function') {
626
+ this.contextLostListener = () => {
627
+ this.contextLost = true;
628
+ this.probeCount(TILEMAP_PROBE_NAMES.CONTEXT_LOST_COUNT, 1);
629
+ if (typeof console !== 'undefined') {
630
+ console.warn('[Tilemap] WebGL context lost — pausing animation tick');
631
+ }
632
+ };
633
+ this.contextRestoredListener = () => {
634
+ this.contextLost = false;
635
+ if (typeof console !== 'undefined') {
636
+ console.info('[Tilemap] WebGL context restored — resuming animation tick');
637
+ }
638
+ // Host 决定是否 force component re-add 重建几何;plugin 不主动 rebuild,
639
+ // 仅清掉 contextLost flag 让 update tick 恢复。
640
+ };
641
+ canvas.addEventListener('webglcontextlost', this.contextLostListener);
642
+ canvas.addEventListener('webglcontextrestored', this.contextRestoredListener);
643
+ this.canvasWithCtxListeners = canvas;
644
+ }
645
+ }
646
+ catch (_e) {
647
+ /* ignore */
648
+ }
649
+ }
650
+ /**
651
+ * T-N2:卸载 visibility / context-loss listener,destroy() 调用以避免内存泄漏。
652
+ */
653
+ uninstallVisibilityHandlers() {
654
+ if (typeof document !== 'undefined' && this.visibilityListener) {
655
+ try {
656
+ document.removeEventListener('visibilitychange', this.visibilityListener);
657
+ }
658
+ catch (_a) {
659
+ /* ignore */
660
+ }
661
+ this.visibilityListener = undefined;
662
+ }
663
+ if (this.canvasWithCtxListeners) {
664
+ try {
665
+ if (this.contextLostListener) {
666
+ this.canvasWithCtxListeners.removeEventListener('webglcontextlost', this.contextLostListener);
667
+ }
668
+ if (this.contextRestoredListener) {
669
+ this.canvasWithCtxListeners.removeEventListener('webglcontextrestored', this.contextRestoredListener);
670
+ }
671
+ }
672
+ catch (_b) {
673
+ /* ignore */
674
+ }
675
+ this.contextLostListener = undefined;
676
+ this.contextRestoredListener = undefined;
677
+ this.canvasWithCtxListeners = null;
678
+ }
679
+ }
680
+ /** Late probe attach — host can call this after `installPerfProbes` if init order isn't right. */
681
+ attachPerfProbes(probes) {
682
+ this.probes = probes;
683
+ }
684
+ /**
685
+ * T-L7 (Phase L) public API:host 注入 viewport culling hint。
686
+ *
687
+ * - bounds 非 null:更新 record.cullingBoundsHint,下次 build 时 buildChunksForLayer 会
688
+ * 跳过不相交的 chunk。
689
+ * - null:清除 hint(等价于不剔除任何 chunk)。
690
+ *
691
+ * 本方法只更新字段,不强制重建。host 想立刻生效有两种姿势:
692
+ * 1. 调用后通过 `component.layersV2 = layersV2.slice()` 触发 observer rebuild
693
+ * 2. 调 `invalidateChunks` 标记 dirty 让 rAF flush 时按新 hint rebuild
694
+ * 这样 setHint 是 O(1),呼应性能预算。
695
+ */
696
+ setCullingBoundsHint(gameObjectId, bounds) {
697
+ const record = this.records[gameObjectId];
698
+ if (!record)
699
+ return;
700
+ if (bounds === null) {
701
+ record.cullingBoundsHint = undefined;
702
+ }
703
+ else {
704
+ record.cullingBoundsHint = bounds;
705
+ }
706
+ }
707
+ /**
708
+ * T-L4 (Phase L) public API:host 标记若干 chunk dirty,触发 rAF flush 增量重建。
709
+ *
710
+ * 触发场景:layer paint stroke 完成后 host 知道哪些 chunkKey 被改了,调本方法即可。
711
+ * 比 prop=layersV2 整体 reassign 高效得多(整 layer rebuild O(visibleChunks) → O(stroke)).
712
+ *
713
+ * 流程:
714
+ * 1. 把 chunkKeys 累加到 record.dirtyChunkKeys.get(layerId)
715
+ * 2. emit DIRTY_PENDING gauge(总 size)
716
+ * 3. 如果还没 scheduled,启动一次 rAF tick 在下一帧调 flushDirtyChunks
717
+ *
718
+ * 调用方应保证 chunkKey 形态与 cellData.chunks key 一致(如 "0,0")。未知 chunkKey
719
+ * 在 flush 时被静默忽略(已在 cellData.chunks 内会被处理;否则跳过)。
720
+ */
721
+ invalidateChunks(gameObjectId, layerId, chunkKeys) {
722
+ const record = this.records[gameObjectId];
723
+ if (!record || chunkKeys.length === 0)
724
+ return;
725
+ let bucket = record.dirtyChunkKeys.get(layerId);
726
+ if (!bucket) {
727
+ bucket = new Set();
728
+ record.dirtyChunkKeys.set(layerId, bucket);
729
+ }
730
+ for (const k of chunkKeys)
731
+ bucket.add(k);
732
+ // gauge:总 dirty chunk 数(跨 layer)
733
+ let total = 0;
734
+ for (const s of record.dirtyChunkKeys.values())
735
+ total += s.size;
736
+ this.probeGauge(TILEMAP_PROBE_NAMES.DIRTY_PENDING, total);
737
+ // schedule rAF flush(避免一帧内重复 schedule)
738
+ if (!record.flushScheduled) {
739
+ record.flushScheduled = true;
740
+ const sched = typeof requestAnimationFrame !== 'undefined'
741
+ ? requestAnimationFrame
742
+ : (cb) => setTimeout(() => cb(0), 16);
743
+ sched(() => this.flushDirtyChunks(gameObjectId));
744
+ }
745
+ }
746
+ /**
747
+ * T-L4 (Phase L):rAF callback;只 rebuild record.dirtyChunkKeys 内 chunk,不影响其他 chunk。
748
+ *
749
+ * 本方法是 public 供测试调用,但生产路径只走 invalidateChunks scheduled trigger。
750
+ * 行为:
751
+ * - 找到 component 的 layersV2 layer for layerId
752
+ * - 对每个 dirty chunkKey:destroy 旧 chunkContainer + 重新 populate
753
+ * - emit DIRTY_FRAMES_BEHIND counter+1(本帧 flush 了 N 个 chunk 即"延迟一帧")
754
+ * - 清空 dirtyChunkKeys + 把 DIRTY_PENDING gauge 归零
755
+ */
756
+ flushDirtyChunks(gameObjectId) {
757
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
758
+ const record = this.records[gameObjectId];
759
+ if (!record)
760
+ return;
761
+ record.flushScheduled = false;
762
+ if (record.dirtyChunkKeys.size === 0)
763
+ return;
764
+ if (record.mode !== 'v2' || !record.loadedTileset) {
765
+ // v1 / unknown 路径不支持增量 chunk rebuild;清空 dirty + skip
766
+ record.dirtyChunkKeys.clear();
767
+ this.probeGauge(TILEMAP_PROBE_NAMES.DIRTY_PENDING, 0);
768
+ return;
769
+ }
770
+ this.probeCount(TILEMAP_PROBE_NAMES.DIRTY_FRAMES_BEHIND, 1);
771
+ const loaded = record.loadedTileset;
772
+ const animDriver = (_a = this.animationDrivers.get(gameObjectId)) !== null && _a !== void 0 ? _a : null;
773
+ // lookup component 通过 game.gameObjects → 找 Tilemap component。
774
+ // 这里取巧:invalidateChunks 调用方应该已经写好 layersV2,我们从 layerContainersV2 反查
775
+ // chunkContainer,destroy + 重新调 populateChunkSprites。需要拿到 layer 配置(cellData/modulate
776
+ // /opacity)— 通过遍历 record.layerContainersV2 找到 chunkContainers 即可。但要 populate 还需
777
+ // cellData.chunks[chunkKey] — 必须找 component。
778
+ const game = this.game;
779
+ let component = null;
780
+ if (game === null || game === void 0 ? void 0 : game.gameObjects) {
781
+ for (const go of game.gameObjects) {
782
+ if (go.id !== gameObjectId)
783
+ continue;
784
+ const c = (_b = go.components) === null || _b === void 0 ? void 0 : _b.find((cc) => (cc === null || cc === void 0 ? void 0 : cc.name) === 'Tilemap');
785
+ if (c) {
786
+ component = c;
787
+ break;
788
+ }
789
+ }
790
+ }
791
+ const cellW = (_d = (_c = component === null || component === void 0 ? void 0 : component.cellSize) === null || _c === void 0 ? void 0 : _c.width) !== null && _d !== void 0 ? _d : loaded.tileWidth;
792
+ const cellH = (_f = (_e = component === null || component === void 0 ? void 0 : component.cellSize) === null || _e === void 0 ? void 0 : _e.height) !== null && _f !== void 0 ? _f : loaded.tileHeight;
793
+ const originX = (_h = (_g = component === null || component === void 0 ? void 0 : component.mapOrigin) === null || _g === void 0 ? void 0 : _g.x) !== null && _h !== void 0 ? _h : 0;
794
+ const originY = (_k = (_j = component === null || component === void 0 ? void 0 : component.mapOrigin) === null || _j === void 0 ? void 0 : _j.y) !== null && _k !== void 0 ? _k : 0;
795
+ this.probeBegin(TILEMAP_PROBE_NAMES.DIRTY_REBUILD_MS);
796
+ try {
797
+ for (const [layerId, chunkKeys] of record.dirtyChunkKeys) {
798
+ const layerEntry = record.layerContainersV2.get(layerId);
799
+ if (!layerEntry)
800
+ continue;
801
+ // 找 layer 配置(modulate/opacity/cellData)— 必须从 component.layersV2 找。
802
+ const layerSpec = (_m = (_l = component === null || component === void 0 ? void 0 : component.layersV2) === null || _l === void 0 ? void 0 : _l.find((l) => l.id === layerId)) !== null && _m !== void 0 ? _m : null;
803
+ if (!layerSpec)
804
+ continue;
805
+ for (const chunkKey of chunkKeys) {
806
+ const oldChunkContainer = layerEntry.chunkContainers.get(chunkKey);
807
+ if (oldChunkContainer) {
808
+ layerEntry.container.removeChild(oldChunkContainer);
809
+ try {
810
+ oldChunkContainer.destroy({ children: true });
811
+ }
812
+ catch (_q) {
813
+ /* ignore */
814
+ }
815
+ layerEntry.chunkContainers.delete(chunkKey);
816
+ }
817
+ // populate 新 chunk(如果 cellData 还包含该 key)
818
+ const blob = (_p = (_o = layerSpec.cellData) === null || _o === void 0 ? void 0 : _o.chunks) === null || _p === void 0 ? void 0 : _p[chunkKey];
819
+ if (!blob || blob.nonEmpty <= 0)
820
+ continue;
821
+ const chunkContainer = new pixi_js.Container();
822
+ chunkContainer.label = `chunk-${chunkKey}`;
823
+ this.populateChunkSprites(chunkContainer, chunkKey, blob, record, loaded, cellW, cellH, originX, originY, layerSpec, animDriver);
824
+ layerEntry.container.addChild(chunkContainer);
825
+ layerEntry.chunkContainers.set(chunkKey, chunkContainer);
826
+ }
827
+ }
828
+ }
829
+ finally {
830
+ this.probeEnd(TILEMAP_PROBE_NAMES.DIRTY_REBUILD_MS);
831
+ }
832
+ record.dirtyChunkKeys.clear();
833
+ this.probeGauge(TILEMAP_PROBE_NAMES.DIRTY_PENDING, 0);
834
+ this.requestRedraw();
835
+ }
836
+ probeBegin(name) {
837
+ var _a;
838
+ (_a = this.probes) === null || _a === void 0 ? void 0 : _a.beginTiming(name);
839
+ }
840
+ probeEnd(name) {
841
+ var _a;
842
+ (_a = this.probes) === null || _a === void 0 ? void 0 : _a.endTiming(name);
843
+ }
844
+ probeCount(name, delta = 1) {
845
+ var _a;
846
+ (_a = this.probes) === null || _a === void 0 ? void 0 : _a.count(name, delta);
847
+ }
848
+ probeGauge(name, value) {
849
+ var _a;
850
+ (_a = this.probes) === null || _a === void 0 ? void 0 : _a.gauge(name, value);
108
851
  }
109
852
  rendererUpdate(_gameObject) {
110
853
  // 静态 tilemap MVP:几何不随 transform.size 变化,无需逐帧刷新。
111
854
  }
855
+ /**
856
+ * Eva.js update 调用(每帧)— 推进 v2 path animation tick。
857
+ * 任何 tile 切到新 frame 时,标记对应 chunk dirty,下一帧重建。
858
+ * v1 路径不参与(没 animation 元数据)。
859
+ *
860
+ * T-N1 (Phase N) hardening:
861
+ * - 每个 driver advance 包在内层 try/catch:一个 entity 的 animation pipeline 抛错
862
+ * 不影响其他 entity 的 tick(continue 到下一个);失败计入 ANIM_TICK_ERROR_COUNT probe
863
+ * 并 console.error 留诊断。
864
+ * - 外层 try/finally 包整段 hot path:保证 probeEnd(ANIM_TICK_MS) 一定 pair probeBegin,
865
+ * 不会因为内层逻辑泄漏 throw 导致 timing 计 inFlight 永远不释放。
866
+ *
867
+ * T-N2 (Phase N) hardening:
868
+ * - tab hidden 或 WebGL context lost 时直接 early return,不浪费 CPU,且避免 context lost
869
+ * 期间渲染调用堆栈抛错。
870
+ */
871
+ update(_frame) {
872
+ // T-N2:tab 切换到后台 / WebGL context 异常 → 暂停 tick。
873
+ if (this.isHidden || this.contextLost)
874
+ return;
875
+ if (this.animationDrivers.size === 0)
876
+ return;
877
+ this.probeBegin(TILEMAP_PROBE_NAMES.ANIM_TICK_MS);
878
+ let totalSpritesSwapped = 0;
879
+ try {
880
+ const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0;
881
+ // PERF-T-O2 (see ADR-0022): consider flattening dirty (sprite, texture) pairs into a typed
882
+ // array once per record when animatedSpritesByAnimKey changes. Trigger: >1000 animated cells
883
+ // + p99(animTick.ms) > 2ms. Defer until probe data warrants.
884
+ for (const [gameObjectId, driver] of this.animationDrivers) {
885
+ const record = this.records[gameObjectId];
886
+ if (!record || record.mode !== 'v2')
887
+ continue;
888
+ try {
889
+ const result = driver.advance(now);
890
+ if (result.dirtyKeys.size === 0)
891
+ continue;
892
+ // For each dirty animation source (slot,col,row), look up every live Sprite that
893
+ // was painted with that source tile and swap its texture to the new frame.
894
+ // currentFrames maps source key → new (col,row) within the same source atlas.
895
+ const loaded = record.loadedTileset;
896
+ if (!loaded)
897
+ continue;
898
+ let anyApplied = false;
899
+ for (const animKey of result.dirtyKeys) {
900
+ const sprites = record.animatedSpritesByAnimKey.get(animKey);
901
+ if (!sprites || sprites.length === 0)
902
+ continue;
903
+ const nextFrame = result.currentFrames.get(animKey);
904
+ if (!nextFrame)
905
+ continue;
906
+ const parts = animKey.split(',');
907
+ const slot = Number.parseInt(parts[0], 10);
908
+ const newTex = this.getAtlasFrameTexture(record, loaded, {
909
+ sourceSlot: slot,
910
+ col: nextFrame.col,
911
+ row: nextFrame.row,
912
+ });
913
+ if (!newTex)
914
+ continue;
915
+ for (const sp of sprites) {
916
+ sp.texture = newTex;
917
+ anyApplied = true;
918
+ totalSpritesSwapped++;
919
+ }
920
+ }
921
+ if (anyApplied)
922
+ this.requestRedraw();
923
+ }
924
+ catch (e) {
925
+ // T-N1:本 entity 出错不破坏其他 entity 的 tick。
926
+ this.probeCount(TILEMAP_PROBE_NAMES.ANIM_TICK_ERROR_COUNT, 1);
927
+ if (typeof console !== 'undefined') {
928
+ console.error(`[Tilemap] animation tick failed for entity ${gameObjectId}`, e);
929
+ }
930
+ // continue to next driver
931
+ }
932
+ }
933
+ }
934
+ finally {
935
+ this.probeEnd(TILEMAP_PROBE_NAMES.ANIM_TICK_MS);
936
+ if (totalSpritesSwapped > 0)
937
+ this.probeCount('tilemap.anim.spritesSwapped', totalSpritesSwapped);
938
+ }
939
+ }
112
940
  componentChanged(changed) {
113
- var _a, _b, _c;
114
941
  return __awaiter(this, void 0, void 0, function* () {
115
942
  if (changed.componentName !== 'Tilemap')
116
943
  return;
117
944
  const component = changed.component;
118
945
  const gameObjectId = changed.gameObject.id;
119
946
  if (changed.type === eva_js.OBSERVER_TYPE.ADD) {
947
+ yield this.handleAdd(gameObjectId, changed.gameObject, component);
948
+ }
949
+ else if (changed.type === eva_js.OBSERVER_TYPE.CHANGE) {
950
+ yield this.handleChange(gameObjectId, component, changed);
951
+ }
952
+ else if (changed.type === eva_js.OBSERVER_TYPE.REMOVE) {
953
+ this.handleRemove(gameObjectId);
954
+ }
955
+ });
956
+ }
957
+ /* ───────────────────────────────────────────────────────────── lifecycle ── */
958
+ handleAdd(gameObjectId, gameObject, component) {
959
+ return __awaiter(this, void 0, void 0, function* () {
960
+ this.probeBegin(TILEMAP_PROBE_NAMES.DIRTY_REBUILD_MS);
961
+ try {
120
962
  const asyncId = this.increaseAsyncId(gameObjectId);
121
- let texture = null;
122
- if (component.tileset) {
123
- const { instance } = yield eva_js.resource.getResource(component.tileset);
124
- if (!this.validateAsyncId(gameObjectId, asyncId))
125
- return;
126
- if (!instance) {
127
- console.error(`GameObject:${changed.gameObject.name}'s Tilemap tileset load error`);
128
- return;
129
- }
130
- texture = instance;
131
- }
132
- if (!texture)
133
- return;
963
+ const mode = this.detectMode(component);
134
964
  const root = new pixi_js.Container();
135
- const container = this.containerManager.getContainer(gameObjectId);
136
- if (container)
137
- container.addChildAt(root, 0);
965
+ const containerHost = this.containerManager.getContainer(gameObjectId);
966
+ if (containerHost)
967
+ containerHost.addChildAt(root, 0);
138
968
  const record = {
139
969
  root,
140
970
  layerContainers: [],
971
+ layerContainersV2: new Map(),
141
972
  frameTextures: [],
142
- baseTexture: texture,
973
+ frameTexturesV2: new Map(),
974
+ baseTexture: null,
975
+ atlasTextures: new Map(),
976
+ loadedTileset: null,
977
+ mode,
978
+ animatedSpritesByAnimKey: new Map(),
979
+ sceneCollectionWarnedSourceIds: new Set(),
980
+ // T-L4 (Phase L):dirty chunk tracking,默认空 + 未 scheduled。
981
+ dirtyChunkKeys: new Map(),
982
+ flushScheduled: false,
143
983
  };
144
984
  this.records[gameObjectId] = record;
145
- this.buildLayers(record, component);
985
+ if (mode === 'v1') {
986
+ yield this.ensureV1Built(record, gameObjectId, gameObject, component, asyncId);
987
+ }
988
+ else if (mode === 'v2') {
989
+ yield this.ensureV2Built(record, gameObjectId, gameObject, component, asyncId);
990
+ }
991
+ // mode === 'unknown' → no-op (record sits empty, waiting for handleChange to upgrade).
146
992
  }
147
- else if (changed.type === eva_js.OBSERVER_TYPE.CHANGE) {
148
- const record = this.records[gameObjectId];
149
- if (!record)
993
+ finally {
994
+ this.probeEnd(TILEMAP_PROBE_NAMES.DIRTY_REBUILD_MS);
995
+ }
996
+ });
997
+ }
998
+ /**
999
+ * v1 path:load tileset texture + buildLayersV1。被 handleAdd 与 handleChange(mode upgrade)共用。
1000
+ *
1001
+ * 调用方负责:
1002
+ * - 在调用前设置 record.mode = 'v1'
1003
+ * - 在调用前 increaseAsyncId 拿到 asyncId
1004
+ * - record 在 records[gameObjectId] 已就位
1005
+ *
1006
+ * 本方法负责:
1007
+ * - resource.getResource(tileset)
1008
+ * - asyncId 校验(swap 期间 ref 被替换则中断)
1009
+ * - 错误日志
1010
+ * - 把 baseTexture 写回 record + buildLayersV1 + requestRedraw
1011
+ */
1012
+ ensureV1Built(record, gameObjectId, gameObject, component, asyncId) {
1013
+ return __awaiter(this, void 0, void 0, function* () {
1014
+ const texture = yield this.loadV1Texture(component);
1015
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1016
+ return;
1017
+ if (!texture) {
1018
+ if (typeof console !== 'undefined') {
1019
+ console.error(`GameObject:${gameObject.name}'s Tilemap tileset load error`);
1020
+ }
1021
+ return;
1022
+ }
1023
+ record.baseTexture = texture;
1024
+ this.buildLayersV1(record, component);
1025
+ this.requestRedraw(); // Editor preview ticker 在 edit 模式被冻结,资源加载完手动 trigger 一帧
1026
+ });
1027
+ }
1028
+ /**
1029
+ * v2 path:load tileset doc + atlas textures + animation driver + buildLayersV2。
1030
+ * 被 handleAdd 与 handleChange(mode upgrade / tilemapRef swap)共用。
1031
+ *
1032
+ * 调用方负责:
1033
+ * - 在调用前设置 record.mode = 'v2'
1034
+ * - 在调用前 increaseAsyncId 拿到 asyncId
1035
+ * - 在调用前已 tearDown 旧 mode 的产物(如果是 mode upgrade)
1036
+ */
1037
+ ensureV2Built(record, gameObjectId, gameObject, component, asyncId) {
1038
+ return __awaiter(this, void 0, void 0, function* () {
1039
+ const loaded = yield this.loadV2Tileset(component);
1040
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1041
+ return;
1042
+ if (!loaded) {
1043
+ if (typeof console !== 'undefined') {
1044
+ console.error(`GameObject:${gameObject.name}'s Tilemap tilemapRef load error`);
1045
+ }
1046
+ return;
1047
+ }
1048
+ record.loadedTileset = loaded;
1049
+ yield this.loadAtlasTextures(record, loaded);
1050
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1051
+ return;
1052
+ // Animation:为该 entity 创建独立 driver(per-entity 避免跨 tilemap phase 互相干扰)。
1053
+ // Driver must exist BEFORE buildLayersV2 so populate can register animated sprites.
1054
+ const animDriver = new TileAnimationDriver();
1055
+ animDriver.loadFromTileset(loaded.raw);
1056
+ const driverForBuild = animDriver.animationCount > 0 ? animDriver : null;
1057
+ if (driverForBuild)
1058
+ this.animationDrivers.set(gameObjectId, driverForBuild);
1059
+ this.buildLayersV2(record, component, driverForBuild);
1060
+ this.requestRedraw();
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Editor preview 的 PIXI ticker 在 edit 模式被冻结。Tilemap mount/hot-swap 完成时
1065
+ * 主动 trigger 一帧让 chunk 立即可见。preserveDrawingBuffer:true 后单次 render 即可
1066
+ * 定格(见 plugin-renderer/lib/System.ts createApplication 注释)。
1067
+ */
1068
+ requestRedraw() {
1069
+ var _a;
1070
+ try {
1071
+ const app = (_a = this.renderSystem) === null || _a === void 0 ? void 0 : _a.application;
1072
+ if ((app === null || app === void 0 ? void 0 : app.renderer) && app.stage) {
1073
+ app.renderer.render(app.stage);
1074
+ }
1075
+ }
1076
+ catch (e) {
1077
+ if (typeof console !== 'undefined') {
1078
+ console.warn('[Tilemap] requestRedraw failed', e);
1079
+ }
1080
+ }
1081
+ }
1082
+ handleChange(gameObjectId, component, changed) {
1083
+ var _a, _b;
1084
+ return __awaiter(this, void 0, void 0, function* () {
1085
+ const record = this.records[gameObjectId];
1086
+ if (!record)
1087
+ return;
1088
+ const prop = (_b = (_a = changed.prop) === null || _a === void 0 ? void 0 : _a.prop) === null || _b === void 0 ? void 0 : _b[0];
1089
+ this.probeBegin(TILEMAP_PROBE_NAMES.PATCH_APPLY_MS);
1090
+ try {
1091
+ // C-1 + T-L2(Phase L):mode upgrade / switch on live entity,带 fail-safe rollback。
1092
+ //
1093
+ // 触发场景:
1094
+ // - unknown → v1 (component 起初无 tileset/tilemapRef,后续 patch 上 tileset)
1095
+ // - unknown → v2 (同上,patch 上 tilemapRef)
1096
+ // - v1 → v2 (host 切换:tileset 清空 + tilemapRef 设值)
1097
+ // - v2 → v1 (反向)
1098
+ //
1099
+ // T-L2 修复:旧代码先 `record.mode = newMode` + 清空 baseTexture/loadedTileset 再 await load。
1100
+ // 如果 load 失败,record 卡在新 mode + 半建空白 → 之后 prop change 走错分支。
1101
+ // 新流程:load 失败时 record.mode → 'unknown',emit MODE_SWITCH_FAILED probe,host 看得见。
1102
+ const newMode = this.detectMode(component);
1103
+ if (newMode !== record.mode) {
1104
+ const asyncId = this.increaseAsyncId(gameObjectId);
1105
+ try {
1106
+ // Teardown 旧 mode 的产物(不可逆,但 host 已发 patch,旧 mode 不再展示是用户预期)
1107
+ if (record.mode === 'v1') {
1108
+ this.tearDownChildrenV1(record);
1109
+ }
1110
+ else if (record.mode === 'v2') {
1111
+ this.tearDownChildrenV2(record);
1112
+ this.animationDrivers.delete(gameObjectId);
1113
+ }
1114
+ // 清空跨 mode 共享字段(mode 暂不切,等 ensure resolve 再 commit)
1115
+ record.baseTexture = null;
1116
+ record.loadedTileset = null;
1117
+ if (newMode === 'v1') {
1118
+ const texture = yield this.loadV1Texture(component);
1119
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1120
+ return;
1121
+ if (!texture) {
1122
+ // mode-switch 失败:record 状态 → 'unknown',probe+1,host 可在 dashboard 看到。
1123
+ record.mode = 'unknown';
1124
+ this.probeCount(TILEMAP_PROBE_NAMES.MODE_SWITCH_FAILED_COUNT, 1);
1125
+ if (typeof console !== 'undefined') {
1126
+ console.error(`GameObject:${changed.gameObject.name}'s Tilemap mode-switch v→v1 failed (tileset load returned null)`);
1127
+ }
1128
+ return;
1129
+ }
1130
+ record.mode = 'v1';
1131
+ record.baseTexture = texture;
1132
+ this.buildLayersV1(record, component);
1133
+ this.requestRedraw();
1134
+ }
1135
+ else if (newMode === 'v2') {
1136
+ const loaded = yield this.loadV2Tileset(component);
1137
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1138
+ return;
1139
+ if (!loaded) {
1140
+ record.mode = 'unknown';
1141
+ this.probeCount(TILEMAP_PROBE_NAMES.MODE_SWITCH_FAILED_COUNT, 1);
1142
+ if (typeof console !== 'undefined') {
1143
+ console.error(`GameObject:${changed.gameObject.name}'s Tilemap mode-switch v→v2 failed (tilemapRef load returned null)`);
1144
+ }
1145
+ return;
1146
+ }
1147
+ yield this.loadAtlasTextures(record, loaded);
1148
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1149
+ return;
1150
+ // commit 推迟到这里
1151
+ record.mode = 'v2';
1152
+ record.loadedTileset = loaded;
1153
+ const animDriver = new TileAnimationDriver();
1154
+ animDriver.loadFromTileset(loaded.raw);
1155
+ const driverForBuild = animDriver.animationCount > 0 ? animDriver : null;
1156
+ if (driverForBuild)
1157
+ this.animationDrivers.set(gameObjectId, driverForBuild);
1158
+ this.buildLayersV2(record, component, driverForBuild);
1159
+ this.requestRedraw();
1160
+ }
1161
+ else {
1162
+ // newMode === 'unknown' → 干净 teardown 后保持 unknown
1163
+ record.mode = 'unknown';
1164
+ }
1165
+ }
1166
+ catch (e) {
1167
+ record.mode = 'unknown';
1168
+ this.probeCount(TILEMAP_PROBE_NAMES.MODE_SWITCH_FAILED_COUNT, 1);
1169
+ if (typeof console !== 'undefined') {
1170
+ console.error('[Tilemap] mode switch threw', e);
1171
+ }
1172
+ }
150
1173
  return;
151
- if (((_b = (_a = changed.prop) === null || _a === void 0 ? void 0 : _a.prop) === null || _b === void 0 ? void 0 : _b[0]) === 'tileset') {
1174
+ }
1175
+ // 同 mode 内 prop 替换(tileset 换图 / tilemapRef 换 ref / layersV2 整体 reassign)
1176
+ if (prop === 'tileset' && record.mode === 'v1') {
1177
+ const asyncId = this.increaseAsyncId(gameObjectId);
1178
+ const texture = yield this.loadV1Texture(component);
1179
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1180
+ return;
1181
+ if (!texture)
1182
+ return;
1183
+ this.tearDownChildrenV1(record);
1184
+ record.baseTexture = texture;
1185
+ this.buildLayersV1(record, component);
1186
+ this.requestRedraw();
1187
+ }
1188
+ else if (prop === 'tilemapRef' && record.mode === 'v2') {
152
1189
  const asyncId = this.increaseAsyncId(gameObjectId);
153
- const { instance } = yield eva_js.resource.getResource(component.tileset);
1190
+ const loaded = yield this.loadV2Tileset(component);
154
1191
  if (!this.validateAsyncId(gameObjectId, asyncId))
155
1192
  return;
156
- if (!instance)
1193
+ if (!loaded)
157
1194
  return;
158
- this.tearDownChildren(record);
159
- record.baseTexture = instance;
160
- this.buildLayers(record, component);
1195
+ this.tearDownChildrenV2(record);
1196
+ record.loadedTileset = loaded;
1197
+ yield this.loadAtlasTextures(record, loaded);
1198
+ if (!this.validateAsyncId(gameObjectId, asyncId))
1199
+ return;
1200
+ // Rebuild animation driver to match the new tileset (animations may have changed).
1201
+ this.animationDrivers.delete(gameObjectId);
1202
+ const animDriver = new TileAnimationDriver();
1203
+ animDriver.loadFromTileset(loaded.raw);
1204
+ const driverForBuild = animDriver.animationCount > 0 ? animDriver : null;
1205
+ if (driverForBuild)
1206
+ this.animationDrivers.set(gameObjectId, driverForBuild);
1207
+ this.buildLayersV2(record, component, driverForBuild);
1208
+ this.requestRedraw();
1209
+ }
1210
+ else if (prop === 'layersV2' && record.mode === 'v2') {
1211
+ // T-L1 (Phase L):layersV2 整体 ref reassign → 整 layer 重建。
1212
+ // 复用 loadedTileset + atlasTextures(它们没变),只 teardown sprite/chunk container
1213
+ // + animation driver(driver 来自 loadedTileset.raw 可重建)。
1214
+ // 整 layer rebuild 是浪费 — T-L4 提供 invalidateChunks 增量路径,
1215
+ // host 在 paint stroke 后用 invalidateChunks 而不是替换整个 layersV2。
1216
+ this.tearDownChildrenV2(record);
1217
+ this.animationDrivers.delete(gameObjectId);
1218
+ // 清空 dirtyChunkKeys:整 layer rebuild 后所有 chunk 都是 fresh
1219
+ record.dirtyChunkKeys.clear();
1220
+ const loaded = record.loadedTileset;
1221
+ let driverForBuild = null;
1222
+ if (loaded) {
1223
+ const animDriver = new TileAnimationDriver();
1224
+ animDriver.loadFromTileset(loaded.raw);
1225
+ if (animDriver.animationCount > 0) {
1226
+ driverForBuild = animDriver;
1227
+ this.animationDrivers.set(gameObjectId, animDriver);
1228
+ }
1229
+ }
1230
+ this.buildLayersV2(record, component, driverForBuild);
1231
+ this.requestRedraw();
161
1232
  }
162
1233
  }
163
- else if (changed.type === eva_js.OBSERVER_TYPE.REMOVE) {
164
- this.increaseAsyncId(gameObjectId);
165
- const record = this.records[gameObjectId];
166
- if (!record)
167
- return;
168
- this.tearDownChildren(record);
169
- const container = (_c = this.containerManager) === null || _c === void 0 ? void 0 : _c.getContainer(gameObjectId);
170
- if (container)
171
- container.removeChild(record.root);
172
- record.root.destroy({ children: true });
173
- delete this.records[gameObjectId];
1234
+ finally {
1235
+ this.probeEnd(TILEMAP_PROBE_NAMES.PATCH_APPLY_MS);
174
1236
  }
175
1237
  });
176
1238
  }
177
- /** 销毁现有 layer/frame 纹理(切换 tileset 或卸载时调用) */
178
- tearDownChildren(record) {
1239
+ handleRemove(gameObjectId) {
1240
+ var _a;
1241
+ this.increaseAsyncId(gameObjectId);
1242
+ const record = this.records[gameObjectId];
1243
+ if (!record)
1244
+ return;
1245
+ this.tearDownChildrenV1(record);
1246
+ this.tearDownChildrenV2(record);
1247
+ const containerHost = (_a = this.containerManager) === null || _a === void 0 ? void 0 : _a.getContainer(gameObjectId);
1248
+ if (containerHost)
1249
+ containerHost.removeChild(record.root);
1250
+ record.root.destroy({ children: true });
1251
+ delete this.records[gameObjectId];
1252
+ this.animationDrivers.delete(gameObjectId);
1253
+ // Re-emit live record count as a gauge so dashboards can see entity churn.
1254
+ this.probeGauge('tilemap.records.alive', Object.keys(this.records).length);
1255
+ }
1256
+ detectMode(component) {
1257
+ if (component.tilemapRef)
1258
+ return 'v2';
1259
+ if (component.tileset)
1260
+ return 'v1';
1261
+ return 'unknown';
1262
+ }
1263
+ /* ───────────────────────────────────────────────────── v1 Phaser-style ── */
1264
+ loadV1Texture(component) {
1265
+ var _a;
1266
+ return __awaiter(this, void 0, void 0, function* () {
1267
+ if (!component.tileset)
1268
+ return null;
1269
+ const { instance } = yield eva_js.resource.getResource(component.tileset);
1270
+ return (_a = instance) !== null && _a !== void 0 ? _a : null;
1271
+ });
1272
+ }
1273
+ tearDownChildrenV1(record) {
179
1274
  for (const layer of record.layerContainers) {
180
1275
  record.root.removeChild(layer);
181
1276
  layer.destroy({ children: true });
@@ -185,11 +1280,13 @@ let TilemapSystem = class TilemapSystem extends pluginRenderer.Renderer {
185
1280
  try {
186
1281
  tex.destroy(false);
187
1282
  }
188
- catch ( /* ignore */_a) { /* ignore */ }
1283
+ catch (_a) {
1284
+ /* ignore */
1285
+ }
189
1286
  }
190
1287
  record.frameTextures = [];
191
1288
  }
192
- buildLayers(record, component) {
1289
+ buildLayersV1(record, component) {
193
1290
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
194
1291
  const base = record.baseTexture;
195
1292
  if (!base)
@@ -200,20 +1297,16 @@ let TilemapSystem = class TilemapSystem extends pluginRenderer.Renderer {
200
1297
  const renderH = (_b = component.renderTileHeight) !== null && _b !== void 0 ? _b : tileH;
201
1298
  const margin = component.tilesetMargin || 0;
202
1299
  const spacing = component.tilesetSpacing || 0;
203
- // 推断 columns
204
1300
  const sourceWidth = (_j = (_g = (_f = (_d = (_c = base.orig) === null || _c === void 0 ? void 0 : _c.width) !== null && _d !== void 0 ? _d : (_e = base.source) === null || _e === void 0 ? void 0 : _e.width) !== null && _f !== void 0 ? _f : base.width) !== null && _g !== void 0 ? _g : (_h = base.frame) === null || _h === void 0 ? void 0 : _h.width) !== null && _j !== void 0 ? _j : 0;
205
1301
  const inferredCols = sourceWidth > 0 ? Math.max(1, Math.floor((sourceWidth - margin + spacing) / (tileW + spacing))) : 1;
206
1302
  const cols = component.tilesetColumns && component.tilesetColumns > 0 ? component.tilesetColumns : inferredCols;
207
- // 每次重建 frameTextures(以 tile id - 1 索引)。Phaser 中 id 0 = 空,真实 tile id 从 1 开始。
208
- // 我们提前不知道 layer 用了哪些 id,所以延迟切;为简单先一次性切前 cols * 推断行数 个。
209
- // 但避免无限切,这里只在 setTexture 时按需切。
210
- const tilesetCache = new Map();
1303
+ const cache = new Map();
211
1304
  const getFrameTexture = (tileId) => {
212
1305
  var _a, _b;
213
1306
  if (tileId <= 0)
214
1307
  return null;
215
1308
  const idx = tileId - 1;
216
- const cached = tilesetCache.get(idx);
1309
+ const cached = cache.get(idx);
217
1310
  if (cached)
218
1311
  return cached;
219
1312
  const col = idx % cols;
@@ -221,19 +1314,14 @@ let TilemapSystem = class TilemapSystem extends pluginRenderer.Renderer {
221
1314
  const x = margin + col * (tileW + spacing);
222
1315
  const y = margin + row * (tileH + spacing);
223
1316
  try {
224
- // PixiJS v8: 用源纹理 + frame Rectangle 创建子纹理
225
1317
  const source = (_b = (_a = base.source) !== null && _a !== void 0 ? _a : base.baseTexture) !== null && _b !== void 0 ? _b : base;
226
- const sub = new pixi_js.Texture({
227
- source,
228
- frame: new pixi_js.Rectangle(x, y, tileW, tileH),
229
- });
230
- tilesetCache.set(idx, sub);
1318
+ const sub = new pixi_js.Texture({ source, frame: new pixi_js.Rectangle(x, y, tileW, tileH) });
1319
+ cache.set(idx, sub);
231
1320
  record.frameTextures.push(sub);
232
1321
  return sub;
233
1322
  }
234
1323
  catch (e) {
235
1324
  if (typeof console !== 'undefined') {
236
- // eslint-disable-next-line no-console
237
1325
  console.warn('[Tilemap] Texture slice failed, falling back to base', e);
238
1326
  }
239
1327
  return base;
@@ -275,27 +1363,1253 @@ let TilemapSystem = class TilemapSystem extends pluginRenderer.Renderer {
275
1363
  record.layerContainers.push(layerContainer);
276
1364
  }
277
1365
  }
1366
+ /* ──────────────────────────────────────────────────── v2 chunked path ── */
1367
+ loadV2Tileset(component) {
1368
+ var _a, _b;
1369
+ return __awaiter(this, void 0, void 0, function* () {
1370
+ if (!component.tilemapRef)
1371
+ return null;
1372
+ const res = yield eva_js.resource.getResource(component.tilemapRef);
1373
+ if (!res)
1374
+ return null;
1375
+ const json = (_a = res.instance) !== null && _a !== void 0 ? _a : (_b = res.data) === null || _b === void 0 ? void 0 : _b.json;
1376
+ if (!json || json.kind !== 'tileset') {
1377
+ if (typeof console !== 'undefined') {
1378
+ console.error(`[Tilemap] resource '${component.tilemapRef}' is not a TileSet document`);
1379
+ }
1380
+ return null;
1381
+ }
1382
+ return makeLoadedTileset(json);
1383
+ });
1384
+ }
1385
+ loadAtlasTextures(record, loaded) {
1386
+ return __awaiter(this, void 0, void 0, function* () {
1387
+ const assetsToLoad = new Set();
1388
+ for (const src of loaded.sourcesBySlot) {
1389
+ if (src.kind === 'atlas' && src.textureAsset)
1390
+ assetsToLoad.add(src.textureAsset);
1391
+ }
1392
+ yield Promise.all(Array.from(assetsToLoad).map((assetId) => __awaiter(this, void 0, void 0, function* () {
1393
+ var _a, _b, _c;
1394
+ try {
1395
+ const res = yield eva_js.resource.getResource(assetId);
1396
+ const texture = (_c = (_a = res === null || res === void 0 ? void 0 : res.instance) !== null && _a !== void 0 ? _a : (_b = res === null || res === void 0 ? void 0 : res.data) === null || _b === void 0 ? void 0 : _b.image) !== null && _c !== void 0 ? _c : null;
1397
+ if (texture)
1398
+ record.atlasTextures.set(assetId, texture);
1399
+ }
1400
+ catch (e) {
1401
+ if (typeof console !== 'undefined') {
1402
+ console.warn(`[Tilemap] failed to load atlas asset '${assetId}'`, e);
1403
+ }
1404
+ }
1405
+ })));
1406
+ });
1407
+ }
1408
+ tearDownChildrenV2(record) {
1409
+ for (const { container } of record.layerContainersV2.values()) {
1410
+ record.root.removeChild(container);
1411
+ container.destroy({ children: true });
1412
+ }
1413
+ record.layerContainersV2.clear();
1414
+ for (const tex of record.frameTexturesV2.values()) {
1415
+ try {
1416
+ tex.destroy(false);
1417
+ }
1418
+ catch (_a) {
1419
+ /* ignore */
1420
+ }
1421
+ }
1422
+ record.frameTexturesV2.clear();
1423
+ // Live Sprite references are now invalid (destroyed by container.destroy above).
1424
+ record.animatedSpritesByAnimKey.clear();
1425
+ }
1426
+ buildLayersV2(record, component, animDriver) {
1427
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
1428
+ const loaded = record.loadedTileset;
1429
+ if (!loaded)
1430
+ return;
1431
+ const cellW = (_b = (_a = component.cellSize) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : loaded.tileWidth;
1432
+ const cellH = (_d = (_c = component.cellSize) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : loaded.tileHeight;
1433
+ const originX = (_f = (_e = component.mapOrigin) === null || _e === void 0 ? void 0 : _e.x) !== null && _f !== void 0 ? _f : 0;
1434
+ const originY = (_h = (_g = component.mapOrigin) === null || _g === void 0 ? void 0 : _g.y) !== null && _h !== void 0 ? _h : 0;
1435
+ const layers = (_j = component.layersV2) !== null && _j !== void 0 ? _j : [];
1436
+ // 按 zIndex 稳定排序
1437
+ const sorted = layers
1438
+ .map((l, i) => ({ layer: l, idx: i }))
1439
+ .sort((a, b) => { var _a, _b; return ((_a = a.layer.zIndex) !== null && _a !== void 0 ? _a : 0) - ((_b = b.layer.zIndex) !== null && _b !== void 0 ? _b : 0); });
1440
+ for (const { layer } of sorted) {
1441
+ if (layer.enabled === false)
1442
+ continue;
1443
+ const container = new pixi_js.Container();
1444
+ container.label = (_k = layer.name) !== null && _k !== void 0 ? _k : layer.id;
1445
+ container.alpha = (_l = layer.opacity) !== null && _l !== void 0 ? _l : 1;
1446
+ container.visible = layer.visible !== false;
1447
+ const chunkContainers = new Map();
1448
+ this.buildChunksForLayer(record, container, chunkContainers, layer, loaded, cellW, cellH, originX, originY, animDriver);
1449
+ record.root.addChild(container);
1450
+ record.layerContainersV2.set(layer.id, { container, chunkContainers });
1451
+ }
1452
+ }
1453
+ // PERF-T-O4 (see ADR-0022): consider lazy chunk building tied to cullingBoundsHint.
1454
+ // Chunks entering viewport: unbuilt → built. Leaving: built → evicted (destroy sprites,
1455
+ // keep metadata). Trigger: tilemap > 50×50 OR > 100 chunks total + DIRTY_REBUILD_MS p99
1456
+ // > 30ms. Needs a host writer for cullingBoundsHint first (T-L7 ship, no writer today).
1457
+ buildChunksForLayer(record, layerContainer, chunkContainers, layer, loaded, cellW, cellH, originX, originY, animDriver) {
1458
+ var _a;
1459
+ const cellData = layer.cellData;
1460
+ if (!cellData || !cellData.chunks)
1461
+ return;
1462
+ const visibleChunks = Object.entries(cellData.chunks).filter(([, b]) => b && b.nonEmpty > 0);
1463
+ const totalChunksVisible = visibleChunks.length;
1464
+ // C-2:viewport culling。
1465
+ //
1466
+ // 当 record.cullingBoundsHint 由 host 注入时,跳过任何 chunk 世界 bounds 与 hint 不相交的
1467
+ // chunk(不 build sprite container,但保留 chunkContainers slot 为空让后续 lazy build 接入)。
1468
+ // hint 未注入时 = 不剔除,行为兼容。
1469
+ //
1470
+ // 真正的 camera bounds 计算留下 cycle(参考 RendererSystem.application.renderer.view)。本 cycle
1471
+ // 只做 probe emission + API ready,实际剔除生效仅当 host 主动注入 hint。
1472
+ const cullingHint = (_a = record.cullingBoundsHint) !== null && _a !== void 0 ? _a : null;
1473
+ let culledCount = 0;
1474
+ this.probeBegin(TILEMAP_PROBE_NAMES.CULL_CHECK_MS);
1475
+ for (const [chunkKey, blob] of visibleChunks) {
1476
+ // Viewport culling check(per-chunk,O(1))。仅当 host 注入 hint 时启用。
1477
+ if (cullingHint) {
1478
+ const parts = chunkKey.split(',');
1479
+ const ckX = Number.parseInt(parts[0], 10);
1480
+ const ckY = Number.parseInt(parts[1], 10);
1481
+ const chunkMinX = originX + ckX * CHUNK_SIZE$2 * cellW;
1482
+ const chunkMinY = originY + ckY * CHUNK_SIZE$2 * cellH;
1483
+ const chunkMaxX = chunkMinX + CHUNK_SIZE$2 * cellW;
1484
+ const chunkMaxY = chunkMinY + CHUNK_SIZE$2 * cellH;
1485
+ const intersects = chunkMaxX > cullingHint.minX &&
1486
+ chunkMinX < cullingHint.maxX &&
1487
+ chunkMaxY > cullingHint.minY &&
1488
+ chunkMinY < cullingHint.maxY;
1489
+ if (!intersects) {
1490
+ culledCount++;
1491
+ continue;
1492
+ }
1493
+ }
1494
+ const chunkContainer = new pixi_js.Container();
1495
+ chunkContainer.label = `chunk-${chunkKey}`;
1496
+ // P1-3:每个 chunk 先咨询 ChunkRenderStrategy 选择渲染路径。
1497
+ // - sprite:populateChunkSprites(现有 MVP 路径,每非空 cell 一个 PIXI.Sprite)
1498
+ // - mesh: 尝试 buildChunkMesh(...)。当前 isMeshPathAvailable()===false,buildChunkMesh
1499
+ // 只返回 stub 空 Container,会被视为"mesh 不可用"自动降级 sprite。
1500
+ // 计算 atlasesInChunk 需要 decode 一次 chunk 数据,扫一遍 unique sourceSlot 的 atlas source 个数。
1501
+ //
1502
+ // C-4:复用 decoded Int32Array — populateChunkSprites 内部会再 decode 一次,把这里 decode
1503
+ // 结果透传过去避免重复工作(纯优化,行为兼容)。
1504
+ let atlasesInChunk = 1;
1505
+ let preDecoded = null;
1506
+ try {
1507
+ preDecoded = decodeChunk(blob);
1508
+ const seenAtlasSlots = new Set();
1509
+ for (let i = 0; i < preDecoded.length; i++) {
1510
+ const v = preDecoded[i];
1511
+ if (isEmptyCellValue(v))
1512
+ continue;
1513
+ const slot = v & 0xff;
1514
+ const src = loaded.sourcesBySlot[slot - 1];
1515
+ if (src && src.kind === 'atlas')
1516
+ seenAtlasSlots.add(slot);
1517
+ }
1518
+ atlasesInChunk = Math.max(1, seenAtlasSlots.size);
1519
+ }
1520
+ catch (_b) {
1521
+ // Decode failure 在 populateChunkSprites 内会再次报警并跳过,这里保留默认 1 让 strategy 继续走。
1522
+ atlasesInChunk = 1;
1523
+ preDecoded = null;
1524
+ }
1525
+ const strategy = resolveChunkRenderStrategy({
1526
+ nonEmptyCellsInChunk: blob.nonEmpty,
1527
+ atlasesInChunk,
1528
+ totalChunksVisible,
1529
+ // preference 未来从 layer/component config 透出;当前一律走 auto。
1530
+ });
1531
+ let dispatched = 'sprite';
1532
+ if (strategy === 'mesh') {
1533
+ if (this.tryBuildMeshChunk(chunkContainer, chunkKey, blob, loaded, cellW, cellH, originX, originY, layer)) {
1534
+ dispatched = 'mesh';
1535
+ }
1536
+ else {
1537
+ // mesh path 当前不可用(stub),降级 sprite + 记录 fallback probe。
1538
+ dispatched = 'meshFallback';
1539
+ this.probeCount(TILEMAP_PROBE_NAMES.STRATEGY_MESH_FALLBACK_COUNT, 1);
1540
+ this.populateChunkSprites(chunkContainer, chunkKey, blob, record, loaded, cellW, cellH, originX, originY, layer, animDriver, preDecoded);
1541
+ }
1542
+ }
1543
+ else {
1544
+ this.populateChunkSprites(chunkContainer, chunkKey, blob, record, loaded, cellW, cellH, originX, originY, layer, animDriver, preDecoded);
1545
+ }
1546
+ if (dispatched === 'sprite')
1547
+ this.probeCount(TILEMAP_PROBE_NAMES.STRATEGY_SPRITE_COUNT, 1);
1548
+ else if (dispatched === 'mesh')
1549
+ this.probeCount(TILEMAP_PROBE_NAMES.STRATEGY_MESH_COUNT, 1);
1550
+ layerContainer.addChild(chunkContainer);
1551
+ chunkContainers.set(chunkKey, chunkContainer);
1552
+ }
1553
+ this.probeEnd(TILEMAP_PROBE_NAMES.CULL_CHECK_MS);
1554
+ if (culledCount > 0)
1555
+ this.probeCount(TILEMAP_PROBE_NAMES.CULL_HITS_COUNT, culledCount);
1556
+ }
1557
+ /**
1558
+ * 尝试用 mesh path 渲染 chunk(P1-3)。
1559
+ *
1560
+ * 当前实现策略:
1561
+ * - 先检查 isMeshPathAvailable():当 false(本 cycle 默认),直接返回 false 让上层降级。
1562
+ * - 当 true(后续 cycle 接入真实 shader 时):调 buildChunkMesh,把结果 add 到 chunkContainer。
1563
+ *
1564
+ * 出错 / 返回空容器一律视为不可用,返回 false 让 buildChunksForLayer 走 sprite 路径。
1565
+ * 这样 "mesh path is dead code" 的局面变成 "mesh path 已 wired,降级有 probe 记录"。
1566
+ */
1567
+ tryBuildMeshChunk(chunkContainer, chunkKey, blob, loaded, cellW, cellH, _originX, _originY, _layer) {
1568
+ return false;
1569
+ }
1570
+ // PERF-T-O3 (see ADR-0022): consider per-Tilemap Sprite pool. On tearDownChildrenV2,
1571
+ // move sprites to pool instead of destroy({children: true}); pop from pool here before
1572
+ // `new Sprite`. Trigger: GC pause profile shows alloc churn dominates + > 5000 sprites
1573
+ // per rebuild cycle. Note: Mesh path (Phase 4) would sidestep this entirely.
1574
+ populateChunkSprites(chunkContainer, chunkKey, blob, record, loaded, cellW, cellH, originX, originY, layer, animDriver,
1575
+ /** C-4:caller(buildChunksForLayer)decode 后透传,可省一次 decodeChunk 调用。 */
1576
+ decoded) {
1577
+ let arr;
1578
+ if (decoded) {
1579
+ arr = decoded;
1580
+ }
1581
+ else {
1582
+ try {
1583
+ arr = decodeChunk(blob);
1584
+ }
1585
+ catch (e) {
1586
+ if (typeof console !== 'undefined') {
1587
+ console.warn(`[Tilemap] chunk decode failed for ${chunkKey}`, e);
1588
+ }
1589
+ return;
1590
+ }
1591
+ }
1592
+ const parts = chunkKey.split(',');
1593
+ const ckX = Number.parseInt(parts[0], 10);
1594
+ const ckY = Number.parseInt(parts[1], 10);
1595
+ const baseCx = ckX * CHUNK_SIZE$2;
1596
+ const baseCy = ckY * CHUNK_SIZE$2;
1597
+ const tintNum = parseHexTint(layer.modulate);
1598
+ let spritesInChunk = 0;
1599
+ for (let ly = 0; ly < CHUNK_SIZE$2; ly++) {
1600
+ for (let lx = 0; lx < CHUNK_SIZE$2; lx++) {
1601
+ const packed = arr[ly * CHUNK_SIZE$2 + lx];
1602
+ if (isEmptyCellValue(packed))
1603
+ continue;
1604
+ const cell = unpackCell(packed);
1605
+ const tex = this.getAtlasFrameTexture(record, loaded, cell);
1606
+ if (!tex)
1607
+ continue;
1608
+ const sprite = new pixi_js.Sprite(tex);
1609
+ spritesInChunk++;
1610
+ sprite.x = originX + (baseCx + lx) * cellW;
1611
+ sprite.y = originY + (baseCy + ly) * cellH;
1612
+ sprite.width = cellW;
1613
+ sprite.height = cellH;
1614
+ if (cell.flipH)
1615
+ sprite.scale.x = -Math.abs(sprite.scale.x || 1);
1616
+ if (cell.flipV)
1617
+ sprite.scale.y = -Math.abs(sprite.scale.y || 1);
1618
+ if (cell.transpose)
1619
+ sprite.rotation = Math.PI / 2;
1620
+ if (cell.flipH)
1621
+ sprite.x += cellW;
1622
+ if (cell.flipV)
1623
+ sprite.y += cellH;
1624
+ if (tintNum != null)
1625
+ sprite.tint = tintNum;
1626
+ chunkContainer.addChild(sprite);
1627
+ // Register sprite for live animation texture swap if this source tile is animated.
1628
+ if (animDriver && animDriver.isAnimatedSource(cell.sourceSlot, cell.col, cell.row)) {
1629
+ const animKey = `${cell.sourceSlot},${cell.col},${cell.row}`;
1630
+ let bucket = record.animatedSpritesByAnimKey.get(animKey);
1631
+ if (!bucket) {
1632
+ bucket = [];
1633
+ record.animatedSpritesByAnimKey.set(animKey, bucket);
1634
+ }
1635
+ bucket.push(sprite);
1636
+ }
1637
+ }
1638
+ }
1639
+ if (spritesInChunk > 0) {
1640
+ this.probeCount(TILEMAP_PROBE_NAMES.DRAWCALLS_COUNT, spritesInChunk);
1641
+ }
1642
+ }
1643
+ getAtlasFrameTexture(record, loaded, cell) {
1644
+ var _a, _b, _c, _d;
1645
+ const src = loaded.sourcesBySlot[cell.sourceSlot - 1];
1646
+ if (!src)
1647
+ return null;
1648
+ if (src.kind !== 'atlas') {
1649
+ // P1-4:遇到 sceneCollection(或其他非 atlas)source。当前 runtime 暂时不实例化 prefab,
1650
+ // 跳过该 cell 不渲染,但要让 host 看见这个 gap:
1651
+ // - 每个 record 内对同一 source 只 warn 一次(防 devtools 刷爆)
1652
+ // - 计数 probe 累加,host 可在 perf dashboard 上看到"static path 漏渲染 N 个 cell"
1653
+ // 真正的 prefab placeholder 渲染留下一轮 cycle(参考 expandSceneCollectionSource / resolveCellPrefabName)。
1654
+ if (src.kind === 'sceneCollection') {
1655
+ this.probeCount(TILEMAP_PROBE_NAMES.SCENECOLLECTION_SKIPPED_COUNT, 1);
1656
+ if (!record.sceneCollectionWarnedSourceIds.has(src.id)) {
1657
+ record.sceneCollectionWarnedSourceIds.add(src.id);
1658
+ if (typeof console !== 'undefined') {
1659
+ console.warn(`[Tilemap] sceneCollection source '${src.id}' cells are not yet rendered ` +
1660
+ `(prefab instantiation pending). Skipped cell at slot=${cell.sourceSlot} col=${cell.col} row=${cell.row}.`);
1661
+ }
1662
+ }
1663
+ }
1664
+ return null;
1665
+ }
1666
+ const atlas = record.atlasTextures.get(src.textureAsset);
1667
+ if (!atlas)
1668
+ return null;
1669
+ const margins = (_a = src.margins) !== null && _a !== void 0 ? _a : { x: 0, y: 0 };
1670
+ const sep = (_b = src.separation) !== null && _b !== void 0 ? _b : { x: 0, y: 0 };
1671
+ const fw = src.regionSize.width;
1672
+ const fh = src.regionSize.height;
1673
+ // T-N3 (Phase N):cache key 必须包含 regionSize / margins / separation —
1674
+ // 同一个 textureAsset 在多个 source 配置(不同切片大小或留白)下会有不同的 frame 坐标,
1675
+ // 旧的 `${textureAsset}#${col},${row}` 在 hot-swap tileset 后会 collision,误返回旧 frame。
1676
+ // 现在把切片几何参数都拼进 key,确保不同配置之间隔离。
1677
+ const cacheKey = `${src.textureAsset}#${fw}x${fh}|${margins.x},${margins.y}|${sep.x},${sep.y}#${cell.col},${cell.row}`;
1678
+ const cached = record.frameTexturesV2.get(cacheKey);
1679
+ if (cached)
1680
+ return cached;
1681
+ const fx = margins.x + cell.col * (fw + sep.x);
1682
+ const fy = margins.y + cell.row * (fh + sep.y);
1683
+ try {
1684
+ const source = (_d = (_c = atlas.source) !== null && _c !== void 0 ? _c : atlas.baseTexture) !== null && _d !== void 0 ? _d : atlas;
1685
+ const sub = new pixi_js.Texture({ source, frame: new pixi_js.Rectangle(fx, fy, fw, fh) });
1686
+ record.frameTexturesV2.set(cacheKey, sub);
1687
+ return sub;
1688
+ }
1689
+ catch (e) {
1690
+ if (typeof console !== 'undefined') {
1691
+ console.warn(`[Tilemap] frame slice failed for ${cacheKey}`, e);
1692
+ }
1693
+ return null;
1694
+ }
1695
+ }
278
1696
  destroy() {
279
1697
  var _a;
280
1698
  for (const key in this.records) {
281
1699
  const id = parseInt(key);
282
1700
  const record = this.records[id];
283
- this.tearDownChildren(record);
1701
+ this.tearDownChildrenV1(record);
1702
+ this.tearDownChildrenV2(record);
284
1703
  const container = (_a = this.containerManager) === null || _a === void 0 ? void 0 : _a.getContainer(id);
285
1704
  if (container)
286
1705
  container.removeChild(record.root);
287
1706
  record.root.destroy({ children: true });
288
1707
  delete this.records[id];
289
1708
  }
1709
+ // T-N2:解绑 visibility / context-loss listener,避免 system tear-down 后泄漏。
1710
+ this.uninstallVisibilityHandlers();
290
1711
  }
291
1712
  };
292
1713
  TilemapSystem.systemName = 'Tilemap';
293
1714
  TilemapSystem = __decorate([
294
1715
  eva_js.decorators.componentObserver({
295
- Tilemap: [{ prop: ['tileset'], deep: false }],
1716
+ Tilemap: [
1717
+ { prop: ['tileset'], deep: false },
1718
+ { prop: ['tilemapRef'], deep: false },
1719
+ // T-L1 (Phase L):watch layersV2 整体 ref 变化(浅观察)。
1720
+ // Host 通过 `component.layersV2 = layersV2.slice()` 或重新 assign 触发 rebuild。
1721
+ // 不用 deep:true:每个 chunk 编辑都触发 too 频繁(host 应用 batch + assign new array)。
1722
+ // 当前实现:T-L1 走整 layer rebuild(reuse loadedTileset + atlasTextures);
1723
+ // T-L4 增量 dirty chunk path 由 host 显式 invalidateChunks 触发,不走 observer。
1724
+ { prop: ['layersV2'], deep: false },
1725
+ ],
296
1726
  })
297
1727
  ], TilemapSystem);
298
- var TilemapSystem$1 = TilemapSystem;
1728
+ var TilemapSystem$1 = TilemapSystem;
1729
+ function parseHexTint(modulate) {
1730
+ if (!modulate)
1731
+ return undefined;
1732
+ const m = /^#?([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?$/.exec(modulate);
1733
+ if (!m)
1734
+ return undefined;
1735
+ return parseInt(m[1], 16);
1736
+ }
1737
+
1738
+ /**
1739
+ * Autotile peering-bit index (Phase 2).
1740
+ *
1741
+ * 在 TileSet load 时构建,把每个 alt tile 的 peering bits 编进 hash key
1742
+ * 实现 O(1) 邻居匹配。
1743
+ *
1744
+ * 数据模型:
1745
+ * - 一个 terrainSet 有最多 8 个方向(Godot corners+sides),每个方向上当前
1746
+ * 邻居的 terrain id 取值 0..255。
1747
+ * - 把 8 个方向打包成一个 BigInt 作 exact-match key:每 byte 一个方向。
1748
+ * - 通配 (`undefined` neighbor) 用 0xff 哨兵表示;查询时构造 actual neighborhood
1749
+ * 也用 0xff 表示"无邻居 / 空 cell"。
1750
+ * - exact 命中优先;否则在 wildcard 桶里按 Hamming 距离打分,取最小者(同分则
1751
+ * 按 probability 加权随机 - 注意此处仅返回候选,不做 RNG)。
1752
+ *
1753
+ * 这里只暴露纯函数 + 数据结构,RNG/decision 留给 AutotileCommand 调用方。
1754
+ */
1755
+ const NEIGHBOR_DIRECTIONS = [
1756
+ "topLeft",
1757
+ "top",
1758
+ "topRight",
1759
+ "right",
1760
+ "bottomRight",
1761
+ "bottom",
1762
+ "bottomLeft",
1763
+ "left",
1764
+ ];
1765
+ /**
1766
+ * 4-bit-per-direction × 8 directions = 32-bit number 作 hash key。
1767
+ * terrain id 范围 0..14;15 (0xf) 作 NO_NEIGHBOR / wildcard 哨兵。
1768
+ * v1 限制最多 15 个 terrain per terrain set,足够 Godot 47-tile Wang 集。
1769
+ */
1770
+ const NO_NEIGHBOR = 0xf;
1771
+ const MAX_TERRAIN_ID = 14;
1772
+ /** 8 dirs × 4 bits = 32-bit number key. terrain id 限制 0..14。 */
1773
+ function packBits(values) {
1774
+ var _a;
1775
+ let key = 0;
1776
+ for (let i = 0; i < 8; i++) {
1777
+ const v = ((_a = values[i]) !== null && _a !== void 0 ? _a : NO_NEIGHBOR) & 0xf;
1778
+ key |= v << (i * 4);
1779
+ }
1780
+ return key >>> 0;
1781
+ }
1782
+ function readNibble(key, slot) {
1783
+ return (key >>> (slot * 4)) & 0xf;
1784
+ }
1785
+ /**
1786
+ * Build the index from a TileSet's terrainSet candidates.
1787
+ *
1788
+ * `candidates` is the flat list of all (sourceSlot, col, row, altIdx) belonging
1789
+ * to the given terrainSet, each with optional peeringBits + probability.
1790
+ */
1791
+ class PeeringBitIndex {
1792
+ constructor(terrainSetIndex) {
1793
+ this.terrainSetIndex = terrainSetIndex;
1794
+ this.exact = new Map();
1795
+ this.wildcards = [];
1796
+ }
1797
+ add(candidate, bits) {
1798
+ var _a;
1799
+ const present = [];
1800
+ const mask = [];
1801
+ for (let i = 0; i < 8; i++) {
1802
+ const dir = NEIGHBOR_DIRECTIONS[i];
1803
+ const v = bits === null || bits === void 0 ? void 0 : bits[dir];
1804
+ if (v === undefined) {
1805
+ present.push(NO_NEIGHBOR);
1806
+ mask.push(0); // wildcard nibble
1807
+ }
1808
+ else {
1809
+ present.push(v & 0xf);
1810
+ mask.push(0xf); // exact nibble required
1811
+ }
1812
+ }
1813
+ const key = packBits(present);
1814
+ const maskKey = packBits(mask);
1815
+ const isFullExact = mask.every((m) => m === 0xf);
1816
+ if (isFullExact) {
1817
+ const list = (_a = this.exact.get(key)) !== null && _a !== void 0 ? _a : [];
1818
+ list.push(candidate);
1819
+ this.exact.set(key, list);
1820
+ }
1821
+ else {
1822
+ this.wildcards.push({ key, mask: maskKey, candidate });
1823
+ }
1824
+ }
1825
+ /**
1826
+ * Match an `actual` neighborhood:8 byte values, NO_NEIGHBOR for "empty".
1827
+ *
1828
+ * Returns the set of best-matching candidates (Hamming distance 0 = exact).
1829
+ * If no candidate matches at any distance, returns empty list.
1830
+ */
1831
+ match(actual) {
1832
+ const key = packBits(actual);
1833
+ const exact = this.exact.get(key);
1834
+ if (exact && exact.length > 0) {
1835
+ return { candidates: exact.slice(), exactMatch: true, bestHammingDistance: 0 };
1836
+ }
1837
+ let bestDist = Infinity;
1838
+ let pool = [];
1839
+ for (const { key: candKey, mask, candidate } of this.wildcards) {
1840
+ let dist = 0;
1841
+ let viable = true;
1842
+ for (let slot = 0; slot < 8; slot++) {
1843
+ const m = readNibble(mask, slot);
1844
+ if (m === 0)
1845
+ continue; // wildcard slot
1846
+ const a = readNibble(key, slot);
1847
+ const b = readNibble(candKey, slot);
1848
+ if (a !== b) {
1849
+ dist++;
1850
+ if (dist > bestDist) {
1851
+ viable = false;
1852
+ break;
1853
+ }
1854
+ }
1855
+ }
1856
+ if (!viable)
1857
+ continue;
1858
+ if (dist < bestDist) {
1859
+ bestDist = dist;
1860
+ pool = [candidate];
1861
+ }
1862
+ else if (dist === bestDist) {
1863
+ pool.push(candidate);
1864
+ }
1865
+ }
1866
+ // Also consider exact buckets with masked dirs - matters when the actual
1867
+ // has NO_NEIGHBOR slots that the exact entries can satisfy via wildcards.
1868
+ // (Not needed in v1 — exact entries by definition have no wildcards.)
1869
+ return { candidates: pool, exactMatch: false, bestHammingDistance: bestDist === Infinity ? -1 : bestDist };
1870
+ }
1871
+ get exactBucketCount() {
1872
+ return this.exact.size;
1873
+ }
1874
+ get wildcardCount() {
1875
+ return this.wildcards.length;
1876
+ }
1877
+ }
1878
+ /**
1879
+ * Probability-weighted picker — deterministic when seed is provided.
1880
+ */
1881
+ function pickAutotileCandidate(candidates, rng) {
1882
+ if (candidates.length === 0)
1883
+ return null;
1884
+ if (candidates.length === 1)
1885
+ return candidates[0];
1886
+ const totalProb = candidates.reduce((sum, c) => sum + (c.probability > 0 ? c.probability : 1), 0);
1887
+ const t = rng() * totalProb;
1888
+ let acc = 0;
1889
+ for (const c of candidates) {
1890
+ acc += c.probability > 0 ? c.probability : 1;
1891
+ if (t < acc)
1892
+ return c;
1893
+ }
1894
+ return candidates[candidates.length - 1];
1895
+ }
1896
+
1897
+ /**
1898
+ * Tile mesh shader 源码(G3)。
1899
+ *
1900
+ * PIXI v8 用 ProgramSource + Geometry,程序员手工写 GLSL 即可。这里 vert/frag
1901
+ * 走 packed vertex attribute,flip/transpose 在 vertex 阶段通过 mat2 完成。
1902
+ *
1903
+ * Attribute 布局(每 vertex 32 bytes):
1904
+ * aPosition vec2 (8B) cell 左上 + 局部偏移
1905
+ * aTexCoord vec2 (8B) atlas UV 0..1
1906
+ * aFlags vec4 (16B) [flipH, flipV, transpose, animPhase]
1907
+ */
1908
+ const TILE_VERT_SHADER = `#version 300 es
1909
+ precision highp float;
1910
+
1911
+ in vec2 aPosition;
1912
+ in vec2 aTexCoord;
1913
+ in vec4 aFlags;
1914
+
1915
+ uniform mat3 uProjectionMatrix;
1916
+ uniform mat3 uWorldTransformMatrix;
1917
+
1918
+ out vec2 vTexCoord;
1919
+
1920
+ void main() {
1921
+ vec3 worldPos = uWorldTransformMatrix * vec3(aPosition, 1.0);
1922
+ gl_Position = vec4((uProjectionMatrix * worldPos).xy, 0.0, 1.0);
1923
+
1924
+ vec2 uv = aTexCoord;
1925
+ if (aFlags.x > 0.5) uv.x = 1.0 - uv.x;
1926
+ if (aFlags.y > 0.5) uv.y = 1.0 - uv.y;
1927
+ if (aFlags.z > 0.5) { float t = uv.x; uv.x = uv.y; uv.y = t; }
1928
+ vTexCoord = uv;
1929
+ }
1930
+ `;
1931
+ const TILE_FRAG_SHADER = `#version 300 es
1932
+ precision highp float;
1933
+
1934
+ in vec2 vTexCoord;
1935
+ out vec4 fragColor;
1936
+
1937
+ uniform sampler2D uTexture;
1938
+ uniform vec4 uModulate;
1939
+
1940
+ void main() {
1941
+ vec4 sampled = texture(uTexture, vTexCoord);
1942
+ fragColor = sampled * uModulate;
1943
+ if (fragColor.a < 0.01) discard;
1944
+ }
1945
+ `;
1946
+ const TILE_SHADER_SOURCES = {
1947
+ vertex: TILE_VERT_SHADER,
1948
+ fragment: TILE_FRAG_SHADER,
1949
+ };
1950
+
1951
+ /**
1952
+ * Build packed vertex/index buffers for a chunk mesh(G3)。
1953
+ *
1954
+ * 输入:Int32Array(256 packed cells)+ atlas region info。
1955
+ * 输出:Float32Array (vertex data) + Uint16Array (indices)。
1956
+ *
1957
+ * 每非空 cell 4 vertices × 8 floats(2 pos + 2 uv + 4 flags) = 32 floats = 128B。
1958
+ * 6 indices/quad × 2B = 12B index。
1959
+ */
1960
+ const CHUNK_SIZE$1 = 16;
1961
+ const VERTS_PER_QUAD = 4;
1962
+ const FLOATS_PER_VERT = 8;
1963
+ /**
1964
+ * Build geometry for a chunk。chunkX/Y 是 chunk 在世界坐标的左上(已含 mapOrigin)。
1965
+ */
1966
+ function buildChunkGeometry(args) {
1967
+ const { cells, chunkWorldX, chunkWorldY, cellWidth, cellHeight, atlasInfo } = args;
1968
+ if (cells.length !== CHUNK_SIZE$1 * CHUNK_SIZE$1) {
1969
+ throw new Error(`buildChunkGeometry: cells length ${cells.length} != ${CHUNK_SIZE$1 * CHUNK_SIZE$1}`);
1970
+ }
1971
+ let nonEmpty = 0;
1972
+ for (let i = 0; i < cells.length; i++)
1973
+ if (!isEmptyCellValue(cells[i]))
1974
+ nonEmpty++;
1975
+ const vertexData = new Float32Array(nonEmpty * VERTS_PER_QUAD * FLOATS_PER_VERT);
1976
+ const indices = new Uint16Array(nonEmpty * 6);
1977
+ let v = 0;
1978
+ let i = 0;
1979
+ let quadIdx = 0;
1980
+ for (let ly = 0; ly < CHUNK_SIZE$1; ly++) {
1981
+ for (let lx = 0; lx < CHUNK_SIZE$1; lx++) {
1982
+ const packed = cells[ly * CHUNK_SIZE$1 + lx];
1983
+ if (isEmptyCellValue(packed))
1984
+ continue;
1985
+ const cell = unpackCell(packed);
1986
+ const x0 = chunkWorldX + lx * cellWidth;
1987
+ const y0 = chunkWorldY + ly * cellHeight;
1988
+ const x1 = x0 + cellWidth;
1989
+ const y1 = y0 + cellHeight;
1990
+ const u0 = (atlasInfo.margins.x + cell.col * (atlasInfo.regionWidth + atlasInfo.separation.x)) / atlasInfo.textureWidth;
1991
+ const u1 = u0 + atlasInfo.regionWidth / atlasInfo.textureWidth;
1992
+ const v0 = (atlasInfo.margins.y + cell.row * (atlasInfo.regionHeight + atlasInfo.separation.y)) / atlasInfo.textureHeight;
1993
+ const v1 = v0 + atlasInfo.regionHeight / atlasInfo.textureHeight;
1994
+ const fh = cell.flipH ? 1 : 0;
1995
+ const fv = cell.flipV ? 1 : 0;
1996
+ const tr = cell.transpose ? 1 : 0;
1997
+ const animPhase = 0;
1998
+ // 4 vertices (TL, TR, BR, BL)
1999
+ const writeVert = (x, y, u, vv) => {
2000
+ vertexData[v++] = x;
2001
+ vertexData[v++] = y;
2002
+ vertexData[v++] = u;
2003
+ vertexData[v++] = vv;
2004
+ vertexData[v++] = fh;
2005
+ vertexData[v++] = fv;
2006
+ vertexData[v++] = tr;
2007
+ vertexData[v++] = animPhase;
2008
+ };
2009
+ writeVert(x0, y0, u0, v0);
2010
+ writeVert(x1, y0, u1, v0);
2011
+ writeVert(x1, y1, u1, v1);
2012
+ writeVert(x0, y1, u0, v1);
2013
+ const base = quadIdx * VERTS_PER_QUAD;
2014
+ indices[i++] = base;
2015
+ indices[i++] = base + 1;
2016
+ indices[i++] = base + 2;
2017
+ indices[i++] = base;
2018
+ indices[i++] = base + 2;
2019
+ indices[i++] = base + 3;
2020
+ quadIdx++;
2021
+ }
2022
+ }
2023
+ return { vertexData, indices, quadCount: quadIdx };
2024
+ }
2025
+
2026
+ /**
2027
+ * TileSet hot reload(H1)。
2028
+ *
2029
+ * dev mode 用文件 watcher 监听 `.tileset.json` 变化,变化时:
2030
+ * 1. 重新 parse JSON → 新 TilesetDocumentRaw
2031
+ * 2. diff 与旧版的 sources / tiles
2032
+ * 3. 只 mark **受影响 cell 所在 chunk** dirty,不 rebuild 整 layer
2033
+ *
2034
+ * 本文件只 ship diff 与计算 affected cells 的纯函数。host(dev server 或 VSCode
2035
+ * extension)负责文件 watch + 把新 raw 通过 resource.reload() 喂给运行时,运行时
2036
+ * 调 `diffTilesetForChunkRebuild` + `computeAffectedChunks` 获取要 rebuild 的
2037
+ * chunk set。
2038
+ *
2039
+ * ## 职责边界:source/tile-level coarse diff(plugin 内部 SSE pipeline 用)
2040
+ *
2041
+ * 这里的 `diffTilesetForChunkRebuild` 是 **chunk-rebuild 决策粒度** 的 diff:
2042
+ * 只关心 "哪些 source 增删改 / 哪些 (col,row) tile key 变了",输出直接喂
2043
+ * `computeAffectedChunks` 计算受影响 chunk set。它故意不下沉到 alternatives /
2044
+ * animation / customData / physics / terrain 字段级别 — 任意一个变了对 chunk
2045
+ * rebuild 的结论都一样(就是 rebuild)。
2046
+ *
2047
+ * 如果消费方是 **host UX 层**(显示 schema-level changes for AI revision / git-
2048
+ * style preview / inspector diff 提示),需要的是 document-level detailed diff
2049
+ * — 见 `@ali/eva-dsl` 的 `diffTilesetDocuments`(`libs/dsl/src/editor/tileset-
2050
+ * diff.ts`),它会按 alternatives / animation / customData / physics / terrain
2051
+ * 分桶报告每个 tile 的具体变化。
2052
+ *
2053
+ * 同名警告:历史上本文件的 `diffTilesetDocuments` 与 dsl 端同名但职责不同,
2054
+ * 已改名为 `diffTilesetForChunkRebuild`;旧名保留 alias 供过渡。
2055
+ */
2056
+ /**
2057
+ * 计算两个 TilesetDocumentRaw 之间的 source/tile-level coarse diff,供
2058
+ * `computeAffectedChunks` 决定哪些 chunk 要 rebuild。
2059
+ *
2060
+ * 不要与 `@ali/eva-dsl` 的 `diffTilesetDocuments` 混淆 — 后者是 document-level
2061
+ * detailed diff(per alternative / animation / customData / physics / terrain),
2062
+ * 用于 host UX 显示,不是 chunk-rebuild 决策。
2063
+ */
2064
+ function diffTilesetForChunkRebuild(prev, next) {
2065
+ const prevSources = new Map();
2066
+ const nextSources = new Map();
2067
+ for (const s of prev.sources)
2068
+ prevSources.set(s.id, s);
2069
+ for (const s of next.sources)
2070
+ nextSources.set(s.id, s);
2071
+ const addedSources = [];
2072
+ const removedSourceIds = [];
2073
+ const changedSourceIds = [];
2074
+ for (const id of nextSources.keys()) {
2075
+ if (!prevSources.has(id))
2076
+ addedSources.push(nextSources.get(id));
2077
+ else if (JSON.stringify(prevSources.get(id)) !== JSON.stringify(nextSources.get(id)))
2078
+ changedSourceIds.push(id);
2079
+ }
2080
+ for (const id of prevSources.keys()) {
2081
+ if (!nextSources.has(id))
2082
+ removedSourceIds.push(id);
2083
+ }
2084
+ const addedTilesByKey = new Set();
2085
+ const removedTilesByKey = new Set();
2086
+ const changedTilesByKey = new Set();
2087
+ const indexTiles = (src) => {
2088
+ const m = new Map();
2089
+ if (src.kind !== "atlas")
2090
+ return m;
2091
+ for (const t of src.tiles)
2092
+ m.set(`${src.id}/${t.atlasCoords.col},${t.atlasCoords.row}`, t);
2093
+ return m;
2094
+ };
2095
+ for (const [id, src] of nextSources) {
2096
+ const prevSrc = prevSources.get(id);
2097
+ if (!prevSrc) {
2098
+ const tiles = indexTiles(src);
2099
+ for (const k of tiles.keys())
2100
+ addedTilesByKey.add(k);
2101
+ continue;
2102
+ }
2103
+ const a = indexTiles(prevSrc);
2104
+ const b = indexTiles(src);
2105
+ for (const k of b.keys()) {
2106
+ if (!a.has(k))
2107
+ addedTilesByKey.add(k);
2108
+ else if (JSON.stringify(a.get(k)) !== JSON.stringify(b.get(k)))
2109
+ changedTilesByKey.add(k);
2110
+ }
2111
+ for (const k of a.keys()) {
2112
+ if (!b.has(k))
2113
+ removedTilesByKey.add(k);
2114
+ }
2115
+ }
2116
+ for (const id of removedSourceIds) {
2117
+ const a = indexTiles(prevSources.get(id));
2118
+ for (const k of a.keys())
2119
+ removedTilesByKey.add(k);
2120
+ }
2121
+ return { addedSources, removedSourceIds, changedSourceIds, addedTilesByKey, removedTilesByKey, changedTilesByKey };
2122
+ }
2123
+ /**
2124
+ * @deprecated Use `diffTilesetForChunkRebuild`. Alias kept for transitional
2125
+ * compatibility — the name collided with `@ali/eva-dsl`'s `diffTilesetDocuments`
2126
+ * (document-level detailed diff for host UX), which has different semantics.
2127
+ */
2128
+ const diffTilesetDocuments = diffTilesetForChunkRebuild;
2129
+ /**
2130
+ * 给定 diff 结果 + 文档 chunks(`chunkKey → Int32Array`),返回需要 rebuild 的 chunk key set。
2131
+ *
2132
+ * 算法:遍历所有 chunks,对每个非空 cell,如果它引用的 atlas (sourceSlot, col, row) 在 changed
2133
+ * /removed/added tile set 里,标记该 chunk dirty。
2134
+ */
2135
+ function computeAffectedChunks(chunks, sourceIdBySlot, diff) {
2136
+ const dirty = new Set();
2137
+ const cellKeyMatches = (cellSlot, col, row) => {
2138
+ const sourceId = sourceIdBySlot.get(cellSlot);
2139
+ if (!sourceId)
2140
+ return false;
2141
+ const k = `${sourceId}/${col},${row}`;
2142
+ return diff.changedTilesByKey.has(k) || diff.removedTilesByKey.has(k) || diff.addedTilesByKey.has(k);
2143
+ };
2144
+ for (const [chunkKey, arr] of Object.entries(chunks)) {
2145
+ for (let i = 0; i < arr.length; i++) {
2146
+ const packed = arr[i];
2147
+ const slot = packed & 0xff;
2148
+ if (slot === 0)
2149
+ continue;
2150
+ const col = (packed >>> 8) & 0xff;
2151
+ const row = (packed >>> 16) & 0xff;
2152
+ if (cellKeyMatches(slot, col, row)) {
2153
+ dirty.add(chunkKey);
2154
+ break;
2155
+ }
2156
+ }
2157
+ }
2158
+ return dirty;
2159
+ }
2160
+
2161
+ /**
2162
+ * SceneCollection source(H2)。
2163
+ *
2164
+ * 与 atlas source 并列;一个 sceneCollection source 的 tile 对应一个 prefab name,
2165
+ * runtime 在 buildLayer 时不 instantiate sprite,而是 instantiate prefab(走 host 注入
2166
+ * 的 prefab factory)。
2167
+ *
2168
+ * 本文件只 ship 解析 helper + 实例化接口,真实 prefab instantiation 留给 host
2169
+ * (DSLRenderer 通常已经有 prefab → GameObject 的 builder)。
2170
+ */
2171
+ /**
2172
+ * 把 sceneCollection source 展开成 prefab ref 数组。
2173
+ */
2174
+ function expandSceneCollectionSource(src) {
2175
+ if (src.kind !== "sceneCollection")
2176
+ return [];
2177
+ return src.prefabRefs.map((p, i) => ({ sourceId: src.id, prefabName: p, index: i }));
2178
+ }
2179
+ /**
2180
+ * 给定 packed cell + source 列表,查这个 cell 对应的 prefab name(如果是 scene-collection
2181
+ * source);atlas source 返回 null。
2182
+ */
2183
+ function resolveCellPrefabName(packed, sourcesBySlot) {
2184
+ var _a;
2185
+ const slot = packed & 0xff;
2186
+ if (slot === 0)
2187
+ return null;
2188
+ const src = sourcesBySlot[slot - 1];
2189
+ if (!src || src.kind !== "sceneCollection")
2190
+ return null;
2191
+ const col = (packed >>> 8) & 0xff;
2192
+ // scene-collection 只用 col 作为 index(row 保留 0)
2193
+ return (_a = src.prefabRefs[col]) !== null && _a !== void 0 ? _a : null;
2194
+ }
2195
+
2196
+ /**
2197
+ * DecompositionCache(C1,关联 ADR-0019)。
2198
+ *
2199
+ * TileSet 上每 alt tile 可能有任意 polygon collision shape。Matter.js 要求 convex,
2200
+ * concave 需 poly-decomp。decompose 是 O(n²),不能每 paint stroke 重做。
2201
+ *
2202
+ * 这里在 TileSet load 时把每 alt tile 的 polygon list 一次性 decompose 成 convex parts,
2203
+ * 后续 stroke 只走 translate;decompose 结果按 (sourceSlot, col, row, altIdx, layerId) key 缓存。
2204
+ *
2205
+ * 当前 cycle:cache 数据结构 + key 生成 + LRU eviction skeleton 实现完毕,真 poly-decomp
2206
+ * 在 Phase 3 落地时接入 npm `poly-decomp` 包。
2207
+ */
2208
+ function cacheKeyOf(k) {
2209
+ return `${k.sourceSlot},${k.col},${k.row},${k.altIdx},${k.layerId}`;
2210
+ }
2211
+ class DecompositionCache {
2212
+ constructor(maxEntries = 1000) {
2213
+ this.cache = new Map();
2214
+ this.accessOrder = new Map();
2215
+ this.accessCounter = 0;
2216
+ this.maxEntries = maxEntries;
2217
+ }
2218
+ get(key) {
2219
+ const k = cacheKeyOf(key);
2220
+ const cached = this.cache.get(k);
2221
+ if (cached)
2222
+ this.accessOrder.set(k, ++this.accessCounter);
2223
+ return cached;
2224
+ }
2225
+ set(key, value) {
2226
+ const k = cacheKeyOf(key);
2227
+ this.cache.set(k, value);
2228
+ this.accessOrder.set(k, ++this.accessCounter);
2229
+ if (this.cache.size > this.maxEntries)
2230
+ this.evictLRU();
2231
+ }
2232
+ has(key) {
2233
+ return this.cache.has(cacheKeyOf(key));
2234
+ }
2235
+ clear() {
2236
+ this.cache.clear();
2237
+ this.accessOrder.clear();
2238
+ this.accessCounter = 0;
2239
+ }
2240
+ get size() {
2241
+ return this.cache.size;
2242
+ }
2243
+ evictLRU() {
2244
+ let oldestKey;
2245
+ let oldestAccess = Infinity;
2246
+ for (const [k, c] of this.accessOrder) {
2247
+ if (c < oldestAccess) {
2248
+ oldestAccess = c;
2249
+ oldestKey = k;
2250
+ }
2251
+ }
2252
+ if (oldestKey) {
2253
+ this.cache.delete(oldestKey);
2254
+ this.accessOrder.delete(oldestKey);
2255
+ }
2256
+ }
2257
+ }
2258
+ /**
2259
+ * 把局部 polygon 平移到目标 cell 的世界坐标。
2260
+ * Phase 3 在 chunk rebuild 时调用,把 cached convex parts 复用到任意 cell 位置。
2261
+ */
2262
+ function translateConvexParts(parts, dx, dy) {
2263
+ return parts.map((p) => ({
2264
+ vertices: p.vertices.map((v) => ({ x: v.x + dx, y: v.y + dy })),
2265
+ }));
2266
+ }
2267
+ /**
2268
+ * 判定 polygon 是否为 convex(简单凸多边形)。
2269
+ *
2270
+ * 算法:遍历每一对相邻边,计算 cross product;如所有非零 cross 同号即 convex。
2271
+ * collinear(cross=0)被视为退化情况,不影响判定。
2272
+ *
2273
+ * 此函数 export 出去让 host UI 可以在用户绘制 polygon 时做 pre-check,
2274
+ * 在视觉层提示 concave warning,避免落入 decomposePolygon 的 throw。
2275
+ */
2276
+ function isConvex(polygon) {
2277
+ if (polygon.length < 3)
2278
+ return true;
2279
+ let sign = 0;
2280
+ const n = polygon.length;
2281
+ for (let i = 0; i < n; i++) {
2282
+ const a = polygon[i];
2283
+ const b = polygon[(i + 1) % n];
2284
+ const c = polygon[(i + 2) % n];
2285
+ const ex1 = b.x - a.x;
2286
+ const ey1 = b.y - a.y;
2287
+ const ex2 = c.x - b.x;
2288
+ const ey2 = c.y - b.y;
2289
+ const cross = ex1 * ey2 - ey1 * ex2;
2290
+ if (cross !== 0) {
2291
+ if (sign === 0)
2292
+ sign = cross > 0 ? 1 : -1;
2293
+ else if ((cross > 0 ? 1 : -1) !== sign)
2294
+ return false;
2295
+ }
2296
+ }
2297
+ return true;
2298
+ }
2299
+ /**
2300
+ * 暂时占位的 polygon → convex parts 函数。
2301
+ * Phase 3 落地 npm `poly-decomp` 时替换实现;当前假设输入已 convex,
2302
+ * 输入 concave polygon(L/T/U/凹槽)会 throw,防止静默产出错误 Matter body。
2303
+ *
2304
+ * Host 应在 paint 前用 isConvex 做 pre-check,把 concave 提示给用户,避免触发 throw。
2305
+ */
2306
+ function decomposePolygon(polygon) {
2307
+ if (polygon.length < 3)
2308
+ return [];
2309
+ if (!isConvex(polygon)) {
2310
+ const err = new Error(`decomposePolygon: input polygon (${polygon.length} vertices) is non-convex. ` +
2311
+ `This is a placeholder implementation that assumes convex input — concave polygons silently produce ` +
2312
+ `invalid Matter.js bodies. Real concave decomposition requires the 'poly-decomp' npm dependency ` +
2313
+ `(see ADR-0019 Phase 1). If you need concave physics tiles right now, split into convex parts manually.`);
2314
+ if (typeof console !== 'undefined')
2315
+ console.error('[Tilemap]', err.message);
2316
+ throw err;
2317
+ }
2318
+ return [{ vertices: polygon.slice() }];
2319
+ }
2320
+
2321
+ /**
2322
+ * 把 DecompositionCache(string-key)适配出 numeric-key 接口供 TileMapStaticBody 用。
2323
+ * numeric key = (slot << 24) | (altIdx << 18) | (row << 9) | col。
2324
+ * 这套 packed key 不需要 layerId(同 cell 跨 layer 的 physics 几何相同)。
2325
+ */
2326
+ function cacheKeyOfNum(slot, col, row, altIdx) {
2327
+ return (((slot & 0xff) << 24) | ((altIdx & 0x3f) << 18) | ((row & 0x1ff) << 9) | (col & 0x1ff)) >>> 0;
2328
+ }
2329
+ DecompositionCache.prototype.getNum = function (key) {
2330
+ return this.get({ sourceSlot: (key >>> 24) & 0xff, altIdx: (key >>> 18) & 0x3f, row: (key >>> 9) & 0x1ff, col: key & 0x1ff, layerId: "__num__" });
2331
+ };
2332
+ DecompositionCache.prototype.setNum = function (key, value) {
2333
+ this.set({ sourceSlot: (key >>> 24) & 0xff, altIdx: (key >>> 18) & 0x3f, row: (key >>> 9) & 0x1ff, col: key & 0x1ff, layerId: "__num__" }, value);
2334
+ };
2335
+
2336
+ /**
2337
+ * Body source registry shim(G1)。
2338
+ *
2339
+ * 真实 plugin-matterjs `registerBodySource` API 落地需要 ADR-0019 评审 + BREAKING
2340
+ * change。这里先 ship 一个完全相同的抽象层,plugin-renderer-tilemap 内部用它建
2341
+ * TileMapStaticBody body 集合。host 在初始化时把这个 registry 喂给 plugin-matterjs
2342
+ * (或直接消费它建 Matter.Composite)。
2343
+ *
2344
+ * 当 ADR-0019 落地后,只需要把这里的 import 切到 @eva/plugin-matterjs/lib/body-source。
2345
+ */
2346
+ class BodySourceRegistryImpl {
2347
+ constructor() {
2348
+ this.builders = new Map();
2349
+ }
2350
+ register(name, builder) {
2351
+ this.builders.set(name, builder);
2352
+ }
2353
+ unregister(name) {
2354
+ this.builders.delete(name);
2355
+ }
2356
+ has(name) {
2357
+ return this.builders.has(name);
2358
+ }
2359
+ build(name, ctx) {
2360
+ const b = this.builders.get(name);
2361
+ if (!b)
2362
+ return null;
2363
+ return b(ctx);
2364
+ }
2365
+ listRegisteredNames() {
2366
+ return Array.from(this.builders.keys());
2367
+ }
2368
+ clear() {
2369
+ this.builders.clear();
2370
+ }
2371
+ }
2372
+ const TILEMAP_BODY_SOURCE_REGISTRY = new BodySourceRegistryImpl();
2373
+
2374
+ /**
2375
+ * TileMapStaticBody(G2)。
2376
+ *
2377
+ * 走 chunked cellData,把每 painted cell 配的 physics polygons 通过 DecompositionCache
2378
+ * 转 convex parts + translate 到 cell world 位置,产出 BodyDefinition[] 给
2379
+ * BodySourceRegistry。
2380
+ */
2381
+ const CHUNK_SIZE = 16;
2382
+ function unpackSlot(packed) {
2383
+ return packed & 0xff;
2384
+ }
2385
+ function unpackCol(packed) {
2386
+ return (packed >>> 8) & 0xff;
2387
+ }
2388
+ function unpackRow(packed) {
2389
+ return (packed >>> 16) & 0xff;
2390
+ }
2391
+ function unpackAlt(packed) {
2392
+ return (packed >>> 24) & 0x1f;
2393
+ }
2394
+ /**
2395
+ * Build BodyDefinition[] for an entire TileMap layer。
2396
+ *
2397
+ * 每 cell 引用 TileSet 中对应 tile 的 physics polygons:
2398
+ * - 在 cache 里查 cached convex parts(load 时已 decompose)
2399
+ * - 没缓存就 decomposePolygon stub(当前 polygon=convex 直接返回)
2400
+ * - translate 到 cell world 位置
2401
+ */
2402
+ function buildTileMapStaticBodies(input, ctx) {
2403
+ var _a;
2404
+ const out = [];
2405
+ for (const [chunkKey, arr] of Object.entries(input.chunksByKey)) {
2406
+ const [ckxStr, ckyStr] = chunkKey.split(",");
2407
+ const ckx = Number.parseInt(ckxStr, 10);
2408
+ const cky = Number.parseInt(ckyStr, 10);
2409
+ if (!Number.isFinite(ckx) || !Number.isFinite(cky))
2410
+ continue;
2411
+ for (let ly = 0; ly < CHUNK_SIZE; ly++) {
2412
+ for (let lx = 0; lx < CHUNK_SIZE; lx++) {
2413
+ const packed = arr[ly * CHUNK_SIZE + lx];
2414
+ const slot = unpackSlot(packed);
2415
+ if (slot === 0)
2416
+ continue;
2417
+ const col = unpackCol(packed);
2418
+ const row = unpackRow(packed);
2419
+ const altIdx = unpackAlt(packed);
2420
+ const physicsKey = `${slot},${col},${row},${altIdx}`;
2421
+ const physicsEntries = input.physicsByCellKey.get(physicsKey);
2422
+ if (!physicsEntries || physicsEntries.length === 0)
2423
+ continue;
2424
+ const cellWorldX = input.mapOriginX + (ckx * CHUNK_SIZE + lx) * input.cellWidth + ctx.worldX;
2425
+ const cellWorldY = input.mapOriginY + (cky * CHUNK_SIZE + ly) * input.cellHeight + ctx.worldY;
2426
+ for (const entry of physicsEntries) {
2427
+ for (const poly of entry.polygons) {
2428
+ const cacheKeyNum = cacheKeyOfNum(slot, col, row, altIdx);
2429
+ let parts = (_a = input.cache.getNum(cacheKeyNum)) === null || _a === void 0 ? void 0 : _a.parts;
2430
+ if (!parts) {
2431
+ parts = decomposePolygon(poly.points);
2432
+ input.cache.setNum(cacheKeyNum, { parts });
2433
+ }
2434
+ const translated = translateConvexParts(parts, cellWorldX, cellWorldY);
2435
+ for (let pi = 0; pi < translated.length; pi++) {
2436
+ out.push({
2437
+ id: `tile-${chunkKey}-${lx}-${ly}-${entry.layerId}-${pi}`,
2438
+ vertices: translated[pi].vertices,
2439
+ centerX: cellWorldX + input.cellWidth / 2,
2440
+ centerY: cellWorldY + input.cellHeight / 2,
2441
+ isStatic: true,
2442
+ oneWay: entry.oneWay,
2443
+ friction: entry.friction,
2444
+ restitution: entry.restitution,
2445
+ metadata: { layerId: entry.layerId, chunkKey, cellLocal: [lx, ly] },
2446
+ });
2447
+ }
2448
+ }
2449
+ }
2450
+ }
2451
+ }
2452
+ }
2453
+ return out;
2454
+ }
2455
+
2456
+ /**
2457
+ * Host-side stop-gap for ADR-0019 plugin-matterjs `registerBodySource` API.
2458
+ *
2459
+ * Until the matter-side `registerBodySource` PR lands (ADR-0019 Phase 1), the
2460
+ * tilemap plugin cannot push BodyDefinition[] directly into Matter.World.
2461
+ * This module gives the host a thin adapter: pull BodyDefinition[] out of the
2462
+ * tilemap record, hand them to a host-supplied registrar that knows how to
2463
+ * create one Matter.Body per def (typically by spawning child GameObjects with
2464
+ * Physics components, since that path is already wired through plugin-matterjs).
2465
+ *
2466
+ * Once ADR-0019 ships, this adapter is replaced by a direct registry handshake
2467
+ * inside system.ts and this file becomes deprecated; the deprecation is one
2468
+ * import-line change for hosts.
2469
+ */
2470
+ /**
2471
+ * Build BodyDefinition[] for a TileMap entity and group by physics layer.
2472
+ *
2473
+ * The host is expected to call this after `system.handleAdd` resolves and the
2474
+ * record has its `loadedTileset`. The host passes:
2475
+ * - chunksByKey from `decodeChunk(layer.cellData.chunks[key])`
2476
+ * - physicsByCellKey derived from `loadedTileset.raw.sources[i].tiles[j].alternatives[k].physics`
2477
+ *
2478
+ * This keeps the runtime plugin agnostic of how the host actually registers bodies
2479
+ * with Matter (child GameObject + Physics component vs. direct Matter.Composite.add).
2480
+ */
2481
+ function buildTileMapStaticBodyDefinitions(input, ctx,
2482
+ /**
2483
+ * T-L3 (Phase L):optional probe registry。当 host 注入时,本函数会:
2484
+ * - PHYSICS_REBAKE_MS:wrap 整个 buildTileMapStaticBodies 调用计时
2485
+ * - BODIES_TOTAL:emit gauge,反映当前生成的 body 总数
2486
+ * - BODIES_CREATED:counter+totalBodyCount,统计累计 created
2487
+ * 不注入时 silent no-op,保持与现有 caller 行为兼容。
2488
+ * BODIES_DESTROYED 暂未 emit(无清晰 destroy point),Cycle 2 再补。
2489
+ */
2490
+ probes) {
2491
+ var _a, _b;
2492
+ probes === null || probes === void 0 ? void 0 : probes.beginTiming(TILEMAP_PROBE_NAMES.PHYSICS_REBAKE_MS);
2493
+ let bodies;
2494
+ try {
2495
+ bodies = buildTileMapStaticBodies(input, ctx);
2496
+ }
2497
+ finally {
2498
+ probes === null || probes === void 0 ? void 0 : probes.endTiming(TILEMAP_PROBE_NAMES.PHYSICS_REBAKE_MS);
2499
+ }
2500
+ const bodiesByLayerId = new Map();
2501
+ for (const b of bodies) {
2502
+ const layerId = (_b = (_a = b.metadata) === null || _a === void 0 ? void 0 : _a.layerId) !== null && _b !== void 0 ? _b : "<unlabeled>";
2503
+ let bucket = bodiesByLayerId.get(layerId);
2504
+ if (!bucket) {
2505
+ bucket = [];
2506
+ bodiesByLayerId.set(layerId, bucket);
2507
+ }
2508
+ bucket.push(b);
2509
+ }
2510
+ const totalBodyCount = bodies.length;
2511
+ probes === null || probes === void 0 ? void 0 : probes.gauge(TILEMAP_PROBE_NAMES.BODIES_TOTAL, totalBodyCount);
2512
+ if (totalBodyCount > 0) {
2513
+ probes === null || probes === void 0 ? void 0 : probes.count(TILEMAP_PROBE_NAMES.BODIES_CREATED, totalBodyCount);
2514
+ }
2515
+ return { bodies, totalBodyCount, bodiesByLayerId };
2516
+ }
2517
+ /**
2518
+ * Extract `physicsByCellKey` Map from a LoadedTileset's raw atlas tile data.
2519
+ *
2520
+ * Each tile alternative may carry `physics: Array<{ layerId, polygons, oneWay, friction, restitution }>`.
2521
+ * The returned Map is keyed by `${slot},${col},${row},${altIdx}` matching the cell-packed
2522
+ * id format used by `buildTileMapStaticBodies`.
2523
+ */
2524
+ function extractPhysicsByCellKey(tilesetRaw) {
2525
+ const out = new Map();
2526
+ for (let slotIdx = 0; slotIdx < tilesetRaw.sources.length; slotIdx++) {
2527
+ const src = tilesetRaw.sources[slotIdx];
2528
+ if (!src || src.kind !== "atlas" || !src.tiles)
2529
+ continue;
2530
+ const slot = slotIdx + 1; // 1-based to match unpackSlot
2531
+ for (const tile of src.tiles) {
2532
+ const { col, row } = tile.atlasCoords;
2533
+ for (const alt of tile.alternatives) {
2534
+ if (!alt.physics || alt.physics.length === 0)
2535
+ continue;
2536
+ const key = `${slot},${col},${row},${alt.altId & 0x1f}`;
2537
+ out.set(key, alt.physics);
2538
+ }
2539
+ }
2540
+ }
2541
+ return out;
2542
+ }
2543
+ /**
2544
+ * Status flag — true once ADR-0019 lands and this host stop-gap can be removed.
2545
+ *
2546
+ * Hosts can branch on this to switch from the child-GameObject workaround to a
2547
+ * direct registry handshake. Until then, importing this module signals "I am
2548
+ * using the pre-ADR-0019 path".
2549
+ */
2550
+ const ADR_0019_NATIVE_REGISTRY_AVAILABLE = false;
2551
+
2552
+ // 注册 TILESET 资源类型。此处 try/catch 防止多次 import 重复注册时抛错。
2553
+ try {
2554
+ eva_js.resource.registerResourceType('TILESET');
2555
+ }
2556
+ catch (_a) {
2557
+ /* already registered */
2558
+ }
2559
+ // 注册 TILESET 资源的 instance 解析回调:把 raw json 作为 instance 暴露,
2560
+ // 同时把 src.json.data 拷贝到 res.data.json,与 lottie/audio 资源约定一致。
2561
+ try {
2562
+ eva_js.resource.registerInstance('TILESET', (res) => {
2563
+ var _a, _b, _c, _d;
2564
+ // pixi.js v8 Assets.load 把 JSON 内容放在 res.data.json 或 src.json.data,
2565
+ // 不同版本路径不一致:这里两边都兜底,返回 raw JSON 作为 instance。
2566
+ const fromData = (_a = res.data) === null || _a === void 0 ? void 0 : _a.json;
2567
+ const fromSrc = (_c = (_b = res.src) === null || _b === void 0 ? void 0 : _b.json) === null || _c === void 0 ? void 0 : _c.data;
2568
+ return (_d = fromData !== null && fromData !== void 0 ? fromData : fromSrc) !== null && _d !== void 0 ? _d : null;
2569
+ });
2570
+ }
2571
+ catch (_b) {
2572
+ /* already registered */
2573
+ }
299
2574
 
2575
+ exports.ADR_0019_NATIVE_REGISTRY_AVAILABLE = ADR_0019_NATIVE_REGISTRY_AVAILABLE;
2576
+ exports.CHUNK_SIZE = CHUNK_SIZE$2;
2577
+ exports.DecompositionCache = DecompositionCache;
2578
+ exports.MAX_TERRAIN_ID = MAX_TERRAIN_ID;
2579
+ exports.NEIGHBOR_DIRECTIONS = NEIGHBOR_DIRECTIONS;
2580
+ exports.NO_NEIGHBOR = NO_NEIGHBOR;
2581
+ exports.PeeringBitIndex = PeeringBitIndex;
2582
+ exports.TILEMAP_BODY_SOURCE_REGISTRY = TILEMAP_BODY_SOURCE_REGISTRY;
2583
+ exports.TILEMAP_PROBE_NAMES = TILEMAP_PROBE_NAMES;
2584
+ exports.TILE_FRAG_SHADER = TILE_FRAG_SHADER;
2585
+ exports.TILE_SHADER_SOURCES = TILE_SHADER_SOURCES;
2586
+ exports.TILE_VERT_SHADER = TILE_VERT_SHADER;
2587
+ exports.TileAnimationDriver = TileAnimationDriver;
300
2588
  exports.Tilemap = Tilemap;
301
2589
  exports.TilemapSystem = TilemapSystem$1;
2590
+ exports.adaptGamePerfProbes = adaptGamePerfProbes;
2591
+ exports.buildChunkGeometry = buildChunkGeometry;
2592
+ exports.buildChunkMesh = buildChunkMesh;
2593
+ exports.buildTileMapStaticBodies = buildTileMapStaticBodies;
2594
+ exports.buildTileMapStaticBodyDefinitions = buildTileMapStaticBodyDefinitions;
2595
+ exports.cacheKeyOf = cacheKeyOf;
2596
+ exports.cacheKeyOfNum = cacheKeyOfNum;
2597
+ exports.computeAffectedChunks = computeAffectedChunks;
2598
+ exports.createInMemoryProbeRegistry = createInMemoryProbeRegistry;
2599
+ exports.decodeChunk = decodeChunk;
2600
+ exports.decomposePolygon = decomposePolygon;
2601
+ exports.diffTilesetDocuments = diffTilesetDocuments;
2602
+ exports.diffTilesetForChunkRebuild = diffTilesetForChunkRebuild;
2603
+ exports.estimateChunkMeshMemoryKB = estimateChunkMeshMemoryKB;
2604
+ exports.estimateSpriteNodes = estimateSpriteNodes;
2605
+ exports.expandSceneCollectionSource = expandSceneCollectionSource;
2606
+ exports.extractPhysicsByCellKey = extractPhysicsByCellKey;
2607
+ exports.getRequiredTilemapProbes = getRequiredTilemapProbes;
2608
+ exports.isEmptyCellValue = isEmptyCellValue;
2609
+ exports.isMeshPathAvailable = isMeshPathAvailable;
2610
+ exports.makeLoadedTileset = makeLoadedTileset;
2611
+ exports.pickAutotileCandidate = pickAutotileCandidate;
2612
+ exports.resolveCellPrefabName = resolveCellPrefabName;
2613
+ exports.resolveChunkRenderStrategy = resolveChunkRenderStrategy;
2614
+ exports.translateConvexParts = translateConvexParts;
2615
+ exports.unpackCell = unpackCell;