evomeme 0.1.7 → 0.1.9-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/canvas-public/app.js +8517 -1692
  2. package/dist/canvas-public/assets/presets/catalog.json +47 -0
  3. package/dist/canvas-public/assets/presets/tiles/A_01_CaoDi.png +0 -0
  4. package/dist/canvas-public/assets/presets/tiles/A_02_ShaMo.png +0 -0
  5. package/dist/canvas-public/assets/presets/tiles/A_03_TianKong.png +0 -0
  6. package/dist/canvas-public/assets/presets/tiles/A_04_ShuiBo.png +0 -0
  7. package/dist/canvas-public/assets/presets/tiles/A_05_HaiMian.png +0 -0
  8. package/dist/canvas-public/assets/presets/tiles/A_06_MuDiBan.png +0 -0
  9. package/dist/canvas-public/assets/presets/tiles/A_07_ShiZhuan.png +0 -0
  10. package/dist/canvas-public/assets/presets/tiles/A_08_AnRongYan.png +0 -0
  11. package/dist/canvas-public/assets/presets/tiles/A_09_QingTaiShi.png +0 -0
  12. package/dist/canvas-public/assets/presets/tiles/A_10_ShenHai.png +0 -0
  13. package/dist/canvas-public/assets/presets/tiles/A_11_HuiYanShi.png +0 -0
  14. package/dist/canvas-public/assets/presets/tiles/A_12_HongTu.png +0 -0
  15. package/dist/canvas-public/assets/presets/tiles/A_13_GengDi.png +0 -0
  16. package/dist/canvas-public/assets/presets/tiles/A_14_HuoShanYan.png +0 -0
  17. package/dist/canvas-public/assets/presets/tiles/A_15_NiTu.png +0 -0
  18. package/dist/canvas-public/assets/presets/tiles/A_16_HuangSha.png +0 -0
  19. package/dist/canvas-public/assets/presets/tiles/A_17_CongLin.png +0 -0
  20. package/dist/canvas-public/assets/presets/tiles/A_18_ZiYanShi.png +0 -0
  21. package/dist/canvas-public/assets/presets/tiles/A_19_MoGuTu.png +0 -0
  22. package/dist/canvas-public/assets/presets/tiles/A_20_HuaDian.png +0 -0
  23. package/dist/canvas-public/assets/presets/tiles/A_21_HaiLang.png +0 -0
  24. package/dist/canvas-public/assets/presets/tiles/A_22_GanTu.png +0 -0
  25. package/dist/canvas-public/assets/presets/tiles/A_23_QingTaiShiBan.png +0 -0
  26. package/dist/canvas-public/assets/presets/tiles/A_24_XingKong.png +0 -0
  27. package/dist/canvas-public/assets/presets/tiles/A_25_MoFaFuWen.png +0 -0
  28. package/dist/canvas-public/assets/presets/tiles/A_26_ShiTuYeDi.png +0 -0
  29. package/dist/canvas-public/assets/presets/tiles/A_27_LanZhuan.png +0 -0
  30. package/dist/canvas-public/assets/presets/tiles/A_28_HongZhuan.png +0 -0
  31. package/dist/canvas-public/assets/presets/tiles/A_29_YueQiu.png +0 -0
  32. package/dist/canvas-public/assets/presets/tiles/A_30_JinShuGe.png +0 -0
  33. package/dist/canvas-public/assets/presets/tiles/A_31_JinShuJingGai.png +0 -0
  34. package/dist/canvas-public/assets/presets/tiles/A_32_ShuZiChuang.png +0 -0
  35. package/dist/canvas-public/color-key-worker.js +280 -0
  36. package/dist/canvas-public/image-canvas-size-editor.js +254 -0
  37. package/dist/canvas-public/index.html +226 -119
  38. package/dist/canvas-public/perfect-pixel-worker.js +499 -0
  39. package/dist/canvas-public/smart-split.js +1 -1
  40. package/dist/canvas-public/styles.css +3902 -1225
  41. package/dist/canvas-public/tile-map-editor.js +2120 -0
  42. package/dist/canvas-public/ui-controls.js +95 -0
  43. package/dist/canvas-public/vendor/pixi-tilemap.js +126 -0
  44. package/dist/canvas-public/vendor/pixi.min.js +2340 -0
  45. package/dist/index.js +32263 -4187
  46. package/dist/stage-blender/bake_materials.py +102 -0
  47. package/dist/stage-blender/export_scene.py +275 -0
  48. package/dist/stage-blender/preview_camera.py +90 -0
  49. package/dist/stage-blender/repair_mesh.py +112 -0
  50. package/dist/stage-public/app.js +759 -0
  51. package/dist/stage-public/baked/bakePilot.js +44 -0
  52. package/dist/stage-public/baked/bakedScene.js +129 -0
  53. package/dist/stage-public/baked/sceneAppearance.js +225 -0
  54. package/dist/stage-public/baked/sceneCamera.js +45 -0
  55. package/dist/stage-public/comparison-contract.js +28 -0
  56. package/dist/stage-public/delivery.html +4 -0
  57. package/dist/stage-public/delivery.js +17 -0
  58. package/dist/stage-public/godot/appearance.gd +170 -0
  59. package/dist/stage-public/godot/preview.gd +79 -0
  60. package/dist/stage-public/index.html +141 -0
  61. package/dist/stage-public/review-app.js +289 -0
  62. package/dist/stage-public/review-runtime.js +63 -0
  63. package/dist/stage-public/review.css +24 -0
  64. package/dist/stage-public/review.html +47 -0
  65. package/dist/stage-public/scene-runtime.js +212 -0
  66. package/dist/stage-public/sceneContractRuntime.js +235 -0
  67. package/dist/stage-public/styles.css +328 -0
  68. package/dist/stage-public/three-appearance.js +104 -0
  69. package/dist/stage-public/vendor/three170/LICENSE +21 -0
  70. package/dist/stage-public/vendor/three170/build/three.module.js +54571 -0
  71. package/dist/stage-public/vendor/three170/examples/jsm/controls/OrbitControls.js +1523 -0
  72. package/dist/stage-public/vendor/three170/examples/jsm/environments/RoomEnvironment.js +144 -0
  73. package/dist/stage-public/vendor/three170/examples/jsm/libs/fflate.module.js +2672 -0
  74. package/dist/stage-public/vendor/three170/examples/jsm/loaders/EXRLoader.js +2563 -0
  75. package/dist/stage-public/vendor/three170/examples/jsm/loaders/GLTFLoader.js +4727 -0
  76. package/dist/stage-public/vendor/three170/examples/jsm/utils/BufferGeometryUtils.js +1373 -0
  77. package/dist/tile-transition-mask.svg +40 -0
  78. package/package.json +77 -69
  79. package/skills/evomeme-canvas/SKILL.md +52 -0
  80. package/skills/evomeme-canvas/agents/openai.yaml +7 -0
  81. package/skills/evomeme-cover/SKILL.md +1 -1
  82. package/skills/evomeme-game-maker/SKILL.md +79 -8
  83. package/skills/game-asset-align/SKILL.md +45 -0
  84. package/skills/game-asset-align/agents/openai.yaml +8 -0
  85. package/skills/game-asset-align/references/alignment-contract.md +61 -0
  86. package/skills/game-asset-align/references/blender-scene-pipeline.md +45 -0
  87. package/skills/game-asset-align/references/canonical-package.md +26 -0
  88. package/skills/game-asset-align/references/engine-capabilities.md +14 -0
  89. package/skills/game-asset-align/references/lightweight-review.md +32 -0
  90. package/skills/game-asset-align/references/repair-policy.md +30 -0
  91. package/skills/game-asset-align/references/tool-contract.md +39 -0
  92. package/skills/game-asset-align/references/visual-review.md +26 -0
  93. package/skills/game-asset-image-generator/SKILL.md +1 -1
  94. package/skills/godot-3d-game/SKILL.md +39 -0
  95. package/skills/godot-3d-game/agents/openai.yaml +8 -0
  96. package/skills/godot-3d-game/references/alignment-gates.md +26 -0
  97. package/skills/godot-3d-game/references/appearance-alignment.md +52 -0
  98. package/skills/godot-3d-game/references/build-and-qa.md +22 -0
  99. package/skills/godot-3d-game/references/canonical-asset-runtime.md +27 -0
  100. package/skills/godot-3d-game/references/import-and-wrapper-scenes.md +20 -0
  101. package/skills/godot-3d-game/references/physics-colliders.md +13 -0
  102. package/skills/godot-3d-game/references/project-structure.md +22 -0
  103. package/skills/godot-3d-game/references/skeleton-animation.md +16 -0
  104. package/skills/threejs-3d-game/SKILL.md +6 -2
  105. package/skills/threejs-3d-game/references/engine-asset-pipeline.md +52 -15
  106. package/dist/canvas-public/sequence-sheet.js +0 -59
@@ -0,0 +1,2120 @@
1
+ /**
2
+ * 地图绘制元素:负责地图数据、PixiJS/@pixi/tilemap 渲染、图片切分、画笔交互与 PNG 导出。
3
+ * 画布主程序只负责创建、挂载和持久化这个元素。
4
+ */
5
+
6
+ export const TILE_MAP_NODE_WIDTH = 820
7
+ export const TILE_MAP_FOOTER_HEIGHT = 22
8
+ export const TILE_MAP_NODE_HEIGHT = 637
9
+ export const TILE_MAP_NODE_MIN_WIDTH = 620
10
+ export const TILE_MAP_NODE_MIN_HEIGHT = 487
11
+
12
+ const MAP_VERSION = 1
13
+ const DEFAULT_COLUMNS = 16
14
+ const DEFAULT_ROWS = 12
15
+ const DEFAULT_CELL_SIZE = 128
16
+ const MAX_GRID_SIDE = 256
17
+ const MAX_EXPORT_SIDE = 16384
18
+ const GRID_BLOCK_SIZE = 4
19
+ const MIN_MINOR_GRID_CELL_SCREEN_SIZE = 8
20
+ const MINOR_GRID_SCREEN_WIDTH = 1.25
21
+ const MAJOR_GRID_SCREEN_WIDTH = 2.5
22
+ const ATLAS_COLUMNS = 8
23
+ const ATLAS_MIN_ROWS = 8
24
+ // Keep every atlas tile at 128px internally so the 8-column preview remains sharp when zoomed.
25
+ const ATLAS_CELL_SIZE = 128
26
+ const MIN_ATLAS_ZOOM = 0.5
27
+ const MAX_ATLAS_ZOOM = 4
28
+ const TILE_DRAG_TYPE = 'application/x-canvas-image'
29
+ const AUTO_TERRAIN_ATLAS_SIDE = 4
30
+ // SVG atlas order for the four quarter-cells: top-left, top-right, bottom-left, bottom-right.
31
+ const AUTO_TERRAIN_TILE_INDEX_BY_MASK = [12, 13, 0, 3, 8, 1, 14, 5, 15, 4, 11, 2, 9, 10, 7, 6]
32
+ const mountedViews = new Map()
33
+ let pixiRuntimePromise = null
34
+
35
+ export function reorderLayersByVisualDrop(layers, draggedLayerId, targetLayerId, placeAfter = false) {
36
+ const original = Array.isArray(layers) ? layers : []
37
+ if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId) return original
38
+
39
+ // Layers are painted bottom-to-top, while the inspector lists them top-to-bottom.
40
+ const visualOrder = [...original].reverse()
41
+ const draggedIndex = visualOrder.findIndex((layer) => layer.id === draggedLayerId)
42
+ const targetExists = visualOrder.some((layer) => layer.id === targetLayerId)
43
+ if (draggedIndex < 0 || !targetExists) return original
44
+
45
+ const [draggedLayer] = visualOrder.splice(draggedIndex, 1)
46
+ const targetIndex = visualOrder.findIndex((layer) => layer.id === targetLayerId)
47
+ visualOrder.splice(targetIndex + (placeAfter ? 1 : 0), 0, draggedLayer)
48
+ return visualOrder.reverse()
49
+ }
50
+
51
+ function loadScriptOnce(src) {
52
+ const existing = document.querySelector(`script[data-tile-map-runtime="${src}"]`)
53
+ if (existing) {
54
+ if (existing.dataset.loaded === 'true') return Promise.resolve()
55
+ return new Promise((resolve, reject) => {
56
+ existing.addEventListener('load', resolve, { once: true })
57
+ existing.addEventListener('error', () => reject(new Error(`无法加载 ${src}`)), { once: true })
58
+ })
59
+ }
60
+ return new Promise((resolve, reject) => {
61
+ const script = document.createElement('script')
62
+ script.src = src
63
+ script.dataset.tileMapRuntime = src
64
+ script.addEventListener('load', () => {
65
+ script.dataset.loaded = 'true'
66
+ resolve()
67
+ }, { once: true })
68
+ script.addEventListener('error', () => reject(new Error(`无法加载 ${src}`)), { once: true })
69
+ document.head.appendChild(script)
70
+ })
71
+ }
72
+
73
+ async function ensurePixiRuntime() {
74
+ if (globalThis.PIXI?.Application && globalThis.PIXI?.tilemap?.CompositeTilemap) return
75
+ if (!pixiRuntimePromise) {
76
+ pixiRuntimePromise = (async () => {
77
+ if (!globalThis.PIXI?.Application) await loadScriptOnce('/vendor/pixi.min.js')
78
+ if (!globalThis.PIXI?.tilemap?.CompositeTilemap) await loadScriptOnce('/vendor/pixi-tilemap.js')
79
+ if (!globalThis.PIXI?.Application || !globalThis.PIXI?.tilemap?.CompositeTilemap) {
80
+ throw new Error('PixiJS 地图渲染器初始化失败')
81
+ }
82
+ })().catch((error) => {
83
+ pixiRuntimePromise = null
84
+ throw error
85
+ })
86
+ }
87
+ await pixiRuntimePromise
88
+ }
89
+
90
+ function clampInteger(value, minimum, maximum, fallback) {
91
+ const parsed = Math.round(Number(value))
92
+ if (!Number.isFinite(parsed)) return fallback
93
+ return Math.min(maximum, Math.max(minimum, parsed))
94
+ }
95
+
96
+ function safeName(value, fallback) {
97
+ const text = String(value || '').trim()
98
+ return text || fallback
99
+ }
100
+
101
+ function normalizeSource(value) {
102
+ if (!value || typeof value !== 'object') return null
103
+ const assetId = String(value.assetId || '').trim()
104
+ const id = String(value.id || '').trim()
105
+ if (!assetId || !id) return null
106
+ const naturalWidth = clampInteger(value.naturalWidth, 1, 32768, 1)
107
+ const naturalHeight = clampInteger(value.naturalHeight, 1, 32768, 1)
108
+ const columns = clampInteger(value.columns, 1, 256, 1)
109
+ const rows = clampInteger(value.rows, 1, 256, 1)
110
+ const marginX = clampInteger(value.marginX, 0, naturalWidth - 1, 0)
111
+ const marginY = clampInteger(value.marginY, 0, naturalHeight - 1, 0)
112
+ const spacingX = clampInteger(value.spacingX, 0, naturalWidth - 1, 0)
113
+ const spacingY = clampInteger(value.spacingY, 0, naturalHeight - 1, 0)
114
+ const availableWidth = naturalWidth - marginX * 2 - spacingX * (columns - 1)
115
+ const availableHeight = naturalHeight - marginY * 2 - spacingY * (rows - 1)
116
+ const tileWidth = clampInteger(value.tileWidth, 1, naturalWidth, Math.max(1, Math.floor(availableWidth / columns)))
117
+ const tileHeight = clampInteger(value.tileHeight, 1, naturalHeight, Math.max(1, Math.floor(availableHeight / rows)))
118
+ return {
119
+ id,
120
+ assetId,
121
+ name: safeName(value.name, '地砖图片'),
122
+ naturalWidth,
123
+ naturalHeight,
124
+ columns,
125
+ rows,
126
+ marginX,
127
+ marginY,
128
+ spacingX,
129
+ spacingY,
130
+ tileWidth,
131
+ tileHeight,
132
+ paletteHidden: value.paletteHidden === true,
133
+ }
134
+ }
135
+
136
+ function normalizeBrush(value, sources) {
137
+ if (!value || typeof value !== 'object') return null
138
+ if (Array.isArray(value.tiles) && value.tiles.length) {
139
+ const sourceMap = new Map(sources.map((source) => [source.id, source]))
140
+ const columns = clampInteger(value.columns, 1, ATLAS_COLUMNS, 1)
141
+ const rows = clampInteger(value.rows, 1, MAX_GRID_SIDE, 1)
142
+ const tiles = value.tiles.slice(0, columns * rows).map((tile) => {
143
+ if (!tile || typeof tile !== 'object') return null
144
+ const source = sourceMap.get(String(tile.sourceId || ''))
145
+ if (!source) return null
146
+ return {
147
+ sourceId: source.id,
148
+ tileIndex: clampInteger(tile.tileIndex, 0, source.columns * source.rows - 1, 0),
149
+ }
150
+ })
151
+ while (tiles.length < columns * rows) tiles.push(null)
152
+ const firstTile = tiles.find(Boolean)
153
+ if (firstTile) {
154
+ return {
155
+ sourceId: firstTile.sourceId,
156
+ tileIndex: firstTile.tileIndex,
157
+ columns,
158
+ rows,
159
+ atlasIndex: Number.isFinite(Number(value.atlasIndex))
160
+ ? clampInteger(value.atlasIndex, 0, Number.MAX_SAFE_INTEGER, 0)
161
+ : null,
162
+ tiles,
163
+ }
164
+ }
165
+ }
166
+ const source = sources.find((candidate) => candidate.id === value.sourceId)
167
+ if (!source) return null
168
+ const tileIndex = clampInteger(value.tileIndex, 0, source.columns * source.rows - 1, 0)
169
+ const startColumn = tileIndex % source.columns
170
+ const startRow = Math.floor(tileIndex / source.columns)
171
+ const columns = clampInteger(value.columns, 1, source.columns - startColumn, 1)
172
+ const rows = clampInteger(value.rows, 1, source.rows - startRow, 1)
173
+ return { sourceId: source.id, tileIndex, columns, rows }
174
+ }
175
+
176
+ function normalizeCells(value, columns, rows, sources) {
177
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
178
+ const sourceMap = new Map(sources.map((source) => [source.id, source]))
179
+ const cells = {}
180
+ for (const [key, cell] of Object.entries(value)) {
181
+ const match = /^(\d+),(\d+)$/.exec(key)
182
+ if (!match || !cell || typeof cell !== 'object') continue
183
+ const x = Number(match[1])
184
+ const y = Number(match[2])
185
+ if (x < 0 || y < 0 || x >= columns || y >= rows) continue
186
+ const source = sourceMap.get(String(cell.sourceId || ''))
187
+ if (!source) continue
188
+ const tileIndex = clampInteger(cell.tileIndex, 0, source.columns * source.rows - 1, 0)
189
+ cells[`${x},${y}`] = { sourceId: source.id, tileIndex }
190
+ }
191
+ return cells
192
+ }
193
+
194
+ export function autoTerrainTileIndex(mask) {
195
+ return AUTO_TERRAIN_TILE_INDEX_BY_MASK[clampInteger(mask, 0, 15, 0)]
196
+ }
197
+
198
+ export function autoTerrainMaskAt(occupiedCells, x, y) {
199
+ const matches = (cellX, cellY) => occupiedCells?.has?.(`${cellX},${cellY}`) || occupiedCells?.[`${cellX},${cellY}`] === true
200
+ if (matches(x, y)) return 15
201
+ const north = matches(x, y - 1)
202
+ const east = matches(x + 1, y)
203
+ const south = matches(x, y + 1)
204
+ const west = matches(x - 1, y)
205
+ return (north || west || matches(x - 1, y - 1) ? 8 : 0) |
206
+ (north || east || matches(x + 1, y - 1) ? 4 : 0) |
207
+ (south || west || matches(x - 1, y + 1) ? 2 : 0) |
208
+ (south || east || matches(x + 1, y + 1) ? 1 : 0)
209
+ }
210
+
211
+ function createLayerId() {
212
+ return `layer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
213
+ }
214
+
215
+ function normalizeLayers(value, legacyCells, columns, rows, sources) {
216
+ const rawLayers = Array.isArray(value) ? value : []
217
+ const seenIds = new Set()
218
+ const layers = rawLayers.map((rawLayer, index) => {
219
+ if (!rawLayer || typeof rawLayer !== 'object') return null
220
+ let id = String(rawLayer.id || `layer-${index + 1}`).trim()
221
+ if (!id || seenIds.has(id)) id = createLayerId()
222
+ seenIds.add(id)
223
+ return {
224
+ id,
225
+ name: safeName(rawLayer.name, `图层 ${index + 1}`),
226
+ cells: normalizeCells(rawLayer.cells, columns, rows, sources),
227
+ }
228
+ }).filter(Boolean)
229
+ if (layers.length) return layers
230
+ return [{
231
+ id: 'layer-1',
232
+ name: '图层 1',
233
+ cells: normalizeCells(legacyCells, columns, rows, sources),
234
+ }]
235
+ }
236
+
237
+ function paintedCellCount(map) {
238
+ return map.layers.reduce((count, layer) => count + Object.keys(layer.cells).length, 0)
239
+ }
240
+
241
+ export function createPaletteFromSources(sources, brushImages = [], cellSize = DEFAULT_CELL_SIZE) {
242
+ const cells = {}
243
+ const sourceMap = new Map(sources.map((source) => [source.id, source]))
244
+ const placedSourceIds = new Set()
245
+ let cursorX = 0
246
+ let cursorY = 0
247
+ let shelfHeight = 0
248
+
249
+ const placeRectangle = (columns, rows, tiles) => {
250
+ if (cursorX > 0 && cursorX + columns > ATLAS_COLUMNS) {
251
+ cursorX = 0
252
+ cursorY += shelfHeight
253
+ shelfHeight = 0
254
+ }
255
+ for (let row = 0; row < rows; row += 1) {
256
+ for (let column = 0; column < columns; column += 1) {
257
+ const tile = tiles[row * columns + column]
258
+ if (!tile) continue
259
+ cells[`${cursorX + column},${cursorY + row}`] = tile
260
+ }
261
+ }
262
+ cursorX += columns
263
+ shelfHeight = Math.max(shelfHeight, rows)
264
+ }
265
+
266
+ for (const brushImage of brushImages) {
267
+ const tiles = brushImage.tileSourceIds.map((sourceId) => {
268
+ const source = sourceMap.get(sourceId)
269
+ placedSourceIds.add(sourceId)
270
+ if (!source || source.paletteHidden) return null
271
+ return { sourceId, tileIndex: 0 }
272
+ })
273
+ placeRectangle(brushImage.columns, brushImage.rows, tiles)
274
+ }
275
+
276
+ for (const source of sources) {
277
+ if (placedSourceIds.has(source.id) || source.paletteHidden) continue
278
+ const tiles = []
279
+ for (let tileIndex = 0; tileIndex < source.columns * source.rows; tileIndex += 1) {
280
+ tiles.push({ sourceId: source.id, tileIndex })
281
+ }
282
+ placeRectangle(Math.min(source.columns, ATLAS_COLUMNS), source.rows, tiles)
283
+ }
284
+ return {
285
+ version: 1,
286
+ layout: 'brush-images',
287
+ columns: ATLAS_COLUMNS,
288
+ rows: Math.max(ATLAS_MIN_ROWS, cursorY + shelfHeight),
289
+ cellSize,
290
+ cells,
291
+ }
292
+ }
293
+
294
+ function normalizePalette(value, sources, brushImages, cellSize) {
295
+ if (!value || typeof value !== 'object' || value.layout !== 'brush-images') {
296
+ return createPaletteFromSources(sources, brushImages, cellSize)
297
+ }
298
+ const rows = clampInteger(value.rows, ATLAS_MIN_ROWS, 65535, ATLAS_MIN_ROWS)
299
+ return {
300
+ version: 1,
301
+ layout: 'brush-images',
302
+ columns: ATLAS_COLUMNS,
303
+ rows,
304
+ cellSize,
305
+ cells: normalizeCells(value.cells, ATLAS_COLUMNS, rows, sources),
306
+ }
307
+ }
308
+
309
+ function normalizeBrushImage(value) {
310
+ if (!value || typeof value !== 'object') return null
311
+ const assetId = String(value.assetId || '').trim()
312
+ const sourceAssetId = String(value.sourceAssetId || '').trim()
313
+ const tileSourceIds = Array.isArray(value.tileSourceIds)
314
+ ? value.tileSourceIds.map(String).filter(Boolean)
315
+ : []
316
+ if (!assetId || !tileSourceIds.length) return null
317
+ return {
318
+ type: 'map_brush_image',
319
+ id: String(value.id || assetId),
320
+ assetId,
321
+ sourceAssetId,
322
+ name: safeName(value.name, '地砖图集'),
323
+ columns: clampInteger(value.columns, 1, 8, 1),
324
+ rows: clampInteger(value.rows, 1, 8, 1),
325
+ marginX: clampInteger(value.marginX, 0, 32767, 0),
326
+ marginY: clampInteger(value.marginY, 0, 32767, 0),
327
+ spacingX: clampInteger(value.spacingX, 0, 32767, 0),
328
+ spacingY: clampInteger(value.spacingY, 0, 32767, 0),
329
+ tileSourceIds,
330
+ }
331
+ }
332
+
333
+ export function normalizeTileMapData(value) {
334
+ const source = value && typeof value === 'object' ? value : {}
335
+ const columns = clampInteger(source.columns, 1, MAX_GRID_SIDE, DEFAULT_COLUMNS)
336
+ const rows = clampInteger(source.rows, 1, MAX_GRID_SIDE, DEFAULT_ROWS)
337
+ const cellSize = clampInteger(source.cellSize, 8, 512, DEFAULT_CELL_SIZE)
338
+ const sources = Array.isArray(source.sources)
339
+ ? source.sources.map(normalizeSource).filter(Boolean)
340
+ : []
341
+ const rawBrushImages = Array.isArray(source.brushImages) && source.brushImages.length
342
+ ? source.brushImages
343
+ : source.tilesets
344
+ const brushImages = Array.isArray(rawBrushImages)
345
+ ? rawBrushImages.map(normalizeBrushImage).filter(Boolean)
346
+ : []
347
+ const activeBrush = normalizeBrush(source.activeBrush, sources)
348
+ const layers = normalizeLayers(source.layers, source.cells, columns, rows, sources)
349
+ const requestedLayerId = String(source.activeLayerId || '')
350
+ const activeLayerId = layers.some((layer) => layer.id === requestedLayerId)
351
+ ? requestedLayerId
352
+ : layers[layers.length - 1].id
353
+ return {
354
+ version: MAP_VERSION,
355
+ columns,
356
+ rows,
357
+ cellSize,
358
+ activeTool: ['brush', 'terrain', 'eraser'].includes(source.activeTool) ? source.activeTool : 'brush',
359
+ activeBrush: activeBrush || (sources[0] ? { sourceId: sources[0].id, tileIndex: 0 } : null),
360
+ activeTerrainBrushId: brushImages.some((brushImage) => (
361
+ brushImage.id === source.activeTerrainBrushId &&
362
+ brushImage.columns === AUTO_TERRAIN_ATLAS_SIDE &&
363
+ brushImage.rows === AUTO_TERRAIN_ATLAS_SIDE
364
+ )) ? source.activeTerrainBrushId : null,
365
+ sources,
366
+ brushImages,
367
+ palette: normalizePalette(source.palette, sources, brushImages, cellSize),
368
+ layers,
369
+ activeLayerId,
370
+ }
371
+ }
372
+
373
+ export function createTileMapItem({ id, x, y, name = '地图', folderName = '地图', cellSize = DEFAULT_CELL_SIZE }) {
374
+ return {
375
+ id,
376
+ type: 'tile_map',
377
+ name: safeName(name, '地图'),
378
+ folderName: safeName(folderName, '地图'),
379
+ x: Number(x) - TILE_MAP_NODE_WIDTH / 2,
380
+ y: Number(y) - 18,
381
+ width: TILE_MAP_NODE_WIDTH,
382
+ height: TILE_MAP_NODE_HEIGHT,
383
+ tileMap: normalizeTileMapData({ cellSize }),
384
+ }
385
+ }
386
+
387
+ export function tileMapContentAspectRatio(item) {
388
+ const map = item?.tileMap || item || {}
389
+ const columns = clampInteger(map.columns, 1, MAX_GRID_SIDE, DEFAULT_COLUMNS)
390
+ const rows = clampInteger(map.rows, 1, MAX_GRID_SIDE, DEFAULT_ROWS)
391
+ return columns / rows
392
+ }
393
+
394
+ export function tileMapNodeHeightForWidth(item, width) {
395
+ const safeWidth = Math.max(1, Number(width) || TILE_MAP_NODE_WIDTH)
396
+ return Math.round(safeWidth / tileMapContentAspectRatio(item) + TILE_MAP_FOOTER_HEIGHT)
397
+ }
398
+
399
+ export function isTileMapItem(item) {
400
+ return Boolean(item && item.type === 'tile_map')
401
+ }
402
+
403
+ export function normalizeTileMapItem(item, index = 0) {
404
+ const fileParts = String(item?.tileMapFile || '').replace(/\\/g, '/').split('/').filter(Boolean)
405
+ const fileFolderName = fileParts.length > 1 ? fileParts[fileParts.length - 2] : ''
406
+ const tileMap = normalizeTileMapData(item?.tileMap)
407
+ const width = Math.max(TILE_MAP_NODE_MIN_WIDTH, Number(item?.width) || TILE_MAP_NODE_WIDTH)
408
+ return {
409
+ ...item,
410
+ type: 'tile_map',
411
+ name: safeName(item?.name, '地图'),
412
+ folderName: safeName(item?.folderName || fileFolderName, '地图'),
413
+ x: Number.isFinite(Number(item?.x)) ? Number(item.x) : 60 + (index % 3) * 860,
414
+ y: Number.isFinite(Number(item?.y)) ? Number(item.y) : 60 + Math.floor(index / 3) * 660,
415
+ width,
416
+ height: tileMapNodeHeightForWidth(tileMap, width),
417
+ tileMap,
418
+ }
419
+ }
420
+
421
+ export function serializeTileMapItem(item) {
422
+ const normalized = normalizeTileMapItem(item)
423
+ return {
424
+ id: normalized.id,
425
+ type: 'tile_map',
426
+ name: normalized.name,
427
+ x: normalized.x,
428
+ y: normalized.y,
429
+ width: normalized.width,
430
+ height: normalized.height,
431
+ ...(normalized.tileMapFile ? { tileMapFile: normalized.tileMapFile } : {}),
432
+ ...(normalized.tileMapMetaFile ? { tileMapMetaFile: normalized.tileMapMetaFile } : {}),
433
+ folderName: normalized.folderName,
434
+ references: (
435
+ normalized.tileMap.brushImages.length
436
+ ? normalized.tileMap.brushImages.map((brushImage) => brushImage.sourceAssetId)
437
+ : normalized.tileMap.sources.map((source) => source.assetId)
438
+ ).filter((id, index, ids) => id && ids.indexOf(id) === index).map((id) => ({
439
+ id,
440
+ role: 'map-brush-image',
441
+ })),
442
+ tileMap: normalized.tileMap,
443
+ }
444
+ }
445
+
446
+ export function tileMapSummary(item) {
447
+ const map = normalizeTileMapData(item?.tileMap)
448
+ return {
449
+ dimensions: `${map.columns} × ${map.rows} 格`,
450
+ outputSize: `${map.columns * map.cellSize} × ${map.rows * map.cellSize} px`,
451
+ sources: String(map.sources.length),
452
+ paintedCells: String(paintedCellCount(map)),
453
+ }
454
+ }
455
+
456
+ function makeElement(tagName, className, text) {
457
+ const element = document.createElement(tagName)
458
+ if (className) element.className = className
459
+ if (text !== undefined) element.textContent = text
460
+ return element
461
+ }
462
+
463
+ function makeButton(text, className = 'tile-map-button') {
464
+ const button = makeElement('button', className, text)
465
+ button.type = 'button'
466
+ return button
467
+ }
468
+
469
+ function sourceTileFrame(source, tileIndex) {
470
+ const column = tileIndex % source.columns
471
+ const row = Math.floor(tileIndex / source.columns)
472
+ return {
473
+ x: source.marginX + column * (source.tileWidth + source.spacingX),
474
+ y: source.marginY + row * (source.tileHeight + source.spacingY),
475
+ width: source.tileWidth,
476
+ height: source.tileHeight,
477
+ }
478
+ }
479
+
480
+ function suggestedSplit(width, height) {
481
+ if (width === height) return { columns: 1, rows: 1 }
482
+ if (width > height && width % height === 0 && width / height <= 32) {
483
+ return { columns: width / height, rows: 1 }
484
+ }
485
+ if (height > width && height % width === 0 && height / width <= 32) {
486
+ return { columns: 1, rows: height / width }
487
+ }
488
+ return { columns: 1, rows: 1 }
489
+ }
490
+
491
+ function loadHtmlImage(url) {
492
+ return new Promise((resolve, reject) => {
493
+ const image = new Image()
494
+ image.decoding = 'async'
495
+ image.onload = () => resolve(image)
496
+ image.onerror = () => reject(new Error('无法读取拖入的图片'))
497
+ image.src = url
498
+ })
499
+ }
500
+
501
+ function downloadBlob(blob, filename) {
502
+ const url = URL.createObjectURL(blob)
503
+ const anchor = document.createElement('a')
504
+ anchor.href = url
505
+ anchor.download = filename
506
+ document.body.appendChild(anchor)
507
+ anchor.click()
508
+ anchor.remove()
509
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
510
+ }
511
+
512
+ function safeFileStem(name) {
513
+ return safeName(name, 'tile-map')
514
+ .replace(/[\\/:*?"<>|]+/g, '-')
515
+ .replace(/\s+/g, '-')
516
+ .slice(0, 80)
517
+ }
518
+
519
+ class TileMapView {
520
+ constructor(container, item, callbacks) {
521
+ this.container = container
522
+ this.item = item
523
+ this.map = item.tileMap = normalizeTileMapData(item.tileMap)
524
+ this.callbacks = callbacks
525
+ this.app = null
526
+ this.backgroundLayer = null
527
+ this.tileLayer = null
528
+ this.gridLayer = null
529
+ this.hoverLayer = null
530
+ this.baseTextureCache = new Map()
531
+ this.imageCache = new Map()
532
+ this.renderSequence = 0
533
+ this.disposed = false
534
+ this.painting = false
535
+ this.strokeChanged = false
536
+ this.strokeBefore = null
537
+ this.outerScale = 1
538
+ this.history = []
539
+ this.future = []
540
+ this.resizeObserver = null
541
+ this.resizeFrame = 0
542
+ this.inspectorRoot = null
543
+ this.selectedStatus = null
544
+ this.stageHost = null
545
+ this.palette = null
546
+ this.tilesetCanvas = null
547
+ this.tilesetSelectionCanvas = null
548
+ this.brushContextMenu = null
549
+ this.paletteEmpty = null
550
+ this.atlasCells = []
551
+ this.atlasDrag = null
552
+ this.atlasZoom = 1
553
+ this.atlasZoomLabel = null
554
+ this.paletteRenderSequence = 0
555
+ this.layerRenderSequence = 0
556
+ this.layerList = null
557
+ this.draggedLayerId = null
558
+ this.undoButton = null
559
+ this.redoButton = null
560
+ this.brushButton = null
561
+ this.terrainBrushButton = null
562
+ this.eraserButton = null
563
+ this.specLabel = null
564
+ }
565
+
566
+ activate() {
567
+ this.callbacks.onActivate?.(this.item.id)
568
+ }
569
+
570
+ reportError(error) {
571
+ const message = String(error?.message || error || '地图操作失败')
572
+ if (this.selectedStatus) this.selectedStatus.textContent = message
573
+ this.callbacks.onError?.(message)
574
+ }
575
+
576
+ activeLayer() {
577
+ return this.map.layers.find((layer) => layer.id === this.map.activeLayerId) || this.map.layers[0]
578
+ }
579
+
580
+ layerSnapshot() {
581
+ return {
582
+ activeLayerId: this.map.activeLayerId,
583
+ layers: this.map.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })),
584
+ }
585
+ }
586
+
587
+ restoreLayerSnapshot(snapshot) {
588
+ this.map.layers = snapshot.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } }))
589
+ this.map.activeLayerId = this.map.layers.some((layer) => layer.id === snapshot.activeLayerId)
590
+ ? snapshot.activeLayerId
591
+ : this.map.layers[this.map.layers.length - 1].id
592
+ }
593
+
594
+ notifyChange() {
595
+ this.item.tileMap = this.map
596
+ this.updateHeaderSummary()
597
+ this.callbacks.onChange?.(this.item.id, this.map)
598
+ }
599
+
600
+ build() {
601
+ const root = makeElement('div', 'tile-map-editor')
602
+ root.dataset.tileMapEditor = this.item.id
603
+ this.stageHost = makeElement('div', 'tile-map-stage')
604
+ const dragHandle = makeElement('div', 'canvas-node-caption tile-map-drag-handle')
605
+ const mapName = makeElement('span', 'tile-map-footer-name', this.item.folderName || this.item.name || '地图')
606
+ this.specLabel = makeElement(
607
+ 'span',
608
+ 'tile-map-footer-spec',
609
+ `瓦片规格 ${this.map.cellSize} × ${this.map.cellSize} px`,
610
+ )
611
+ dragHandle.append(mapName, this.specLabel)
612
+ dragHandle.title = '拖拽移动地图元素'
613
+ root.append(this.stageHost, dragHandle)
614
+ this.container.appendChild(root)
615
+
616
+ root.addEventListener('pointerdown', (event) => {
617
+ this.activate()
618
+ if (event.button === 0 && !event.target.closest('.tile-map-drag-handle')) event.stopPropagation()
619
+ })
620
+ void this.mountPixi()
621
+ }
622
+
623
+ mountInspector(container) {
624
+ this.closeBrushContextMenu()
625
+ this.inspectorRoot?.remove()
626
+ this.atlasDrag = null
627
+
628
+ const root = makeElement('section', 'tile-map-inspector')
629
+ root.dataset.tileMapInspector = this.item.id
630
+ this.inspectorRoot = root
631
+
632
+ const iconButton = (label, paths) => {
633
+ const button = makeButton('', 'tile-map-icon-button')
634
+ button.title = label
635
+ button.setAttribute('aria-label', label)
636
+ button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`
637
+ return button
638
+ }
639
+
640
+ const layerSection = makeElement('section', 'tile-map-layer-section')
641
+ const layerHeader = makeElement('div', 'tile-map-layer-header')
642
+ layerHeader.appendChild(makeElement('strong', null, '图层'))
643
+ const addLayerButton = makeButton('+ 新建图层')
644
+ this.layerList = makeElement('div', 'tile-map-layer-list')
645
+ layerHeader.appendChild(addLayerButton)
646
+ layerSection.append(layerHeader, this.layerList)
647
+
648
+ const toolActions = makeElement('div', 'tile-map-tool-strip')
649
+ this.brushButton = iconButton('画笔', '<path d="m14.7 2.3 7 7-11 11H3v-7Z"/><path d="m13 4 7 7"/>')
650
+ this.terrainBrushButton = iconButton('自动地形画笔', '<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4z"/><path d="m15 18 2 2 4-5"/>')
651
+ this.eraserButton = iconButton('橡皮', '<path d="m7 21-4-4a2 2 0 0 1 0-3L13.5 3.5a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3L10 21Z"/><path d="m6.5 10 7 7"/><path d="M7 21h14"/>')
652
+ this.undoButton = iconButton('撤销', '<path d="M9 7 4 12l5 5"/><path d="M20 17a7 7 0 0 0-7-7H4"/>')
653
+ this.redoButton = iconButton('重做', '<path d="m15 7 5 5-5 5"/><path d="M4 17a7 7 0 0 1 7-7h9"/>')
654
+ toolActions.append(this.brushButton, this.terrainBrushButton, this.eraserButton, this.undoButton, this.redoButton)
655
+
656
+ const gridFields = makeElement('div', 'tile-map-grid-fields')
657
+ const columnsInput = this.numberField('列', this.map.columns, 1, MAX_GRID_SIDE)
658
+ const rowsInput = this.numberField('行', this.map.rows, 1, MAX_GRID_SIDE)
659
+ gridFields.append(columnsInput.label, rowsInput.label)
660
+
661
+ this.palette = makeElement('div', 'tile-map-palette')
662
+ this.tilesetCanvas = document.createElement('canvas')
663
+ this.tilesetCanvas.className = 'tile-map-tileset-canvas'
664
+ this.tilesetCanvas.tabIndex = 0
665
+ this.tilesetSelectionCanvas = document.createElement('canvas')
666
+ this.tilesetSelectionCanvas.className = 'tile-map-tileset-selection'
667
+ this.tilesetSelectionCanvas.setAttribute('aria-hidden', 'true')
668
+ this.paletteEmpty = makeElement('p', 'tile-map-palette-empty', '将画布图片拖入此处以添加画笔')
669
+ this.palette.append(this.paletteEmpty, this.tilesetCanvas, this.tilesetSelectionCanvas)
670
+
671
+ this.selectedStatus = null
672
+ root.append(
673
+ this.palette,
674
+ toolActions,
675
+ gridFields,
676
+ layerSection,
677
+ )
678
+ container.appendChild(root)
679
+
680
+ for (const control of root.querySelectorAll('button, input')) {
681
+ control.addEventListener('pointerdown', (event) => event.stopPropagation())
682
+ }
683
+
684
+ const openDroppedImage = async (event) => {
685
+ event.preventDefault()
686
+ event.stopPropagation()
687
+ root.classList.remove('is-image-over')
688
+ const raw = event.dataTransfer?.getData(TILE_DRAG_TYPE)
689
+ if (!raw) {
690
+ this.reportError('请从画布上的图片拖入地图')
691
+ return
692
+ }
693
+ try {
694
+ const reference = JSON.parse(raw)
695
+ if (!reference?.id) throw new Error('拖入的图片信息无效')
696
+ await this.openSliceDialog(reference)
697
+ } catch (error) {
698
+ this.reportError(error)
699
+ }
700
+ }
701
+ root.addEventListener('dragenter', (event) => {
702
+ if (!event.dataTransfer?.types?.includes(TILE_DRAG_TYPE)) return
703
+ event.preventDefault()
704
+ root.classList.add('is-image-over')
705
+ })
706
+ root.addEventListener('dragover', (event) => {
707
+ if (!event.dataTransfer?.types?.includes(TILE_DRAG_TYPE)) return
708
+ event.preventDefault()
709
+ event.dataTransfer.dropEffect = 'copy'
710
+ })
711
+ root.addEventListener('dragleave', (event) => {
712
+ if (!root.contains(event.relatedTarget)) root.classList.remove('is-image-over')
713
+ })
714
+ root.addEventListener('drop', openDroppedImage)
715
+
716
+ this.brushButton.addEventListener('click', () => this.setTool('brush'))
717
+ this.terrainBrushButton.addEventListener('click', () => this.setTool('terrain'))
718
+ this.eraserButton.addEventListener('click', () => this.setTool('eraser'))
719
+ this.undoButton.addEventListener('click', () => this.undo())
720
+ this.redoButton.addEventListener('click', () => this.redo())
721
+ addLayerButton.addEventListener('click', () => this.addLayer())
722
+
723
+ const commitGrid = () => {
724
+ const columns = clampInteger(columnsInput.input.value, 1, MAX_GRID_SIDE, this.map.columns)
725
+ const rows = clampInteger(rowsInput.input.value, 1, MAX_GRID_SIDE, this.map.rows)
726
+ const cellSize = this.map.cellSize
727
+ if (columns * cellSize > MAX_EXPORT_SIDE || rows * cellSize > MAX_EXPORT_SIDE) {
728
+ this.reportError(`导出尺寸不能超过 ${MAX_EXPORT_SIDE} × ${MAX_EXPORT_SIDE} px`)
729
+ columnsInput.input.value = String(this.map.columns)
730
+ rowsInput.input.value = String(this.map.rows)
731
+ return
732
+ }
733
+ const changed = columns !== this.map.columns || rows !== this.map.rows || cellSize !== this.map.cellSize
734
+ if (!changed) return
735
+ this.map.columns = columns
736
+ this.map.rows = rows
737
+ this.map.cellSize = cellSize
738
+ this.map.layers = this.map.layers.map((layer) => ({
739
+ ...layer,
740
+ cells: normalizeCells(layer.cells, columns, rows, this.map.sources),
741
+ }))
742
+ this.future = []
743
+ this.lockNodeAspectRatio()
744
+ this.scheduleRendererResize()
745
+ void this.renderTiles()
746
+ this.notifyChange()
747
+ }
748
+ columnsInput.input.addEventListener('change', commitGrid)
749
+ rowsInput.input.addEventListener('change', commitGrid)
750
+
751
+ this.setTool(this.map.activeTool, false)
752
+ this.bindTilesetSelection()
753
+ this.setAtlasZoom(this.atlasZoom)
754
+ this.renderPalette()
755
+ this.renderLayerList()
756
+ this.updateHeaderSummary()
757
+ this.updateHistoryButtons()
758
+ }
759
+
760
+ numberField(labelText, value, minimum, maximum, suffix = '') {
761
+ const label = makeElement('label', 'tile-map-number-field')
762
+ label.appendChild(makeElement('span', null, labelText))
763
+ const input = document.createElement('input')
764
+ input.type = 'number'
765
+ input.min = String(minimum)
766
+ input.max = String(maximum)
767
+ input.step = '1'
768
+ input.value = String(value)
769
+ label.appendChild(input)
770
+ if (suffix) label.appendChild(makeElement('span', 'tile-map-unit', suffix))
771
+ return { label, input }
772
+ }
773
+
774
+ addLayer() {
775
+ this.pushHistory(this.layerSnapshot())
776
+ const layer = {
777
+ id: createLayerId(),
778
+ name: `图层 ${this.map.layers.length + 1}`,
779
+ cells: {},
780
+ }
781
+ this.map.layers.push(layer)
782
+ this.map.activeLayerId = layer.id
783
+ this.future = []
784
+ this.renderLayerList()
785
+ this.renderTiles()
786
+ this.notifyChange()
787
+ }
788
+
789
+ deleteLayer(layerId) {
790
+ if (this.map.layers.length <= 1) return
791
+ const index = this.map.layers.findIndex((layer) => layer.id === layerId)
792
+ if (index < 0) return
793
+ const layer = this.map.layers[index]
794
+ if (Object.keys(layer.cells).length && !window.confirm(`确定删除“${layer.name}”及其中的所有地砖?`)) return
795
+ this.pushHistory(this.layerSnapshot())
796
+ this.map.layers.splice(index, 1)
797
+ if (this.map.activeLayerId === layerId) {
798
+ this.map.activeLayerId = this.map.layers[Math.min(index, this.map.layers.length - 1)].id
799
+ }
800
+ this.future = []
801
+ this.renderLayerList()
802
+ this.renderTiles()
803
+ this.notifyChange()
804
+ }
805
+
806
+ selectLayer(layerId) {
807
+ if (layerId === this.map.activeLayerId || !this.map.layers.some((layer) => layer.id === layerId)) return
808
+ this.map.activeLayerId = layerId
809
+ this.renderLayerList()
810
+ this.notifyChange()
811
+ }
812
+
813
+ renameLayer(layerId, value) {
814
+ const layer = this.map.layers.find((candidate) => candidate.id === layerId)
815
+ if (!layer) return
816
+ const name = safeName(value, layer.name)
817
+ if (name === layer.name) {
818
+ this.renderLayerList()
819
+ return
820
+ }
821
+ this.pushHistory(this.layerSnapshot())
822
+ layer.name = name
823
+ this.future = []
824
+ this.renderLayerList()
825
+ this.notifyChange()
826
+ }
827
+
828
+ clearLayerDragStyles() {
829
+ if (!this.layerList) return
830
+ for (const row of this.layerList.children) {
831
+ row.classList.remove('is-dragging', 'is-drop-before', 'is-drop-after')
832
+ }
833
+ }
834
+
835
+ reorderLayer(draggedLayerId, targetLayerId, placeAfter) {
836
+ const reordered = reorderLayersByVisualDrop(this.map.layers, draggedLayerId, targetLayerId, placeAfter)
837
+ if (reordered === this.map.layers || reordered.every((layer, index) => layer === this.map.layers[index])) return
838
+ this.pushHistory(this.layerSnapshot())
839
+ this.map.layers = reordered
840
+ this.future = []
841
+ this.renderLayerList()
842
+ this.renderTiles()
843
+ this.notifyChange()
844
+ }
845
+
846
+ renderLayerList() {
847
+ if (!this.layerList) return
848
+ this.layerList.replaceChildren()
849
+ for (const layer of [...this.map.layers].reverse()) {
850
+ const row = makeElement('div', 'tile-map-layer-item')
851
+ row.classList.toggle('is-active', layer.id === this.map.activeLayerId)
852
+ row.dataset.layerId = layer.id
853
+ row.tabIndex = 0
854
+
855
+ const reorderHandle = makeElement('span', 'tile-map-layer-drag-handle', '⠿')
856
+ reorderHandle.draggable = true
857
+ reorderHandle.title = `拖拽调整“${layer.name}”的顺序`
858
+ reorderHandle.setAttribute('aria-label', reorderHandle.title)
859
+ reorderHandle.addEventListener('pointerdown', (event) => event.stopPropagation())
860
+ reorderHandle.addEventListener('click', (event) => event.stopPropagation())
861
+ reorderHandle.addEventListener('dragstart', (event) => {
862
+ this.draggedLayerId = layer.id
863
+ row.classList.add('is-dragging')
864
+ event.dataTransfer.effectAllowed = 'move'
865
+ event.dataTransfer.setData('text/plain', layer.id)
866
+ })
867
+ reorderHandle.addEventListener('dragend', () => {
868
+ this.draggedLayerId = null
869
+ this.clearLayerDragStyles()
870
+ })
871
+
872
+ const thumbnail = document.createElement('canvas')
873
+ thumbnail.className = 'tile-map-layer-thumbnail'
874
+ thumbnail.width = 112
875
+ thumbnail.height = 72
876
+ thumbnail.setAttribute('aria-hidden', 'true')
877
+
878
+ const name = document.createElement('input')
879
+ name.type = 'text'
880
+ name.className = 'tile-map-layer-name'
881
+ name.value = layer.name
882
+ name.maxLength = 80
883
+ name.setAttribute('aria-label', '图层名称')
884
+ name.addEventListener('pointerdown', (event) => event.stopPropagation())
885
+ name.addEventListener('click', (event) => event.stopPropagation())
886
+ name.addEventListener('focus', () => {
887
+ if (this.map.activeLayerId === layer.id) return
888
+ this.map.activeLayerId = layer.id
889
+ for (const item of this.layerList?.children || []) {
890
+ item.classList.toggle('is-active', item.dataset.layerId === layer.id)
891
+ }
892
+ this.notifyChange()
893
+ })
894
+ name.addEventListener('change', () => this.renameLayer(layer.id, name.value))
895
+ name.addEventListener('keydown', (event) => {
896
+ if (event.key === 'Enter') name.blur()
897
+ })
898
+
899
+ const remove = makeButton('×', 'tile-map-layer-delete')
900
+ remove.disabled = this.map.layers.length <= 1
901
+ remove.title = remove.disabled ? '至少保留一个图层' : `删除“${layer.name}”`
902
+ remove.setAttribute('aria-label', remove.title)
903
+ remove.addEventListener('pointerdown', (event) => event.stopPropagation())
904
+ remove.addEventListener('click', (event) => {
905
+ event.stopPropagation()
906
+ this.deleteLayer(layer.id)
907
+ })
908
+
909
+ row.append(reorderHandle, thumbnail, name, remove)
910
+ row.addEventListener('click', () => this.selectLayer(layer.id))
911
+ row.addEventListener('dragover', (event) => {
912
+ if (!this.draggedLayerId || this.draggedLayerId === layer.id) return
913
+ event.preventDefault()
914
+ event.dataTransfer.dropEffect = 'move'
915
+ const placeAfter = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2
916
+ this.clearLayerDragStyles()
917
+ row.classList.add(placeAfter ? 'is-drop-after' : 'is-drop-before')
918
+ const draggedRow = this.layerList?.querySelector(`[data-layer-id="${CSS.escape(this.draggedLayerId)}"]`)
919
+ draggedRow?.classList.add('is-dragging')
920
+ })
921
+ row.addEventListener('drop', (event) => {
922
+ if (!this.draggedLayerId || this.draggedLayerId === layer.id) return
923
+ event.preventDefault()
924
+ const draggedLayerId = this.draggedLayerId
925
+ const placeAfter = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2
926
+ this.draggedLayerId = null
927
+ this.clearLayerDragStyles()
928
+ this.reorderLayer(draggedLayerId, layer.id, placeAfter)
929
+ })
930
+ row.addEventListener('keydown', (event) => {
931
+ if ((event.key === 'Enter' || event.key === ' ') && event.target === row) {
932
+ event.preventDefault()
933
+ this.selectLayer(layer.id)
934
+ }
935
+ })
936
+ this.layerList.appendChild(row)
937
+ }
938
+ void this.renderLayerThumbnails()
939
+ }
940
+
941
+ async renderLayerThumbnails() {
942
+ if (!this.layerList) return
943
+ const sequence = ++this.layerRenderSequence
944
+ const sourceMap = new Map(this.map.sources.map((source) => [source.id, source]))
945
+ const usedSourceIds = [...new Set(this.map.layers.flatMap((layer) => (
946
+ Object.values(layer.cells).map((cell) => cell.sourceId)
947
+ )))]
948
+ const loaded = new Map()
949
+ await Promise.all(usedSourceIds.map(async (sourceId) => {
950
+ const source = sourceMap.get(sourceId)
951
+ if (!source) return
952
+ try {
953
+ loaded.set(sourceId, await this.htmlImageForSource(source))
954
+ } catch {
955
+ /* Missing assets stay transparent in the thumbnail. */
956
+ }
957
+ }))
958
+ if (this.disposed || sequence !== this.layerRenderSequence || !this.layerList) return
959
+
960
+ for (const row of this.layerList.children) {
961
+ const layer = this.map.layers.find((candidate) => candidate.id === row.dataset.layerId)
962
+ const canvas = row.querySelector('.tile-map-layer-thumbnail')
963
+ if (!layer || !canvas) continue
964
+ const context = canvas.getContext('2d')
965
+ context.imageSmoothingEnabled = false
966
+ context.clearRect(0, 0, canvas.width, canvas.height)
967
+ context.fillStyle = '#101720'
968
+ context.fillRect(0, 0, canvas.width, canvas.height)
969
+ const scale = Math.min(canvas.width / this.map.columns, canvas.height / this.map.rows)
970
+ const tileWidth = Math.max(0.5, scale)
971
+ const tileHeight = Math.max(0.5, scale)
972
+ const offsetX = (canvas.width - this.map.columns * scale) / 2
973
+ const offsetY = (canvas.height - this.map.rows * scale) / 2
974
+ for (const [key, cell] of Object.entries(layer.cells)) {
975
+ const source = sourceMap.get(cell.sourceId)
976
+ const image = loaded.get(cell.sourceId)
977
+ if (!source || !image) continue
978
+ const [x, y] = key.split(',').map(Number)
979
+ const frame = sourceTileFrame(source, cell.tileIndex)
980
+ context.drawImage(
981
+ image,
982
+ frame.x,
983
+ frame.y,
984
+ frame.width,
985
+ frame.height,
986
+ offsetX + x * scale,
987
+ offsetY + y * scale,
988
+ tileWidth,
989
+ tileHeight,
990
+ )
991
+ }
992
+ }
993
+ }
994
+
995
+ async mountPixi() {
996
+ await ensurePixiRuntime()
997
+ const PIXI = globalThis.PIXI
998
+ const CompositeTilemap = PIXI?.tilemap?.CompositeTilemap
999
+ if (!PIXI?.Application || !CompositeTilemap) {
1000
+ this.reportError('PixiJS 地图渲染器未加载')
1001
+ return
1002
+ }
1003
+ try {
1004
+ const app = new PIXI.Application()
1005
+ await app.init({
1006
+ resizeTo: this.stageHost,
1007
+ preference: 'webgl',
1008
+ backgroundColor: 0x10151c,
1009
+ antialias: false,
1010
+ roundPixels: true,
1011
+ autoDensity: true,
1012
+ resolution: Math.min(2, window.devicePixelRatio || 1),
1013
+ })
1014
+ if (this.disposed) {
1015
+ app.destroy(true)
1016
+ return
1017
+ }
1018
+ this.app = app
1019
+ app.canvas.classList.add('tile-map-pixi-canvas')
1020
+ app.canvas.style.touchAction = 'none'
1021
+ this.stageHost.prepend(app.canvas)
1022
+ this.backgroundLayer = new PIXI.Graphics()
1023
+ this.tileLayer = new PIXI.Container()
1024
+ this.gridLayer = new PIXI.Graphics()
1025
+ this.hoverLayer = new PIXI.Graphics()
1026
+ app.stage.sortableChildren = true
1027
+ this.backgroundLayer.zIndex = 0
1028
+ this.gridLayer.zIndex = 10
1029
+ this.tileLayer.zIndex = 20
1030
+ this.hoverLayer.zIndex = 30
1031
+ app.stage.addChild(this.backgroundLayer, this.tileLayer, this.gridLayer, this.hoverLayer)
1032
+ this.bindPixiPointerEvents(app.canvas)
1033
+ this.resizeObserver = new ResizeObserver(() => this.scheduleRendererResize())
1034
+ this.resizeObserver.observe(this.stageHost)
1035
+ this.resizeRendererToHost()
1036
+ await this.renderTiles()
1037
+ } catch (error) {
1038
+ this.reportError(error)
1039
+ }
1040
+ }
1041
+
1042
+ bindPixiPointerEvents(canvas) {
1043
+ canvas.addEventListener('contextmenu', (event) => event.preventDefault())
1044
+ canvas.addEventListener('pointerdown', (event) => {
1045
+ if (event.button !== 0) return
1046
+ event.preventDefault()
1047
+ event.stopPropagation()
1048
+ this.activate()
1049
+ this.painting = true
1050
+ this.strokeChanged = false
1051
+ this.strokeBefore = this.layerSnapshot()
1052
+ canvas.setPointerCapture?.(event.pointerId)
1053
+ this.paintAtEvent(event)
1054
+ })
1055
+ canvas.addEventListener('pointermove', (event) => {
1056
+ const point = this.eventCell(event)
1057
+ this.drawHover(point)
1058
+ if (!this.painting) return
1059
+ event.preventDefault()
1060
+ this.paintAtEvent(event)
1061
+ })
1062
+ const finish = (event) => {
1063
+ if (!this.painting) return
1064
+ this.painting = false
1065
+ if (this.strokeChanged && this.strokeBefore) {
1066
+ this.pushHistory(this.strokeBefore)
1067
+ this.future = []
1068
+ this.renderLayerList()
1069
+ this.notifyChange()
1070
+ }
1071
+ this.strokeBefore = null
1072
+ this.strokeChanged = false
1073
+ }
1074
+ canvas.addEventListener('pointerup', finish)
1075
+ canvas.addEventListener('pointercancel', finish)
1076
+ canvas.addEventListener('pointerleave', () => {
1077
+ this.hoverLayer?.clear()
1078
+ finish()
1079
+ })
1080
+ }
1081
+
1082
+ clientToScreen(clientX, clientY) {
1083
+ if (!this.app?.screen || !this.app.canvas) return null
1084
+ const rect = this.app.canvas.getBoundingClientRect()
1085
+ if (rect.width <= 0 || rect.height <= 0) return null
1086
+ // The outer canvas world is CSS-scaled. Convert visual client coordinates
1087
+ // back to Pixi's logical renderer coordinates before applying world transforms.
1088
+ return {
1089
+ x: (clientX - rect.left) * this.app.screen.width / rect.width,
1090
+ y: (clientY - rect.top) * this.app.screen.height / rect.height,
1091
+ }
1092
+ }
1093
+
1094
+ eventCell(event) {
1095
+ if (!this.app) return null
1096
+ const screenPoint = this.clientToScreen(event.clientX, event.clientY)
1097
+ if (!screenPoint) return null
1098
+ const cellWidth = this.app.screen.width / this.map.columns
1099
+ const cellHeight = this.app.screen.height / this.map.rows
1100
+ const x = Math.floor(screenPoint.x / cellWidth)
1101
+ const y = Math.floor(screenPoint.y / cellHeight)
1102
+ if (x < 0 || y < 0 || x >= this.map.columns || y >= this.map.rows) return null
1103
+ return { x, y }
1104
+ }
1105
+
1106
+ paintAtEvent(event) {
1107
+ const point = this.eventCell(event)
1108
+ if (!point) return
1109
+ const key = `${point.x},${point.y}`
1110
+ const layer = this.activeLayer()
1111
+ const erase = this.map.activeTool === 'eraser'
1112
+ if (erase) {
1113
+ if (!layer.cells[key]) return
1114
+ delete layer.cells[key]
1115
+ } else if (this.map.activeTool === 'terrain') {
1116
+ const brushImage = this.activeTerrainBrushImage()
1117
+ if (!brushImage) {
1118
+ if (this.selectedStatus) this.selectedStatus.textContent = '请先在颜料盘中选择一个完整的 4×4 地形图集'
1119
+ return
1120
+ }
1121
+ this.paintAutoTerrainAt(layer, brushImage, point.x, point.y)
1122
+ } else {
1123
+ if (!this.map.activeBrush) {
1124
+ if (this.selectedStatus) this.selectedStatus.textContent = '请先在左侧栏导入图片并选择地砖'
1125
+ return
1126
+ }
1127
+ const brush = this.map.activeBrush
1128
+ const brushColumns = brush.columns || 1
1129
+ const brushRows = brush.rows || 1
1130
+ const primarySource = this.map.sources.find((candidate) => candidate.id === brush.sourceId)
1131
+ const sourceMap = new Map(this.map.sources.map((source) => [source.id, source]))
1132
+ const sourceColumn = primarySource ? brush.tileIndex % primarySource.columns : 0
1133
+ const sourceRow = primarySource ? Math.floor(brush.tileIndex / primarySource.columns) : 0
1134
+ let changed = false
1135
+ for (let brushY = 0; brushY < brushRows; brushY += 1) {
1136
+ for (let brushX = 0; brushX < brushColumns; brushX += 1) {
1137
+ const brushTile = Array.isArray(brush.tiles)
1138
+ ? brush.tiles[brushY * brushColumns + brushX]
1139
+ : primarySource
1140
+ ? {
1141
+ sourceId: primarySource.id,
1142
+ tileIndex: (sourceRow + brushY) * primarySource.columns + sourceColumn + brushX,
1143
+ }
1144
+ : null
1145
+ if (!brushTile || !sourceMap.has(brushTile.sourceId)) continue
1146
+ const targetX = point.x + brushX
1147
+ const targetY = point.y + brushY
1148
+ if (targetX >= this.map.columns || targetY >= this.map.rows) continue
1149
+ const targetKey = `${targetX},${targetY}`
1150
+ const next = {
1151
+ sourceId: brushTile.sourceId,
1152
+ tileIndex: brushTile.tileIndex,
1153
+ }
1154
+ const current = layer.cells[targetKey]
1155
+ if (current?.sourceId === next.sourceId && current?.tileIndex === next.tileIndex) continue
1156
+ layer.cells[targetKey] = next
1157
+ changed = true
1158
+ }
1159
+ }
1160
+ if (!changed) return
1161
+ }
1162
+ this.strokeChanged = true
1163
+ this.renderTiles()
1164
+ this.updateHeaderSummary()
1165
+ }
1166
+
1167
+ activeTerrainBrushImage() {
1168
+ const sourceIds = new Set(this.map.sources.map((source) => source.id))
1169
+ return this.map.brushImages.find((brushImage) => (
1170
+ brushImage.id === this.map.activeTerrainBrushId &&
1171
+ brushImage.columns === AUTO_TERRAIN_ATLAS_SIDE &&
1172
+ brushImage.rows === AUTO_TERRAIN_ATLAS_SIDE &&
1173
+ brushImage.tileSourceIds.length >= AUTO_TERRAIN_ATLAS_SIDE ** 2 &&
1174
+ brushImage.tileSourceIds.slice(0, AUTO_TERRAIN_ATLAS_SIDE ** 2).every((sourceId) => sourceIds.has(sourceId))
1175
+ )) || null
1176
+ }
1177
+
1178
+ terrainBrushImageForTile(tile) {
1179
+ if (!tile?.sourceId) return null
1180
+ return this.map.brushImages.find((brushImage) => (
1181
+ brushImage.columns === AUTO_TERRAIN_ATLAS_SIDE &&
1182
+ brushImage.rows === AUTO_TERRAIN_ATLAS_SIDE &&
1183
+ brushImage.tileSourceIds.length >= AUTO_TERRAIN_ATLAS_SIDE ** 2 &&
1184
+ brushImage.tileSourceIds.includes(tile.sourceId)
1185
+ )) || null
1186
+ }
1187
+
1188
+ paintAutoTerrainAt(layer, brushImage, terrainX, terrainY) {
1189
+ const pureTerrainSourceId = brushImage.tileSourceIds[autoTerrainTileIndex(15)]
1190
+ if (!pureTerrainSourceId) return
1191
+ const occupiedCells = new Set(
1192
+ Object.entries(layer.cells)
1193
+ .filter(([, cell]) => cell.sourceId === pureTerrainSourceId)
1194
+ .map(([key]) => key),
1195
+ )
1196
+ occupiedCells.add(`${terrainX},${terrainY}`)
1197
+ for (let y = terrainY - 1; y <= terrainY + 1; y += 1) {
1198
+ for (let x = terrainX - 1; x <= terrainX + 1; x += 1) {
1199
+ if (x < 0 || y < 0 || x >= this.map.columns || y >= this.map.rows) continue
1200
+ const key = `${x},${y}`
1201
+ const mask = autoTerrainMaskAt(occupiedCells, x, y)
1202
+ if (!mask) continue
1203
+ const tileSourceId = brushImage.tileSourceIds[autoTerrainTileIndex(mask)]
1204
+ if (!tileSourceId || !this.map.sources.some((source) => source.id === tileSourceId)) continue
1205
+ layer.cells[key] = { sourceId: tileSourceId, tileIndex: 0 }
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ setTool(tool, notify = true) {
1211
+ this.map.activeTool = ['brush', 'terrain', 'eraser'].includes(tool) ? tool : 'brush'
1212
+ this.brushButton?.classList.toggle('is-active', this.map.activeTool === 'brush')
1213
+ this.terrainBrushButton?.classList.toggle('is-active', this.map.activeTool === 'terrain')
1214
+ this.eraserButton?.classList.toggle('is-active', this.map.activeTool === 'eraser')
1215
+ this.drawAtlasSelection()
1216
+ if (notify) this.notifyChange()
1217
+ }
1218
+
1219
+ pushHistory(snapshot) {
1220
+ this.history.push(snapshot)
1221
+ if (this.history.length > 50) this.history.shift()
1222
+ this.updateHistoryButtons()
1223
+ }
1224
+
1225
+ undo() {
1226
+ const previous = this.history.pop()
1227
+ if (!previous) return
1228
+ this.future.push(this.layerSnapshot())
1229
+ this.restoreLayerSnapshot(previous)
1230
+ this.renderTiles()
1231
+ this.renderLayerList()
1232
+ this.notifyChange()
1233
+ this.updateHistoryButtons()
1234
+ }
1235
+
1236
+ redo() {
1237
+ const next = this.future.pop()
1238
+ if (!next) return
1239
+ this.history.push(this.layerSnapshot())
1240
+ this.restoreLayerSnapshot(next)
1241
+ this.renderTiles()
1242
+ this.renderLayerList()
1243
+ this.notifyChange()
1244
+ this.updateHistoryButtons()
1245
+ }
1246
+
1247
+ updateHistoryButtons() {
1248
+ if (this.undoButton) this.undoButton.disabled = !this.history.length
1249
+ if (this.redoButton) this.redoButton.disabled = !this.future.length
1250
+ }
1251
+
1252
+ updateHeaderSummary() {
1253
+ if (this.specLabel) {
1254
+ this.specLabel.textContent = `瓦片规格 ${this.map.cellSize} × ${this.map.cellSize} px`
1255
+ }
1256
+ }
1257
+
1258
+ drawGrid() {
1259
+ if (!this.app?.screen || !this.gridLayer || !this.backgroundLayer) return
1260
+ const width = this.app.screen.width
1261
+ const height = this.app.screen.height
1262
+ const cellWidth = width / this.map.columns
1263
+ const cellHeight = height / this.map.rows
1264
+ this.backgroundLayer.clear()
1265
+ this.backgroundLayer.rect(0, 0, width, height).fill({ color: 0x17202b, alpha: 1 })
1266
+ this.gridLayer.clear()
1267
+ const scaleX = Math.max(0.0001, Math.abs(this.outerScale || 1))
1268
+ const scaleY = scaleX
1269
+ const renderResolution = Math.max(1, Number(this.app.renderer?.resolution) || 1)
1270
+ const renderPixel = 1 / renderResolution
1271
+ const pixelAlignedStrip = (center, requestedSize) => {
1272
+ const size = Math.max(renderPixel, Math.ceil(requestedSize * renderResolution) / renderResolution)
1273
+ return {
1274
+ start: Math.round((center - size / 2) * renderResolution) / renderResolution,
1275
+ size,
1276
+ }
1277
+ }
1278
+ // Filled strips are stable under fractional WebGL transforms. A nominal
1279
+ // 1px stroke can fall fully between samples with antialiasing disabled.
1280
+ // Clamp and snap each strip to physical renderer pixels before outer CSS scaling.
1281
+ const minorWidth = Math.max(renderPixel, MINOR_GRID_SCREEN_WIDTH / scaleX)
1282
+ const minorHeight = Math.max(renderPixel, MINOR_GRID_SCREEN_WIDTH / scaleY)
1283
+ const showMinorGrid = Math.min(cellWidth, cellHeight) * scaleX >= MIN_MINOR_GRID_CELL_SCREEN_SIZE
1284
+ if (showMinorGrid) {
1285
+ for (let x = 1; x < this.map.columns; x += 1) {
1286
+ if (x % GRID_BLOCK_SIZE === 0) continue
1287
+ const strip = pixelAlignedStrip(x * cellWidth, minorWidth)
1288
+ this.gridLayer.rect(strip.start, 0, strip.size, height)
1289
+ }
1290
+ for (let y = 1; y < this.map.rows; y += 1) {
1291
+ if (y % GRID_BLOCK_SIZE === 0) continue
1292
+ const strip = pixelAlignedStrip(y * cellHeight, minorHeight)
1293
+ this.gridLayer.rect(0, strip.start, width, strip.size)
1294
+ }
1295
+ this.gridLayer.fill({ color: 0x718096, alpha: 0.32 })
1296
+ }
1297
+
1298
+ const majorWidth = Math.max(renderPixel, MAJOR_GRID_SCREEN_WIDTH / scaleX)
1299
+ const majorHeight = Math.max(renderPixel, MAJOR_GRID_SCREEN_WIDTH / scaleY)
1300
+ for (let x = 0; x <= this.map.columns; x += GRID_BLOCK_SIZE) {
1301
+ const strip = pixelAlignedStrip(x * cellWidth, majorWidth)
1302
+ this.gridLayer.rect(strip.start, 0, strip.size, height)
1303
+ }
1304
+ if (this.map.columns % GRID_BLOCK_SIZE !== 0) {
1305
+ const strip = pixelAlignedStrip(width, majorWidth)
1306
+ this.gridLayer.rect(strip.start, 0, strip.size, height)
1307
+ }
1308
+ for (let y = 0; y <= this.map.rows; y += GRID_BLOCK_SIZE) {
1309
+ const strip = pixelAlignedStrip(y * cellHeight, majorHeight)
1310
+ this.gridLayer.rect(0, strip.start, width, strip.size)
1311
+ }
1312
+ if (this.map.rows % GRID_BLOCK_SIZE !== 0) {
1313
+ const strip = pixelAlignedStrip(height, majorHeight)
1314
+ this.gridLayer.rect(0, strip.start, width, strip.size)
1315
+ }
1316
+ this.gridLayer.fill({ color: 0xa0aec0, alpha: 0.56 })
1317
+ }
1318
+
1319
+ setOuterScale(value) {
1320
+ const next = Math.max(0.01, Math.abs(Number(value) || 1))
1321
+ if (Math.abs(next - this.outerScale) < 0.0001) return
1322
+ this.outerScale = next
1323
+ this.drawGrid()
1324
+ }
1325
+
1326
+ drawHover(point) {
1327
+ if (!this.app?.screen || !this.hoverLayer) return
1328
+ this.hoverLayer.clear()
1329
+ if (!point) return
1330
+ const color = this.map.activeTool === 'eraser' ? 0xff6b6b : 0x4fd1c5
1331
+ const brushColumns = this.map.activeTool === 'brush' ? this.map.activeBrush?.columns || 1 : 1
1332
+ const brushRows = this.map.activeTool === 'brush' ? this.map.activeBrush?.rows || 1 : 1
1333
+ const cellWidth = this.app.screen.width / this.map.columns
1334
+ const cellHeight = this.app.screen.height / this.map.rows
1335
+ const width = Math.min(brushColumns, this.map.columns - point.x) * cellWidth
1336
+ const height = Math.min(brushRows, this.map.rows - point.y) * cellHeight
1337
+ this.hoverLayer
1338
+ .rect(point.x * cellWidth, point.y * cellHeight, width, height)
1339
+ .fill({ color, alpha: 0.22 })
1340
+ .stroke({ color, alpha: 0.9, width: 2 / Math.max(0.2, this.outerScale || 1) })
1341
+ }
1342
+
1343
+ scheduleRendererResize() {
1344
+ if (this.resizeFrame || this.disposed) return
1345
+ this.resizeFrame = requestAnimationFrame(() => {
1346
+ this.resizeFrame = 0
1347
+ this.resizeRendererToHost()
1348
+ })
1349
+ }
1350
+
1351
+ resizeRendererToHost() {
1352
+ if (!this.app?.renderer || !this.stageHost) return
1353
+ const width = Math.max(1, Math.round(this.stageHost.clientWidth))
1354
+ const height = Math.max(1, Math.round(this.stageHost.clientHeight))
1355
+ if (this.app.screen.width !== width || this.app.screen.height !== height) {
1356
+ this.app.renderer.resize(width, height)
1357
+ }
1358
+ this.layoutMapToStage()
1359
+ }
1360
+
1361
+ layoutMapToStage() {
1362
+ if (!this.app) return
1363
+ this.drawGrid()
1364
+ this.resizeRenderedTiles()
1365
+ }
1366
+
1367
+ resizeRenderedTiles() {
1368
+ if (!this.app?.screen || !this.tileLayer) return
1369
+ const cellWidth = this.app.screen.width / this.map.columns
1370
+ const cellHeight = this.app.screen.height / this.map.rows
1371
+ for (const mapLayer of this.tileLayer.children) {
1372
+ for (const sourceLayer of mapLayer.children || []) {
1373
+ const sourceWidth = Number(sourceLayer.tileMapSourceWidth)
1374
+ const sourceHeight = Number(sourceLayer.tileMapSourceHeight)
1375
+ if (sourceWidth > 0 && sourceHeight > 0) {
1376
+ sourceLayer.scale.set(cellWidth / sourceWidth, cellHeight / sourceHeight)
1377
+ }
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ lockNodeAspectRatio() {
1383
+ const width = Math.max(TILE_MAP_NODE_MIN_WIDTH, this.container.offsetWidth || Number(this.item.width) || TILE_MAP_NODE_WIDTH)
1384
+ const height = tileMapNodeHeightForWidth(this.map, width)
1385
+ this.item.width = Math.round(width)
1386
+ this.item.height = height
1387
+ this.container.style.width = `${this.item.width}px`
1388
+ this.container.style.height = `${height}px`
1389
+ this.container.classList.add('is-manual-size')
1390
+ }
1391
+
1392
+ async sourceBaseTexture(source) {
1393
+ if (this.baseTextureCache.has(source.id)) return this.baseTextureCache.get(source.id)
1394
+ const PIXI = globalThis.PIXI
1395
+ const url = this.callbacks.resolveImageUrl(source.assetId)
1396
+ const promise = PIXI.Assets.load(url).then((texture) => {
1397
+ if (texture?.source) texture.source.scaleMode = 'nearest'
1398
+ return texture
1399
+ })
1400
+ this.baseTextureCache.set(source.id, promise)
1401
+ return promise
1402
+ }
1403
+
1404
+ async renderTiles() {
1405
+ if (!this.app?.screen || !this.tileLayer) return
1406
+ const sequence = ++this.renderSequence
1407
+ const layerEntries = this.map.layers.map((layer) => Object.entries(layer.cells))
1408
+ const sourceMap = new Map(this.map.sources.map((source) => [source.id, source]))
1409
+ const usedSourceIds = [...new Set(layerEntries.flatMap((entries) => entries.map(([, cell]) => cell.sourceId)))]
1410
+ const prepared = new Map()
1411
+ await Promise.all(usedSourceIds.map(async (sourceId) => {
1412
+ const source = sourceMap.get(sourceId)
1413
+ if (!source) return
1414
+ try {
1415
+ prepared.set(sourceId, await this.sourceBaseTexture(source))
1416
+ } catch {
1417
+ /* A missing source remains empty until the asset is restored. */
1418
+ }
1419
+ }))
1420
+ if (this.disposed || sequence !== this.renderSequence || !this.tileLayer) return
1421
+ for (const child of this.tileLayer.removeChildren()) child.destroy?.({ children: true })
1422
+ const CompositeTilemap = globalThis.PIXI.tilemap.CompositeTilemap
1423
+ const cellWidth = this.app.screen.width / this.map.columns
1424
+ const cellHeight = this.app.screen.height / this.map.rows
1425
+ for (const entries of layerEntries) {
1426
+ const mapLayer = new globalThis.PIXI.Container()
1427
+ const layerSourceIds = [...new Set(entries.map(([, cell]) => cell.sourceId))]
1428
+ for (const sourceId of layerSourceIds) {
1429
+ const source = sourceMap.get(sourceId)
1430
+ const texture = prepared.get(sourceId)
1431
+ if (!source || !texture) continue
1432
+ const sourceLayer = new CompositeTilemap()
1433
+ sourceLayer.roundPixels = true
1434
+ sourceLayer.tileMapSourceWidth = source.tileWidth
1435
+ sourceLayer.tileMapSourceHeight = source.tileHeight
1436
+ sourceLayer.scale.set(cellWidth / source.tileWidth, cellHeight / source.tileHeight)
1437
+ for (const [key, cell] of entries) {
1438
+ if (cell.sourceId !== sourceId) continue
1439
+ const [x, y] = key.split(',').map(Number)
1440
+ const frame = sourceTileFrame(source, cell.tileIndex)
1441
+ sourceLayer.tile(texture, x * source.tileWidth, y * source.tileHeight, {
1442
+ u: frame.x,
1443
+ v: frame.y,
1444
+ tileWidth: source.tileWidth,
1445
+ tileHeight: source.tileHeight,
1446
+ })
1447
+ }
1448
+ mapLayer.addChild(sourceLayer)
1449
+ }
1450
+ this.tileLayer.addChild(mapLayer)
1451
+ }
1452
+ }
1453
+
1454
+ setAtlasZoom(value) {
1455
+ this.atlasZoom = Math.min(MAX_ATLAS_ZOOM, Math.max(MIN_ATLAS_ZOOM, Number(value) || 1))
1456
+ if (this.tilesetCanvas) this.tilesetCanvas.style.width = `${this.atlasZoom * 100}%`
1457
+ if (this.tilesetSelectionCanvas) this.tilesetSelectionCanvas.style.width = `${this.atlasZoom * 100}%`
1458
+ if (this.atlasZoomLabel) this.atlasZoomLabel.textContent = `${Math.round(this.atlasZoom * 100)}%`
1459
+ }
1460
+
1461
+ bindTilesetSelection() {
1462
+ const canvas = this.tilesetCanvas
1463
+ if (!canvas) return
1464
+ canvas.addEventListener('contextmenu', (event) => {
1465
+ event.preventDefault()
1466
+ event.stopPropagation()
1467
+ this.closeBrushContextMenu()
1468
+ const cell = this.atlasCellAtEvent(event)
1469
+ const bounds = this.atlasSelectionBounds()
1470
+ if (!cell || !bounds) return
1471
+ if (
1472
+ cell.column < bounds.startColumn || cell.column > bounds.endColumn ||
1473
+ cell.row < bounds.startRow || cell.row > bounds.endRow
1474
+ ) return
1475
+ this.openBrushContextMenu(event.clientX, event.clientY, bounds)
1476
+ })
1477
+ canvas.addEventListener('pointerdown', (event) => {
1478
+ if (event.button !== 0) return
1479
+ this.closeBrushContextMenu()
1480
+ const cell = this.atlasCellAtEvent(event)
1481
+ if (!cell?.tile) return
1482
+ event.preventDefault()
1483
+ event.stopPropagation()
1484
+ this.activate()
1485
+ if (this.map.activeTool === 'terrain') {
1486
+ const brushImage = this.terrainBrushImageForTile(cell.tile)
1487
+ const bounds = brushImage ? this.terrainBrushPaletteBounds(brushImage) : null
1488
+ if (!brushImage || !bounds) {
1489
+ this.reportError('自动地形画笔只能选择完整导入的 4×4 图集')
1490
+ return
1491
+ }
1492
+ this.map.activeTerrainBrushId = brushImage.id
1493
+ this.drawAtlasSelection()
1494
+ this.notifyChange()
1495
+ return
1496
+ }
1497
+ this.atlasDrag = {
1498
+ pointerId: event.pointerId,
1499
+ startColumn: cell.column,
1500
+ startRow: cell.row,
1501
+ endColumn: cell.column,
1502
+ endRow: cell.row,
1503
+ }
1504
+ canvas.setPointerCapture?.(event.pointerId)
1505
+ this.drawAtlasSelection()
1506
+ })
1507
+ canvas.addEventListener('pointermove', (event) => {
1508
+ if (!this.atlasDrag || this.atlasDrag.pointerId !== event.pointerId) return
1509
+ const cell = this.atlasCellAtEvent(event)
1510
+ if (!cell) return
1511
+ this.atlasDrag.endColumn = cell.column
1512
+ this.atlasDrag.endRow = cell.row
1513
+ this.drawAtlasSelection()
1514
+ })
1515
+ const finish = (event, commit) => {
1516
+ if (!this.atlasDrag || this.atlasDrag.pointerId !== event.pointerId) return
1517
+ const drag = this.atlasDrag
1518
+ this.atlasDrag = null
1519
+ if (commit) {
1520
+ const startColumn = Math.min(drag.startColumn, drag.endColumn)
1521
+ const startRow = Math.min(drag.startRow, drag.endRow)
1522
+ const endColumn = Math.max(drag.startColumn, drag.endColumn)
1523
+ const endRow = Math.max(drag.startRow, drag.endRow)
1524
+ const columns = endColumn - startColumn + 1
1525
+ const rows = endRow - startRow + 1
1526
+ const tiles = []
1527
+ for (let row = startRow; row <= endRow; row += 1) {
1528
+ for (let column = startColumn; column <= endColumn; column += 1) {
1529
+ const tile = this.atlasCells[row * ATLAS_COLUMNS + column]
1530
+ tiles.push(tile ? { sourceId: tile.sourceId, tileIndex: tile.tileIndex } : null)
1531
+ }
1532
+ }
1533
+ const firstTile = tiles.find(Boolean)
1534
+ if (firstTile) {
1535
+ this.map.activeBrush = {
1536
+ sourceId: firstTile.sourceId,
1537
+ tileIndex: firstTile.tileIndex,
1538
+ columns,
1539
+ rows,
1540
+ atlasIndex: startRow * ATLAS_COLUMNS + startColumn,
1541
+ tiles,
1542
+ }
1543
+ this.setTool('brush', false)
1544
+ if (this.selectedStatus) {
1545
+ this.selectedStatus.textContent = `已选择 ${this.map.activeBrush.columns}×${this.map.activeBrush.rows} 画笔`
1546
+ }
1547
+ this.notifyChange()
1548
+ }
1549
+ }
1550
+ this.drawAtlasSelection()
1551
+ }
1552
+ canvas.addEventListener('pointerup', (event) => finish(event, true))
1553
+ canvas.addEventListener('pointercancel', (event) => finish(event, false))
1554
+ }
1555
+
1556
+ atlasCellAtEvent(event) {
1557
+ const canvas = this.tilesetCanvas
1558
+ if (!canvas || !this.atlasCells.length) return null
1559
+ const rect = canvas.getBoundingClientRect()
1560
+ if (rect.width <= 0 || rect.height <= 0) return null
1561
+ const column = Math.floor((event.clientX - rect.left) * canvas.width / rect.width / ATLAS_CELL_SIZE)
1562
+ const row = Math.floor((event.clientY - rect.top) * canvas.height / rect.height / ATLAS_CELL_SIZE)
1563
+ const atlasRows = canvas.height / ATLAS_CELL_SIZE
1564
+ if (column < 0 || column >= ATLAS_COLUMNS || row < 0 || row >= atlasRows) return null
1565
+ return {
1566
+ tile: this.atlasCells[row * ATLAS_COLUMNS + column] || null,
1567
+ column,
1568
+ row,
1569
+ }
1570
+ }
1571
+
1572
+ atlasSelectionBounds(brush = this.atlasDrag || this.map.activeBrush) {
1573
+ if (!this.atlasDrag && this.map.activeTool === 'terrain') {
1574
+ return this.terrainBrushPaletteBounds(this.activeTerrainBrushImage())
1575
+ }
1576
+ if (!brush) return null
1577
+ if (this.atlasDrag && brush === this.atlasDrag) {
1578
+ return {
1579
+ startColumn: Math.min(brush.startColumn, brush.endColumn),
1580
+ startRow: Math.min(brush.startRow, brush.endRow),
1581
+ endColumn: Math.max(brush.startColumn, brush.endColumn),
1582
+ endRow: Math.max(brush.startRow, brush.endRow),
1583
+ }
1584
+ }
1585
+ const savedAtlasIndex = Number(brush.atlasIndex)
1586
+ const fallbackAtlasIndex = this.atlasCells.findIndex((tile) => (
1587
+ tile?.sourceId === brush.sourceId && tile.tileIndex === brush.tileIndex
1588
+ ))
1589
+ const atlasIndex = brush.atlasIndex !== null && brush.atlasIndex !== undefined &&
1590
+ Number.isInteger(savedAtlasIndex) && savedAtlasIndex >= 0
1591
+ ? savedAtlasIndex
1592
+ : fallbackAtlasIndex
1593
+ if (atlasIndex < 0) return null
1594
+ const startColumn = atlasIndex % ATLAS_COLUMNS
1595
+ const startRow = Math.floor(atlasIndex / ATLAS_COLUMNS)
1596
+ return {
1597
+ startColumn,
1598
+ startRow,
1599
+ endColumn: startColumn + (brush.columns || 1) - 1,
1600
+ endRow: startRow + (brush.rows || 1) - 1,
1601
+ }
1602
+ }
1603
+
1604
+ terrainBrushPaletteBounds(brushImage) {
1605
+ if (!brushImage || brushImage.columns !== AUTO_TERRAIN_ATLAS_SIDE || brushImage.rows !== AUTO_TERRAIN_ATLAS_SIDE) {
1606
+ return null
1607
+ }
1608
+ const sourceIds = new Set(brushImage.tileSourceIds.slice(0, AUTO_TERRAIN_ATLAS_SIDE ** 2))
1609
+ const positions = []
1610
+ for (let index = 0; index < this.atlasCells.length; index += 1) {
1611
+ if (!sourceIds.has(this.atlasCells[index]?.sourceId)) continue
1612
+ positions.push({ column: index % ATLAS_COLUMNS, row: Math.floor(index / ATLAS_COLUMNS) })
1613
+ }
1614
+ if (positions.length !== AUTO_TERRAIN_ATLAS_SIDE ** 2) return null
1615
+ const startColumn = Math.min(...positions.map((position) => position.column))
1616
+ const startRow = Math.min(...positions.map((position) => position.row))
1617
+ const endColumn = Math.max(...positions.map((position) => position.column))
1618
+ const endRow = Math.max(...positions.map((position) => position.row))
1619
+ if (
1620
+ endColumn - startColumn + 1 !== AUTO_TERRAIN_ATLAS_SIDE ||
1621
+ endRow - startRow + 1 !== AUTO_TERRAIN_ATLAS_SIDE
1622
+ ) return null
1623
+ return { startColumn, startRow, endColumn, endRow }
1624
+ }
1625
+
1626
+ drawAtlasSelection() {
1627
+ const canvas = this.tilesetSelectionCanvas
1628
+ if (!canvas) return
1629
+ const context = canvas.getContext('2d')
1630
+ context.clearRect(0, 0, canvas.width, canvas.height)
1631
+ const bounds = this.atlasSelectionBounds()
1632
+ if (!bounds) return
1633
+ const atlasLineScale = ATLAS_CELL_SIZE / 32
1634
+ const x = bounds.startColumn * ATLAS_CELL_SIZE
1635
+ const y = bounds.startRow * ATLAS_CELL_SIZE
1636
+ const width = (bounds.endColumn - bounds.startColumn + 1) * ATLAS_CELL_SIZE
1637
+ const height = (bounds.endRow - bounds.startRow + 1) * ATLAS_CELL_SIZE
1638
+ const autoTerrain = this.map.activeTool === 'terrain' && !this.atlasDrag
1639
+ context.fillStyle = autoTerrain ? 'rgba(61, 214, 198, 0.18)' : 'rgba(255, 55, 55, 0.18)'
1640
+ context.strokeStyle = autoTerrain ? '#3dd6c6' : '#ff3535'
1641
+ context.lineWidth = 3 * atlasLineScale
1642
+ context.fillRect(x, y, width, height)
1643
+ context.strokeRect(
1644
+ x + 1.5 * atlasLineScale,
1645
+ y + 1.5 * atlasLineScale,
1646
+ width - 3 * atlasLineScale,
1647
+ height - 3 * atlasLineScale,
1648
+ )
1649
+ }
1650
+
1651
+ closeBrushContextMenu() {
1652
+ if (!this.brushContextMenu) return
1653
+ document.removeEventListener('pointerdown', this.brushContextMenu._outsideHandler, true)
1654
+ this.brushContextMenu.remove()
1655
+ this.brushContextMenu = null
1656
+ }
1657
+
1658
+ openBrushContextMenu(clientX, clientY, bounds) {
1659
+ this.closeBrushContextMenu()
1660
+ const menu = makeElement('div', 'tile-map-brush-context-menu')
1661
+ const remove = makeButton('删除画笔')
1662
+ menu.appendChild(remove)
1663
+ document.body.appendChild(menu)
1664
+ const rect = menu.getBoundingClientRect()
1665
+ menu.style.left = `${Math.max(8, Math.min(clientX, window.innerWidth - rect.width - 8))}px`
1666
+ menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`
1667
+ menu._outsideHandler = (event) => {
1668
+ if (!menu.contains(event.target)) this.closeBrushContextMenu()
1669
+ }
1670
+ document.addEventListener('pointerdown', menu._outsideHandler, true)
1671
+ remove.addEventListener('click', async () => {
1672
+ this.closeBrushContextMenu()
1673
+ try {
1674
+ await this.deleteBrushesInBounds(bounds)
1675
+ } catch (error) {
1676
+ this.reportError(error)
1677
+ }
1678
+ })
1679
+ this.brushContextMenu = menu
1680
+ }
1681
+
1682
+ async deleteBrushesInBounds(bounds) {
1683
+ const deletedSourceIds = new Set()
1684
+ const nextPaletteCells = { ...this.map.palette.cells }
1685
+ for (let row = bounds.startRow; row <= bounds.endRow; row += 1) {
1686
+ for (let column = bounds.startColumn; column <= bounds.endColumn; column += 1) {
1687
+ const key = `${column},${row}`
1688
+ const tile = nextPaletteCells[key]
1689
+ if (!tile) continue
1690
+ deletedSourceIds.add(tile.sourceId)
1691
+ delete nextPaletteCells[key]
1692
+ }
1693
+ }
1694
+ if (!deletedSourceIds.size) return
1695
+
1696
+ const visibleSourceIds = new Set(Object.values(nextPaletteCells).map((tile) => tile.sourceId))
1697
+ const emptiedBrushImages = this.map.brushImages.filter((brushImage) => (
1698
+ !brushImage.tileSourceIds.some((sourceId) => visibleSourceIds.has(sourceId))
1699
+ ))
1700
+ const invalidatedTerrainBrushIds = new Set(this.map.brushImages
1701
+ .filter((brushImage) => (
1702
+ brushImage.columns === AUTO_TERRAIN_ATLAS_SIDE &&
1703
+ brushImage.rows === AUTO_TERRAIN_ATLAS_SIDE &&
1704
+ brushImage.tileSourceIds.some((sourceId) => deletedSourceIds.has(sourceId))
1705
+ ))
1706
+ .map((brushImage) => brushImage.id))
1707
+ const deletedSources = this.map.sources.filter((source) => deletedSourceIds.has(source.id))
1708
+ await this.callbacks.deleteTileset?.({
1709
+ map_id: this.item.id,
1710
+ tile_ids: deletedSources.map((source) => source.assetId),
1711
+ tileset_ids: emptiedBrushImages.map((brushImage) => brushImage.assetId),
1712
+ })
1713
+
1714
+ this.map.layers = this.map.layers.map((layer) => ({
1715
+ ...layer,
1716
+ cells: Object.fromEntries(Object.entries(layer.cells).filter(([, cell]) => (
1717
+ !deletedSourceIds.has(cell.sourceId)
1718
+ ))),
1719
+ }))
1720
+ this.map.sources = this.map.sources.flatMap((source) => {
1721
+ if (!deletedSourceIds.has(source.id)) return [source]
1722
+ this.baseTextureCache.delete(source.id)
1723
+ this.imageCache.delete(source.id)
1724
+ return []
1725
+ })
1726
+ const emptiedIds = new Set(emptiedBrushImages.map((brushImage) => brushImage.id))
1727
+ this.map.brushImages = this.map.brushImages.filter((brushImage) => !emptiedIds.has(brushImage.id))
1728
+ this.map.palette = { ...this.map.palette, cells: nextPaletteCells }
1729
+ this.map.activeBrush = null
1730
+ if (invalidatedTerrainBrushIds.has(this.map.activeTerrainBrushId)) this.map.activeTerrainBrushId = null
1731
+ this.atlasDrag = null
1732
+ this.history = []
1733
+ this.future = []
1734
+ await this.renderPalette()
1735
+ await this.renderTiles()
1736
+ this.renderLayerList()
1737
+ this.updateHistoryButtons()
1738
+ this.notifyChange()
1739
+ }
1740
+
1741
+ async renderPalette() {
1742
+ const canvas = this.tilesetCanvas
1743
+ if (!this.palette || !canvas) return
1744
+ const sequence = ++this.paletteRenderSequence
1745
+ if (this.paletteEmpty) this.paletteEmpty.hidden = false
1746
+ this.map.palette = normalizePalette(
1747
+ this.map.palette,
1748
+ this.map.sources,
1749
+ this.map.brushImages,
1750
+ this.map.cellSize,
1751
+ )
1752
+ const atlasRows = this.map.palette.rows
1753
+ this.atlasCells = Array.from({ length: atlasRows * ATLAS_COLUMNS }, (_, index) => {
1754
+ const column = index % ATLAS_COLUMNS
1755
+ const row = Math.floor(index / ATLAS_COLUMNS)
1756
+ return this.map.palette.cells[`${column},${row}`] || null
1757
+ })
1758
+ canvas.width = ATLAS_COLUMNS * ATLAS_CELL_SIZE
1759
+ canvas.height = atlasRows * ATLAS_CELL_SIZE
1760
+ if (this.tilesetSelectionCanvas) {
1761
+ this.tilesetSelectionCanvas.width = canvas.width
1762
+ this.tilesetSelectionCanvas.height = canvas.height
1763
+ }
1764
+ this.setAtlasZoom(this.atlasZoom)
1765
+ const context = canvas.getContext('2d')
1766
+ context.imageSmoothingEnabled = false
1767
+ context.clearRect(0, 0, canvas.width, canvas.height)
1768
+
1769
+ const sourceMap = new Map(this.map.sources.map((source) => [source.id, source]))
1770
+ const loadedImages = new Map()
1771
+ await Promise.all(this.map.sources.map(async (source) => {
1772
+ try {
1773
+ loadedImages.set(source.id, await this.htmlImageForSource(source))
1774
+ } catch {
1775
+ /* Missing source images leave empty atlas cells. */
1776
+ }
1777
+ }))
1778
+ if (this.disposed || sequence !== this.paletteRenderSequence) return
1779
+ for (let atlasIndex = 0; atlasIndex < this.atlasCells.length; atlasIndex += 1) {
1780
+ const tile = this.atlasCells[atlasIndex]
1781
+ if (!tile) continue
1782
+ const source = sourceMap.get(tile.sourceId)
1783
+ const image = loadedImages.get(tile.sourceId)
1784
+ if (!source || !image) continue
1785
+ const frame = sourceTileFrame(source, tile.tileIndex)
1786
+ context.drawImage(
1787
+ image,
1788
+ frame.x,
1789
+ frame.y,
1790
+ frame.width,
1791
+ frame.height,
1792
+ (atlasIndex % ATLAS_COLUMNS) * ATLAS_CELL_SIZE,
1793
+ Math.floor(atlasIndex / ATLAS_COLUMNS) * ATLAS_CELL_SIZE,
1794
+ ATLAS_CELL_SIZE,
1795
+ ATLAS_CELL_SIZE,
1796
+ )
1797
+ }
1798
+
1799
+ const atlasLineScale = ATLAS_CELL_SIZE / 32
1800
+ const atlasLineOffset = atlasLineScale / 2
1801
+ context.strokeStyle = 'rgba(255,255,255,0.28)'
1802
+ context.lineWidth = atlasLineScale
1803
+ for (let column = 0; column <= ATLAS_COLUMNS; column += 1) {
1804
+ context.beginPath()
1805
+ context.moveTo(column * ATLAS_CELL_SIZE + atlasLineOffset, 0)
1806
+ context.lineTo(column * ATLAS_CELL_SIZE + atlasLineOffset, atlasRows * ATLAS_CELL_SIZE)
1807
+ context.stroke()
1808
+ }
1809
+ for (let row = 0; row <= atlasRows; row += 1) {
1810
+ context.beginPath()
1811
+ context.moveTo(0, row * ATLAS_CELL_SIZE + atlasLineOffset)
1812
+ context.lineTo(ATLAS_COLUMNS * ATLAS_CELL_SIZE, row * ATLAS_CELL_SIZE + atlasLineOffset)
1813
+ context.stroke()
1814
+ }
1815
+
1816
+ this.drawAtlasSelection()
1817
+ }
1818
+
1819
+ async openSliceDialog(reference) {
1820
+ const url = reference.url || this.callbacks.resolveImageUrl(reference.id)
1821
+ const image = await loadHtmlImage(url)
1822
+ if (this.disposed) return
1823
+ const existing = this.map.brushImages.find((brushImage) => (
1824
+ brushImage.sourceAssetId === reference.id || brushImage.assetId === reference.id
1825
+ ))
1826
+ const guessed = suggestedSplit(image.naturalWidth, image.naturalHeight)
1827
+ const initial = existing || {
1828
+ columns: guessed.columns,
1829
+ rows: guessed.rows,
1830
+ marginX: 0,
1831
+ marginY: 0,
1832
+ spacingX: 0,
1833
+ spacingY: 0,
1834
+ }
1835
+ const overlay = makeElement('div', 'tile-map-slice-overlay')
1836
+ const dialog = makeElement('form', 'tile-map-slice-dialog')
1837
+ const head = makeElement('div', 'tile-map-slice-head')
1838
+ head.append(
1839
+ makeElement('div', null, existing ? '重新切分画笔图片' : '切分画笔图片'),
1840
+ makeElement('small', null, `${image.naturalWidth} × ${image.naturalHeight} px`),
1841
+ )
1842
+ const preview = document.createElement('canvas')
1843
+ preview.className = 'tile-map-slice-preview'
1844
+ const fields = makeElement('div', 'tile-map-slice-fields')
1845
+ const columns = this.numberField('列数', initial.columns, 1, 8)
1846
+ const rows = this.numberField('行数', initial.rows, 1, 8)
1847
+ const marginX = this.numberField('横向边距', initial.marginX, 0, image.naturalWidth - 1, 'px')
1848
+ const marginY = this.numberField('纵向边距', initial.marginY, 0, image.naturalHeight - 1, 'px')
1849
+ const spacingX = this.numberField('横向间隔', initial.spacingX, 0, image.naturalWidth - 1, 'px')
1850
+ const spacingY = this.numberField('纵向间隔', initial.spacingY, 0, image.naturalHeight - 1, 'px')
1851
+ fields.append(columns.label, rows.label, marginX.label, marginY.label, spacingX.label, spacingY.label)
1852
+ const validation = makeElement('div', 'tile-map-slice-validation')
1853
+ const actions = makeElement('div', 'tile-map-slice-actions')
1854
+ const cancel = makeButton('取消')
1855
+ const confirm = makeButton(existing ? '更新画笔' : '加入画笔', 'tile-map-button tile-map-button-primary')
1856
+ confirm.type = 'submit'
1857
+ actions.append(cancel, confirm)
1858
+ dialog.append(head, preview, fields, validation, actions)
1859
+ overlay.appendChild(dialog)
1860
+ this.inspectorRoot?.appendChild(overlay)
1861
+
1862
+ const readConfig = () => {
1863
+ const cols = clampInteger(columns.input.value, 1, 8, 1)
1864
+ const rowCount = clampInteger(rows.input.value, 1, 8, 1)
1865
+ const mx = clampInteger(marginX.input.value, 0, image.naturalWidth - 1, 0)
1866
+ const my = clampInteger(marginY.input.value, 0, image.naturalHeight - 1, 0)
1867
+ const sx = clampInteger(spacingX.input.value, 0, image.naturalWidth - 1, 0)
1868
+ const sy = clampInteger(spacingY.input.value, 0, image.naturalHeight - 1, 0)
1869
+ const availableWidth = image.naturalWidth - mx * 2 - sx * (cols - 1)
1870
+ const availableHeight = image.naturalHeight - my * 2 - sy * (rowCount - 1)
1871
+ const tileWidth = availableWidth / cols
1872
+ const tileHeight = availableHeight / rowCount
1873
+ const valid = tileWidth >= 1 && tileHeight >= 1 && Number.isInteger(tileWidth) && Number.isInteger(tileHeight)
1874
+ return { columns: cols, rows: rowCount, marginX: mx, marginY: my, spacingX: sx, spacingY: sy, tileWidth, tileHeight, valid }
1875
+ }
1876
+
1877
+ const drawPreview = () => {
1878
+ const config = readConfig()
1879
+ const maxWidth = 430
1880
+ const maxHeight = 210
1881
+ const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight, 1)
1882
+ preview.width = Math.max(1, Math.round(image.naturalWidth * scale))
1883
+ preview.height = Math.max(1, Math.round(image.naturalHeight * scale))
1884
+ const context = preview.getContext('2d')
1885
+ context.clearRect(0, 0, preview.width, preview.height)
1886
+ context.drawImage(image, 0, 0, preview.width, preview.height)
1887
+ if (config.valid) {
1888
+ context.strokeStyle = '#4fd1c5'
1889
+ context.lineWidth = 1
1890
+ for (let row = 0; row < config.rows; row += 1) {
1891
+ for (let column = 0; column < config.columns; column += 1) {
1892
+ context.strokeRect(
1893
+ (config.marginX + column * (config.tileWidth + config.spacingX)) * scale,
1894
+ (config.marginY + row * (config.tileHeight + config.spacingY)) * scale,
1895
+ config.tileWidth * scale,
1896
+ config.tileHeight * scale,
1897
+ )
1898
+ }
1899
+ }
1900
+ validation.textContent = `将切成 ${config.columns * config.rows} 块,每块 ${config.tileWidth} × ${config.tileHeight} px`
1901
+ } else {
1902
+ validation.textContent = '当前边距或间隔无法整除图片,请调整参数'
1903
+ }
1904
+ validation.classList.toggle('is-error', !config.valid)
1905
+ confirm.disabled = !config.valid
1906
+ }
1907
+ for (const input of dialog.querySelectorAll('input')) input.addEventListener('input', drawPreview)
1908
+ cancel.addEventListener('click', () => overlay.remove())
1909
+ overlay.addEventListener('pointerdown', (event) => event.stopPropagation())
1910
+ dialog.addEventListener('submit', async (event) => {
1911
+ event.preventDefault()
1912
+ const config = readConfig()
1913
+ if (!config.valid) return
1914
+ confirm.disabled = true
1915
+ confirm.textContent = '处理中…'
1916
+ try {
1917
+ const result = await this.callbacks.processTileset?.({
1918
+ map_id: this.item.id,
1919
+ source_id: reference.id,
1920
+ columns: config.columns,
1921
+ rows: config.rows,
1922
+ margin_x: config.marginX,
1923
+ margin_y: config.marginY,
1924
+ spacing_x: config.spacingX,
1925
+ spacing_y: config.spacingY,
1926
+ tile_size: this.map.cellSize,
1927
+ })
1928
+ if (!result?.tileset?.id || !Array.isArray(result.tiles) || !result.tiles.length) {
1929
+ throw new Error('图集没有产生可用瓦片')
1930
+ }
1931
+ const previousSourceIds = new Set(existing?.tileSourceIds || [])
1932
+ const nextSources = result.tiles.map((tile, index) => normalizeSource({
1933
+ id: `tile_source_${String(tile.id).replace(/[^a-zA-Z0-9_-]/g, '_')}`,
1934
+ assetId: tile.id,
1935
+ name: `${reference.name || '地砖图片'} ${index + 1}`,
1936
+ naturalWidth: this.map.cellSize,
1937
+ naturalHeight: this.map.cellSize,
1938
+ columns: 1,
1939
+ rows: 1,
1940
+ marginX: 0,
1941
+ marginY: 0,
1942
+ spacingX: 0,
1943
+ spacingY: 0,
1944
+ tileWidth: this.map.cellSize,
1945
+ tileHeight: this.map.cellSize,
1946
+ })).filter(Boolean)
1947
+ const nextSourceIds = nextSources.map((source) => source.id)
1948
+ this.map.sources = [
1949
+ ...this.map.sources.filter((source) => !previousSourceIds.has(source.id)),
1950
+ ...nextSources,
1951
+ ]
1952
+ if (existing) {
1953
+ const brushImageIndex = this.map.brushImages.findIndex((candidate) => candidate.id === existing.id)
1954
+ this.map.brushImages[brushImageIndex] = {
1955
+ ...existing,
1956
+ type: 'map_brush_image',
1957
+ assetId: result.tileset.id,
1958
+ sourceAssetId: reference.id,
1959
+ name: reference.name || '地砖图集',
1960
+ ...config,
1961
+ tileSourceIds: nextSourceIds,
1962
+ }
1963
+ for (const layer of this.map.layers) {
1964
+ for (const [key, cell] of Object.entries(layer.cells)) {
1965
+ if (!previousSourceIds.has(cell.sourceId)) continue
1966
+ const oldIndex = existing.tileSourceIds.indexOf(cell.sourceId)
1967
+ if (oldIndex < 0 || !nextSources[oldIndex]) delete layer.cells[key]
1968
+ else layer.cells[key] = { sourceId: nextSources[oldIndex].id, tileIndex: 0 }
1969
+ }
1970
+ }
1971
+ } else {
1972
+ this.map.brushImages.push({
1973
+ type: 'map_brush_image',
1974
+ id: `tileset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
1975
+ assetId: result.tileset.id,
1976
+ sourceAssetId: reference.id,
1977
+ name: reference.name || '地砖图集',
1978
+ ...config,
1979
+ tileSourceIds: nextSourceIds,
1980
+ })
1981
+ }
1982
+ this.map.palette = createPaletteFromSources(
1983
+ this.map.sources,
1984
+ this.map.brushImages,
1985
+ this.map.cellSize,
1986
+ )
1987
+ const source = nextSources[0]
1988
+ this.map.activeBrush = source ? { sourceId: source.id, tileIndex: 0 } : null
1989
+ this.map.activeTool = 'brush'
1990
+ for (const sourceId of previousSourceIds) {
1991
+ this.baseTextureCache.delete(sourceId)
1992
+ this.imageCache.delete(sourceId)
1993
+ }
1994
+ overlay.remove()
1995
+ this.setTool('brush', false)
1996
+ this.renderPalette()
1997
+ this.renderLayerList()
1998
+ this.renderTiles()
1999
+ this.notifyChange()
2000
+ } catch (error) {
2001
+ this.reportError(error)
2002
+ confirm.disabled = false
2003
+ confirm.textContent = existing ? '更新画笔' : '加入画笔'
2004
+ }
2005
+ })
2006
+ drawPreview()
2007
+ }
2008
+
2009
+ async htmlImageForSource(source) {
2010
+ if (this.imageCache.has(source.id)) return this.imageCache.get(source.id)
2011
+ const promise = loadHtmlImage(this.callbacks.resolveImageUrl(source.assetId))
2012
+ this.imageCache.set(source.id, promise)
2013
+ return promise
2014
+ }
2015
+
2016
+ async exportPng() {
2017
+ const width = this.map.columns * this.map.cellSize
2018
+ const height = this.map.rows * this.map.cellSize
2019
+ if (width > MAX_EXPORT_SIDE || height > MAX_EXPORT_SIDE) {
2020
+ throw new Error(`PNG 尺寸不能超过 ${MAX_EXPORT_SIDE} × ${MAX_EXPORT_SIDE} px`)
2021
+ }
2022
+ const canvas = document.createElement('canvas')
2023
+ canvas.width = width
2024
+ canvas.height = height
2025
+ const context = canvas.getContext('2d')
2026
+ context.imageSmoothingEnabled = false
2027
+ const sourceMap = new Map(this.map.sources.map((source) => [source.id, source]))
2028
+ const loaded = new Map()
2029
+ await Promise.all(this.map.sources.map(async (source) => {
2030
+ loaded.set(source.id, await this.htmlImageForSource(source))
2031
+ }))
2032
+ for (const layer of this.map.layers) {
2033
+ for (const [key, cell] of Object.entries(layer.cells)) {
2034
+ const source = sourceMap.get(cell.sourceId)
2035
+ const image = loaded.get(cell.sourceId)
2036
+ if (!source || !image) continue
2037
+ const [x, y] = key.split(',').map(Number)
2038
+ const frame = sourceTileFrame(source, cell.tileIndex)
2039
+ context.drawImage(
2040
+ image,
2041
+ frame.x,
2042
+ frame.y,
2043
+ frame.width,
2044
+ frame.height,
2045
+ x * this.map.cellSize,
2046
+ y * this.map.cellSize,
2047
+ this.map.cellSize,
2048
+ this.map.cellSize,
2049
+ )
2050
+ }
2051
+ }
2052
+ const blob = await new Promise((resolve, reject) => {
2053
+ canvas.toBlob((value) => value ? resolve(value) : reject(new Error('PNG 导出失败')), 'image/png')
2054
+ })
2055
+ downloadBlob(blob, `${safeFileStem(this.item.name)}.png`)
2056
+ }
2057
+
2058
+ destroy() {
2059
+ this.disposed = true
2060
+ this.closeBrushContextMenu()
2061
+ this.renderSequence += 1
2062
+ this.layerRenderSequence += 1
2063
+ this.resizeObserver?.disconnect()
2064
+ if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame)
2065
+ this.resizeFrame = 0
2066
+ this.inspectorRoot?.remove()
2067
+ this.inspectorRoot = null
2068
+ if (this.app) {
2069
+ try {
2070
+ this.app.destroy(true, { children: true, texture: false, textureSource: false })
2071
+ } catch {
2072
+ /* Pixi may already be disposed during a canvas re-render. */
2073
+ }
2074
+ }
2075
+ this.app = null
2076
+ }
2077
+ }
2078
+
2079
+ export function mountTileMapNode(container, item, callbacks) {
2080
+ const previous = mountedViews.get(item.id)
2081
+ previous?.destroy()
2082
+ const view = new TileMapView(container, item, callbacks)
2083
+ mountedViews.set(item.id, view)
2084
+ view.build()
2085
+ return view
2086
+ }
2087
+
2088
+ export function mountTileMapInspector(container, item) {
2089
+ const view = mountedViews.get(item.id)
2090
+ if (!view) return false
2091
+ view.mountInspector(container)
2092
+ return true
2093
+ }
2094
+
2095
+ export function refreshTileMapGridScale(outerScale) {
2096
+ for (const view of mountedViews.values()) view.setOuterScale(outerScale)
2097
+ }
2098
+
2099
+ export function resizeTileMapView(id) {
2100
+ mountedViews.get(id)?.scheduleRendererResize()
2101
+ }
2102
+
2103
+ export async function exportTileMapPng(id) {
2104
+ const view = mountedViews.get(id)
2105
+ if (!view) throw new Error('地图编辑器尚未加载')
2106
+ await view.exportPng()
2107
+ if (view.selectedStatus) view.selectedStatus.textContent = 'PNG 已导出'
2108
+ }
2109
+
2110
+ export function disposeTileMapViews() {
2111
+ for (const view of mountedViews.values()) view.destroy()
2112
+ mountedViews.clear()
2113
+ }
2114
+
2115
+ export function disposeTileMapView(id) {
2116
+ const view = mountedViews.get(id)
2117
+ if (!view) return
2118
+ view.destroy()
2119
+ mountedViews.delete(id)
2120
+ }