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.
- package/dist/canvas-public/app.js +8517 -1692
- package/dist/canvas-public/assets/presets/catalog.json +47 -0
- package/dist/canvas-public/assets/presets/tiles/A_01_CaoDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_02_ShaMo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_03_TianKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_04_ShuiBo.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_05_HaiMian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_06_MuDiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_07_ShiZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_08_AnRongYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_09_QingTaiShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_10_ShenHai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_11_HuiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_12_HongTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_13_GengDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_14_HuoShanYan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_15_NiTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_16_HuangSha.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_17_CongLin.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_18_ZiYanShi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_19_MoGuTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_20_HuaDian.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_21_HaiLang.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_22_GanTu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_23_QingTaiShiBan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_24_XingKong.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_25_MoFaFuWen.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_26_ShiTuYeDi.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_27_LanZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_28_HongZhuan.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_29_YueQiu.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_30_JinShuGe.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_31_JinShuJingGai.png +0 -0
- package/dist/canvas-public/assets/presets/tiles/A_32_ShuZiChuang.png +0 -0
- package/dist/canvas-public/color-key-worker.js +280 -0
- package/dist/canvas-public/image-canvas-size-editor.js +254 -0
- package/dist/canvas-public/index.html +226 -119
- package/dist/canvas-public/perfect-pixel-worker.js +499 -0
- package/dist/canvas-public/smart-split.js +1 -1
- package/dist/canvas-public/styles.css +3902 -1225
- package/dist/canvas-public/tile-map-editor.js +2120 -0
- package/dist/canvas-public/ui-controls.js +95 -0
- package/dist/canvas-public/vendor/pixi-tilemap.js +126 -0
- package/dist/canvas-public/vendor/pixi.min.js +2340 -0
- package/dist/index.js +32263 -4187
- package/dist/stage-blender/bake_materials.py +102 -0
- package/dist/stage-blender/export_scene.py +275 -0
- package/dist/stage-blender/preview_camera.py +90 -0
- package/dist/stage-blender/repair_mesh.py +112 -0
- package/dist/stage-public/app.js +759 -0
- package/dist/stage-public/baked/bakePilot.js +44 -0
- package/dist/stage-public/baked/bakedScene.js +129 -0
- package/dist/stage-public/baked/sceneAppearance.js +225 -0
- package/dist/stage-public/baked/sceneCamera.js +45 -0
- package/dist/stage-public/comparison-contract.js +28 -0
- package/dist/stage-public/delivery.html +4 -0
- package/dist/stage-public/delivery.js +17 -0
- package/dist/stage-public/godot/appearance.gd +170 -0
- package/dist/stage-public/godot/preview.gd +79 -0
- package/dist/stage-public/index.html +141 -0
- package/dist/stage-public/review-app.js +289 -0
- package/dist/stage-public/review-runtime.js +63 -0
- package/dist/stage-public/review.css +24 -0
- package/dist/stage-public/review.html +47 -0
- package/dist/stage-public/scene-runtime.js +212 -0
- package/dist/stage-public/sceneContractRuntime.js +235 -0
- package/dist/stage-public/styles.css +328 -0
- package/dist/stage-public/three-appearance.js +104 -0
- package/dist/stage-public/vendor/three170/LICENSE +21 -0
- package/dist/stage-public/vendor/three170/build/three.module.js +54571 -0
- package/dist/stage-public/vendor/three170/examples/jsm/controls/OrbitControls.js +1523 -0
- package/dist/stage-public/vendor/three170/examples/jsm/environments/RoomEnvironment.js +144 -0
- package/dist/stage-public/vendor/three170/examples/jsm/libs/fflate.module.js +2672 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/EXRLoader.js +2563 -0
- package/dist/stage-public/vendor/three170/examples/jsm/loaders/GLTFLoader.js +4727 -0
- package/dist/stage-public/vendor/three170/examples/jsm/utils/BufferGeometryUtils.js +1373 -0
- package/dist/tile-transition-mask.svg +40 -0
- package/package.json +77 -69
- package/skills/evomeme-canvas/SKILL.md +52 -0
- package/skills/evomeme-canvas/agents/openai.yaml +7 -0
- package/skills/evomeme-cover/SKILL.md +1 -1
- package/skills/evomeme-game-maker/SKILL.md +79 -8
- package/skills/game-asset-align/SKILL.md +45 -0
- package/skills/game-asset-align/agents/openai.yaml +8 -0
- package/skills/game-asset-align/references/alignment-contract.md +61 -0
- package/skills/game-asset-align/references/blender-scene-pipeline.md +45 -0
- package/skills/game-asset-align/references/canonical-package.md +26 -0
- package/skills/game-asset-align/references/engine-capabilities.md +14 -0
- package/skills/game-asset-align/references/lightweight-review.md +32 -0
- package/skills/game-asset-align/references/repair-policy.md +30 -0
- package/skills/game-asset-align/references/tool-contract.md +39 -0
- package/skills/game-asset-align/references/visual-review.md +26 -0
- package/skills/game-asset-image-generator/SKILL.md +1 -1
- package/skills/godot-3d-game/SKILL.md +39 -0
- package/skills/godot-3d-game/agents/openai.yaml +8 -0
- package/skills/godot-3d-game/references/alignment-gates.md +26 -0
- package/skills/godot-3d-game/references/appearance-alignment.md +52 -0
- package/skills/godot-3d-game/references/build-and-qa.md +22 -0
- package/skills/godot-3d-game/references/canonical-asset-runtime.md +27 -0
- package/skills/godot-3d-game/references/import-and-wrapper-scenes.md +20 -0
- package/skills/godot-3d-game/references/physics-colliders.md +13 -0
- package/skills/godot-3d-game/references/project-structure.md +22 -0
- package/skills/godot-3d-game/references/skeleton-animation.md +16 -0
- package/skills/threejs-3d-game/SKILL.md +6 -2
- package/skills/threejs-3d-game/references/engine-asset-pipeline.md +52 -15
- 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
|
+
}
|