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