'use strict'; const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const grid = require('../grid-math'); const tools = require('../editor-tools'); const tileMeta = require('../tile-meta'); const spawnTools = require('../spawn-tools'); const spawnDefaults = require('../entity-spawn-defaults'); const entityTexPresets = require('../entity-texture-presets'); const levelIdUtil = require('../level-id'); const prefabSync = require('../prefab-sync'); const bakeIgnore = require('../bake-ignore'); let themeDb; try { themeDb = require('../../theme-controller/dist/theme-db'); } catch (e) { themeDb = null; } const DB_REL = 'assets/level-data/levels-database.json'; const PALETTES_INDEX_REL = 'assets/resources/map-tiles/palettes/_index.json'; const THEME_LABELS = { silu: '丝路 silu', sanxing: '三星堆 sanxing', snow: '雪地 snow', chinese: '中国风 chinese', numMan: '数字人 numMan', redarmy: '红军 redarmy', default: '默认 default', }; exports.style = ` .lme-root { display:flex; flex-direction:column; height:100%; font-size:12px; } .lme-toolbar { display:flex; gap:6px; align-items:center; padding:8px; border-bottom:1px solid var(--color-normal-border); flex-wrap:wrap; } .lme-body { display:flex; flex:1; min-height:0; } .lme-center { flex:1; display:flex; flex-direction:column; min-width:0; border-right:1px solid var(--color-normal-border); } .lme-canvas-wrap { flex:1; overflow:hidden; background:#3a3a3a; padding:0; min-height:0; } #lme-canvas { display:block; cursor:crosshair; width:100%; height:100%; } .lme-palette { width:220px; padding:8px; overflow-y:auto; background:var(--color-normal-fill-emphasis); } .lme-palette h4 { margin:0 0 6px; font-size:12px; } .lme-palette-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; } .lme-tile { display:flex; flex-direction:column; align-items:center; padding:6px; border:2px solid transparent; border-radius:4px; cursor:pointer; background:var(--color-normal-fill); } .lme-tile.active { border-color:#4a9eff; } .lme-tile img { width:64px; height:64px; object-fit:contain; background:#2a2a2a; border-radius:2px; } .lme-tile span { margin-top:4px; font-size:10px; text-align:center; line-height:1.2; } .lme-status { padding:6px 8px; color:var(--color-normal-contrast-weaken); border-top:1px solid var(--color-normal-border); font-size:11px; } .lme-layer-btn.active { font-weight:bold; } #layer-ground.active { background:rgba(76,175,80,0.35); color:#e8f5e9; } #layer-border.active { background:rgba(255,152,0,0.35); color:#fff3e0; } .lme-mode-btn.active { font-weight:bold; background:rgba(156,39,176,0.35); } #spawn-player.active { background:rgba(33,150,243,0.35); } #spawn-vehicle.active { background:rgba(76,175,80,0.35); } #spawn-prop.active { background:rgba(255,193,7,0.35); } #spawn-prop-ground.active { background:rgba(255,152,0,0.35); } #spawn-erase.active { background:rgba(244,67,54,0.3); } .lme-spawn-tools { display:flex; gap:6px; align-items:center; flex-wrap:wrap; } .lme-spawn-dir { display:flex; gap:4px; align-items:center; } .lme-tools ui-button.active { font-weight:bold; background:var(--color-primary-fill); } .lme-tool-sep { width:1px; height:20px; background:var(--color-normal-border); margin:0 4px; } `; exports.template = `
关卡 ID 上一关 下一关 加载 新建 保存 JSON 烘焙预制体 从预制体同步 打开 Level 预制体 场景选中吸附 启用场景吸附助手 主题控制器 地图主题 编辑 地图 实体配置 绘制层 Ground Border 画笔 框选 吸管 橡皮擦 填充
地图/出生点编辑 · 自动保存 JSON 并烘焙 · 60×60 网格 · 滚轮缩放 · 长按右键拖动

平铺调色板

`; exports.$ = { levelId: '#level-id', btnPrev: '#btn-prev', btnNext: '#btn-next', btnLoad: '#btn-load', btnNew: '#btn-new', btnSave: '#btn-save', btnBake: '#btn-bake', btnImportPrefab: '#btn-import-prefab', btnOpenPrefab: '#btn-open-prefab', btnSceneSnap: '#btn-scene-snap', btnAttachHelper: '#btn-attach-helper', btnThemeCtrl: '#btn-theme-ctrl', themeSelect: '#theme-select', modeMap: '#mode-map', modeSpawns: '#mode-spawns', spawnToolsWrap: '#spawn-tools-wrap', spawnPlayer: '#spawn-player', spawnVehicle: '#spawn-vehicle', spawnProp: '#spawn-prop', spawnPropGround: '#spawn-prop-ground', spawnErase: '#spawn-erase', spawnClearVehicle: '#spawn-clear-vehicle', spawnDirection: '#spawn-direction', spawnDirWrap: '#spawn-dir-wrap', spawnPlacementScale: '#spawn-placement-scale', spawnScaleWrap: '#spawn-scale-wrap', layerGround: '#layer-ground', layerBorder: '#layer-border', toolPaint: '#tool-paint', toolBox: '#tool-box', toolPicker: '#tool-picker', toolEraser: '#tool-eraser', toolFill: '#tool-fill', canvas: '#lme-canvas', paletteList: '#palette-list', paletteTitle: '#palette-title', status: '#status', }; function projectPath() { return Editor.Project.path; } function dbPath() { return path.join(projectPath(), DB_REL); } function palettesIndexPath() { return path.join(projectPath(), PALETTES_INDEX_REL); } function textureFsPath(texRel) { const base = texRel.startsWith('textures/') ? texRel : `textures/${texRel}`; return path.join(projectPath(), 'assets/resources', `${base}.png`); } function bindOnceOpts(el, event, handler, opts) { if (!el || el._lmeBoundOpts === handler) return; el._lmeBoundOpts = handler; el.addEventListener(event, handler, opts); } function bindOnce(el, event, handler) { bindOnceOpts(el, event, handler, undefined); } function normalizeBorder(border) { const out = {}; for (const [k, v] of Object.entries(border || {})) { if (v === true || v === 1) out[k] = 'WallBlock'; else if (typeof v === 'string') out[k] = v; else out[k] = 'WallBlock'; } return out; } function emptyLevelConfig(levelId, theme) { return levelIdUtil.syncLevelEntry({ boundary: { x: 10, y: 10 }, spawns: [ { x: 0, y: 0, kind: 'player', playerDirection: 'Direction.South', }, { x: 1, y: 0, kind: 'prop', }, ], ground: {}, border: {}, theme: theme || 'sanxing', }, levelId); } function readDatabase() { const raw = fs.readFileSync(dbPath(), 'utf8'); return levelIdUtil.normalizeDb(JSON.parse(raw)); } function writeDatabase(db) { levelIdUtil.normalizeDb(db); levelIdUtil.updateDbStats(db); db.generatedAt = new Date().toISOString(); fs.writeFileSync(dbPath(), JSON.stringify(db, null, 2), 'utf8'); } function parseLevelIdInput(raw, db) { const n = parseInt(String(raw || ''), 10); if (!Number.isNaN(n) && n >= levelIdUtil.minLevelId()) return n; return levelIdUtil.nextAvailableLevelId(db); } function tileBrushId(tile) { return tile.tileKey || tile.name || tile.display; } function loadImageCache(state, texRel) { const fsPath = textureFsPath(texRel); if (state.imageCache[fsPath]) return state.imageCache[fsPath]; const img = new Image(); img.src = `file://${fsPath}`; state.imageCache[fsPath] = img; if (fs.existsSync(fsPath)) { img.onload = () => { if (state._drawPending) return; state._drawPending = true; requestAnimationFrame(() => { state._drawPending = false; if (state._lastDraw) state._lastDraw(); }); }; } return img; } exports.ready = function () { console.log('[level-map-editor] panel ready'); if (this._lmeReady) return; this._lmeReady = true; const state = { levelId: levelIdUtil.minLevelId(), config: null, db: null, theme: 'sanxing', palettes: {}, tiles: [], editLayer: 'ground', editMode: 'map', spawnTool: 'player', spawnDirection: 'Direction.South', placementScale: { ...spawnDefaults.DEFAULT_SPAWN_SCALE }, selectedSpawn: null, tool: 'paint', brush: null, painting: false, hoverCell: null, boxStart: null, boxEnd: null, lastPaintKey: null, imageCache: {}, offsetX: 0, offsetY: 0, zoom: 1, panX: 0, panY: 0, panActive: false, rmbDown: false, rmbTimer: null, panLast: null, _drawPending: false, _lastDraw: null, _autoBakeTimer: null, }; const setStatus = (msg) => { if (this.$.status) this.$.status.textContent = msg; }; const applyThemeTiles = () => { const entry = state.palettes[state.theme]; if (!entry || !entry.tiles) { state.tiles = []; return; } state.tiles = entry.tiles.map((t) => ({ ...t, name: t.tileKey, display: t.display || t.tileKey, })); if (this.$.paletteTitle) { this.$.paletteTitle.textContent = `平铺调色板 — ${THEME_LABELS[state.theme] || state.theme}`; } renderPalette.call(this, state); }; const loadPalettesIndex = () => { try { let palettes = {}; let labels = { ...THEME_LABELS }; if (themeDb) { const themesData = themeDb.readThemesDb(projectPath()); if (themesData.themes && Object.keys(themesData.themes).length) { palettes = themeDb.themesToPalettesMap(themesData); labels = { ...labels, ...themeDb.buildThemeLabels(themesData) }; } } if (!Object.keys(palettes).length) { const data = JSON.parse(fs.readFileSync(palettesIndexPath(), 'utf8')); palettes = data.themes || {}; } state.palettes = palettes; state.themeLabels = labels; const sel = this.$.themeSelect; if (sel) { sel.innerHTML = ''; const order = ['sanxing', 'silu', 'snow', 'chinese', 'numMan', 'redarmy', 'default']; const keys = [...new Set([...order, ...Object.keys(state.palettes)])]; for (const key of keys) { if (!state.palettes[key]) continue; const opt = document.createElement('option'); opt.value = key; const entry = state.palettes[key]; opt.textContent = labels[key] || entry.displayName || key; if (key === state.theme) opt.selected = true; sel.appendChild(opt); } } applyThemeTiles(); } catch (e) { setStatus(`主题/调色板加载失败: ${e.message}。请打开「主题控制器」或运行 build_theme_palettes.py`); } }; const loadLevel = () => { try { state.db = readDatabase(); const id = parseLevelIdInput(this.$.levelId.value, state.db); this.$.levelId.value = String(id); if (Number.isNaN(id) || id < levelIdUtil.minLevelId()) { setStatus(`请输入有效的关卡 ID(≥${levelIdUtil.minLevelId()})`); return; } const cfg = state.db.levels[String(id)]; if (!cfg) { setStatus(`关卡 ${id} 不存在,可点「新建」创建空关卡`); return; } applyLevelConfig.call(this, id, cfg); setStatus( `关卡 ${id}:Ground ${Object.keys(state.config.ground).length} 格,Border ${Object.keys(state.config.border).length} 格,地图主题 ${state.config.theme || state.theme}`, ); } catch (e) { setStatus(`加载失败: ${e.message}`); } }; const navigateLevel = (direction) => { try { state.db = readDatabase(); const ids = levelIdUtil.sortedLevelIds(state.db); if (!ids.length) { setStatus('关卡库为空,请先新建关卡'); return; } const cur = state.levelId || parseLevelIdInput(this.$.levelId.value, state.db); const nextId = direction === 'prev' ? levelIdUtil.prevLevelIdInDb(state.db, cur) : levelIdUtil.nextLevelIdInDb(state.db, cur); if (nextId === cur && ids.length === 1) { setStatus(`仅有关卡 ${cur},无法切换`); return; } this.$.levelId.value = String(nextId); loadLevel.call(this); } catch (e) { setStatus(`切换关卡失败: ${e.message}`); } }; const reloadFromDisk = (levelId, hint) => { try { state.db = readDatabase(); const cfg = state.db.levels[String(levelId)]; if (!cfg) return false; if (state.levelId !== levelId) return false; applyLevelConfig.call(this, levelId, cfg); setStatus( hint || `关卡 ${levelId} 已从预制体同步 · Ground ${Object.keys(state.config.ground).length} · Border ${Object.keys(state.config.border).length}`, ); return true; } catch (e) { console.warn('[level-map-editor] reloadFromDisk failed', e); return false; } }; const importFromPrefab = async () => { const id = state.levelId; if (!id) return; try { const result = prefabSync.importLevelFromPrefab(id); if (!result || !result.ok) { setStatus(`从预制体同步失败: ${(result && result.reason) || '未知错误'}`); return; } await prefabSync.refreshDatabaseAsset(); reloadFromDisk(id, `关卡 ${id} 已从 Level${id}.prefab 回写 JSON 并刷新`); } catch (e) { setStatus(`从预制体同步失败: ${e.message}`); } }; const refreshSavedAssets = async () => { const assetUrl = `db://${DB_REL}`; try { await Editor.Message.request('asset-db', 'refresh-asset', assetUrl); const uuid = await Editor.Message.request('asset-db', 'query-uuid', assetUrl); if (uuid) await Editor.Message.request('asset-db', 'refresh-asset', uuid); } catch (e) { console.warn('[level-map-editor] asset refresh failed', e); } }; const persistMapChanges = (options = {}) => { if (!state.config || !state.db) return false; try { state.config.theme = state.theme; state.config = levelIdUtil.syncLevelEntry(state.config, state.levelId); state.db.levels[String(state.levelId)] = state.config; writeDatabase(state.db); } catch (e) { console.error('[level-map-editor] persist failed', e); setStatus(`保存失败: ${e.message}`); return false; } void refreshSavedAssets(); if (!options.quiet) { const hint = spawnTools.validationHint(state); setStatus( hint ? `关卡 ${state.levelId} 已保存 · ${hint}` : `关卡 ${state.levelId} 已自动保存(主题 ${state.theme})`, ); } console.log(`[level-map-editor] saved level ${state.levelId} → ${dbPath()}`); if (!options.skipBake) scheduleAutoBake(); return true; }; const AUTO_BAKE_DELAY_MS = 1200; const scheduleAutoBake = () => { if (state._autoBakeTimer) clearTimeout(state._autoBakeTimer); state._autoBakeTimer = setTimeout(() => { state._autoBakeTimer = null; runBake.call(this, { auto: true }); }, AUTO_BAKE_DELAY_MS); }; const cancelAutoBake = () => { if (state._autoBakeTimer) { clearTimeout(state._autoBakeTimer); state._autoBakeTimer = null; } }; const applyLevelConfig = (id, cfg) => { state.levelId = id; if (this.$.levelId) this.$.levelId.value = String(id); state.config = levelIdUtil.syncLevelEntry(JSON.parse(JSON.stringify(cfg)), id); state.selectedSpawn = null; state.config.ground = state.config.ground || {}; state.config.border = normalizeBorder(state.config.border); state.config.spawns = Array.isArray(cfg.spawns) ? JSON.parse(JSON.stringify(cfg.spawns)) : []; spawnTools.ensureSpawns(state); if (cfg.theme && state.palettes[cfg.theme]) { state.theme = cfg.theme; state.config.theme = cfg.theme; if (this.$.themeSelect) this.$.themeSelect.value = state.theme; applyThemeTiles(); } else if (!state.config.theme) { state.config.theme = state.theme; } else { state.theme = state.config.theme; if (this.$.themeSelect) this.$.themeSelect.value = state.theme; applyThemeTiles(); } cancelAutoBake(); computeBaseOffset(state, this.$.canvas?.width || 720, this.$.canvas?.height || 520); fitViewToGrid(state, this.$.canvas); drawGrid.call(this, state); syncSpawnDirUi(); renderPalette.call(this, state); }; const createNewLevel = () => { try { state.db = readDatabase(); let id = parseLevelIdInput(this.$.levelId.value, state.db); if (state.db.levels[String(id)]) { id = levelIdUtil.nextAvailableLevelId(state.db); } this.$.levelId.value = String(id); const theme = this.$.themeSelect?.value || state.theme || 'sanxing'; state.theme = theme; const cfg = emptyLevelConfig(id, theme); state.db.levels[String(id)] = cfg; writeDatabase(state.db); applyLevelConfig.call(this, id, cfg); void refreshSavedAssets(); runBake.call(this, { auto: true }); setStatus( `已新建关卡 ${id} → JSON / Level${id}.prefab / index 已同步(主题 ${theme})`, ); console.log(`[level-map-editor] created level ${id}`); } catch (e) { setStatus(`新建失败: ${e.message}`); console.error('[level-map-editor] create level failed', e); } }; const runBake = (options = {}) => { if (!state.config) return; persistMapChanges({ quiet: true, skipBake: true }); try { bakeIgnore.markBakePending(state.levelId); execSync( `python3 tools/bake_cocos_level_prefabs.py --db ${DB_REL} --out-dir assets/resources/level-prefabs --level-id ${state.levelId} --theme ${state.theme}`, { cwd: projectPath(), stdio: 'pipe' }, ); const prefabUrl = `db://assets/resources/level-prefabs/Level${state.levelId}.prefab`; try { Editor.Message.request('asset-db', 'refresh-asset', prefabUrl); } catch (e) { console.warn('[level-map-editor] prefab refresh failed', e); } if (options.auto) { setStatus(`关卡 ${state.levelId} 已自动保存并烘焙 → Level${state.levelId}.prefab`); } else { setStatus(`关卡 ${state.levelId} 已烘焙(主题 ${state.theme})→ level-prefabs/Level${state.levelId}.prefab`); } console.log(`[level-map-editor] baked Level${state.levelId}.prefab`); } catch (e) { const err = e.stderr ? e.stderr.toString() : e.message; if (options.auto) { setStatus(`关卡 ${state.levelId} 已保存 JSON,自动烘焙失败: ${err}`); } else { setStatus(`烘焙失败: ${err}`); } } }; const saveLevel = () => { cancelAutoBake(); persistMapChanges({ quiet: true, skipBake: true }); setStatus(`已保存关卡 ${state.levelId}(主题 ${state.theme})`); }; /** 每次改地图立即写 JSON */ const scheduleMapPersist = () => { persistMapChanges(); }; const bakeLevel = () => { cancelAutoBake(); runBake.call(this, { auto: false }); }; const openLevelPrefab = async () => { const id = state.levelId; const uuid = await Editor.Message.request('asset-db', 'query-uuid', `db://assets/resources/level-prefabs/Level${id}.prefab`); if (uuid) { await Editor.Message.request('asset-db', 'open-asset', uuid); setStatus(`已打开 Level${id}.prefab(Ground / Border 子节点,贴图主题 ${state.theme})`); } else { setStatus(`未找到 Level${id}.prefab,请先烘焙`); } }; const toolButtons = { paint: this.$.toolPaint, box: this.$.toolBox, picker: this.$.toolPicker, eraser: this.$.toolEraser, fill: this.$.toolFill, }; const spawnToolButtons = { player: this.$.spawnPlayer, vehicle: this.$.spawnVehicle, prop: this.$.spawnProp, prop_ground: this.$.spawnPropGround, erase: this.$.spawnErase, }; const syncSpawnDirUi = () => { const needDir = state.spawnTool === 'player' || state.spawnTool === 'vehicle'; if (this.$.spawnDirWrap) { this.$.spawnDirWrap.style.display = needDir ? 'flex' : 'none'; } if (this.$.spawnScaleWrap) { this.$.spawnScaleWrap.style.display = state.spawnTool === 'erase' ? 'none' : 'flex'; } const toolScale = state.placementScale[state.spawnTool] ?? 1; if (this.$.spawnPlacementScale && document.activeElement !== this.$.spawnPlacementScale) { this.$.spawnPlacementScale.value = String(toolScale); } const vehicle = spawnTools.getVehicleSpawn(state); if (this.$.spawnVehicle) { this.$.spawnVehicle.textContent = vehicle ? '载具 1/1' : '载具 0/1'; } }; const syncEditModeUi = () => { const mapMode = state.editMode === 'map'; if (this.$.modeMap) this.$.modeMap.classList.toggle('active', mapMode); if (this.$.modeSpawns) this.$.modeSpawns.classList.toggle('active', !mapMode); if (this.$.spawnToolsWrap) this.$.spawnToolsWrap.style.display = mapMode ? 'none' : 'flex'; const mapOnly = this.$.layerGround?.parentElement; if (this.$.layerGround) this.$.layerGround.style.display = mapMode ? '' : 'none'; if (this.$.layerBorder) this.$.layerBorder.style.display = mapMode ? '' : 'none'; const mapTools = this.$.toolPaint?.parentElement; if (mapTools) mapTools.style.display = mapMode ? '' : 'none'; renderPalette.call(this, state); syncSpawnDirUi(); if (this.$.canvas) { this.$.canvas.style.cursor = mapMode ? (tools.TOOL_CURSORS[state.tool] || 'crosshair') : 'pointer'; } }; const setEditMode = (mode) => { state.editMode = mode; state.painting = false; state.boxStart = null; state.boxEnd = null; syncEditModeUi(); if (state.config) { drawGrid.call(this, state); } if (mode === 'spawns') { const hint = spawnTools.validationHint(state); setStatus( `实体配置 · ${spawnTools.spawnSummary(state)}${hint ? ` · ${hint}` : ''}`, ); } else { setStatus(tools.TOOL_LABELS[state.tool] || state.tool); } }; const setSpawnTool = (toolId) => { state.spawnTool = toolId; for (const [id, btn] of Object.entries(spawnToolButtons)) { if (btn) btn.classList.toggle('active', id === toolId); } syncSpawnDirUi(); const labels = { player: '放置玩家(每关仅 1 个,含 x/y/朝向)', vehicle: '放置载具(0 或 1 个;再次点击原格可清除)', prop: '放置可拾取物·砖块上(Unity Prop)', prop_ground: '放置可拾取物·空地(Unity nProp,偏低)', erase: '删除该格上所有实体', }; setStatus(`实体 · ${labels[toolId] || toolId} · ${spawnTools.spawnSummary(state)}`); }; const readPlacementScale = (st) => { const raw = this.$.spawnPlacementScale?.value; const n = spawnDefaults.clampScale(raw !== undefined && raw !== '' ? raw : st.placementScale[st.spawnTool]); st.placementScale[st.spawnTool] = n; return n; }; const applyEntityTextures = () => { if (!state.config) return false; entityTexPresets.ensureEntityTextures(state); const read = entityTexPresets.readEntityTexturesFromPanel(this.$.paletteList); state.config.entityTextures = read; entityTexPresets.pruneEmptyEntityTextures(state); scheduleMapPersist(); renderPalette.call(this, state); setStatus('关卡实体贴图已保存(entityTextures)'); return true; }; const applySpawnInspector = () => { if (!state.config || !state.selectedSpawn || !spawnTools.spawnStillExists(state, state.selectedSpawn)) { state.selectedSpawn = null; renderPalette.call(this, state); return false; } const sel = state.selectedSpawn; const xEl = this.$.paletteList?.querySelector('#spawn-inspect-x'); const yEl = this.$.paletteList?.querySelector('#spawn-inspect-y'); const dirEl = this.$.paletteList?.querySelector('#spawn-inspect-dir'); const scaleEl = this.$.paletteList?.querySelector('#spawn-inspect-scale'); const texEl = this.$.paletteList?.querySelector('#spawn-inspect-texture'); const patch = { x: xEl ? parseInt(xEl.value, 10) : sel.x, y: yEl ? parseInt(yEl.value, 10) : sel.y, scale: scaleEl ? spawnDefaults.clampScale(scaleEl.value) : spawnTools.formatSpawnScale(sel), texture: texEl ? texEl.value : sel.texture, }; if (sel.kind === 'player' && dirEl) patch.playerDirection = dirEl.value; if (sel.kind === 'vehicle' && dirEl) patch.vehicleDirection = dirEl.value; if (texEl) { const t = entityTexPresets.normalizeTexturePath(texEl.value); patch.texture = t || ''; } if (Number.isNaN(patch.x) || Number.isNaN(patch.y)) { setStatus('坐标必须为整数'); return false; } spawnTools.updateSpawnEntry(state, sel, patch); scheduleMapPersist(); drawGrid.call(this, state); renderPalette.call(this, state); setStatus(`已更新 ${spawnTools.SPAWN_KIND_LABELS[sel.kind] || sel.kind} · ${spawnTools.spawnSummary(state)}`); return true; }; const selectSpawnAtCell = (cell, options = {}) => { if (!cell || !state.config) return false; const prefer = options.preferKind || state.spawnTool; const hit = spawnTools.pickSpawnAtCell(state, cell.x, cell.y, prefer === 'erase' ? null : prefer); if (!hit) return false; state.selectedSpawn = hit; if (hit.kind === 'player' || hit.kind === 'vehicle') { const d = hit.playerDirection || hit.vehicleDirection; if (d) { state.spawnDirection = d; if (this.$.spawnDirection) this.$.spawnDirection.value = d; } } const sc = spawnTools.formatSpawnScale(hit); state.placementScale[hit.kind] = sc; if (this.$.spawnPlacementScale) this.$.spawnPlacementScale.value = String(sc); renderPalette.call(this, state); drawGrid.call(this, state); return true; }; const applySpawnAt = (cell) => { if (!state.config || !cell || !isInGrid(cell)) return false; const dir = this.$.spawnDirection?.value || state.spawnDirection || 'Direction.South'; state.spawnDirection = dir; const scale = readPlacementScale(state); let changed = false; let affectedSpawn = null; if (state.spawnTool === 'player') { affectedSpawn = spawnTools.setPlayerSpawn(state, cell.x, cell.y, dir, scale); changed = true; } else if (state.spawnTool === 'vehicle') { const r = spawnTools.toggleVehicleSpawnDetailed(state, cell.x, cell.y, dir, scale); changed = r.changed; affectedSpawn = r.spawn; if (r.removed) state.selectedSpawn = null; } else if (state.spawnTool === 'prop') { const r = spawnTools.togglePropSpawn(state, cell.x, cell.y, scale, 'block'); changed = r.changed; affectedSpawn = r.spawn; if (r.removed && state.selectedSpawn?.x === cell.x && state.selectedSpawn?.y === cell.y) { state.selectedSpawn = null; } } else if (state.spawnTool === 'prop_ground') { const r = spawnTools.togglePropSpawn(state, cell.x, cell.y, scale, 'ground'); changed = r.changed; affectedSpawn = r.spawn; if (r.removed && state.selectedSpawn?.x === cell.x && state.selectedSpawn?.y === cell.y) { state.selectedSpawn = null; } } else if (state.spawnTool === 'erase') { changed = spawnTools.removeSpawnAt(state, cell.x, cell.y); if (changed) state.selectedSpawn = null; } if (affectedSpawn) state.selectedSpawn = affectedSpawn; if (changed) { scheduleMapPersist(); syncSpawnDirUi(); renderPalette.call(this, state); const hint = spawnTools.validationHint(state); setStatus(`实体已更新:${spawnTools.spawnSummary(state)}${hint ? ` · ${hint}` : ''}`); } return changed; }; const setActiveTool = (toolId) => { if (state.editMode !== 'map') return; state.tool = toolId; state.boxStart = null; state.boxEnd = null; state.lastPaintKey = null; for (const [id, btn] of Object.entries(toolButtons)) { if (btn) btn.classList.toggle('active', id === toolId); } if (this.$.canvas) { this.$.canvas.style.cursor = tools.TOOL_CURSORS[toolId] || 'default'; } renderPalette.call(this, state); setStatus(tools.TOOL_LABELS[toolId] || toolId); }; const setEditLayer = (layer) => { state.editLayer = layer; this.$.layerGround.classList.toggle('active', layer === 'ground'); this.$.layerBorder.classList.toggle('active', layer === 'border'); drawGrid.call(this, state); renderPalette.call(this, state); const count = Object.keys(tools.layerMap(state)).length; setStatus( `绘制层:${layer === 'ground' ? 'Ground' : 'Border'}(${count} 格已高亮)— ${tools.TOOL_LABELS[state.tool]}`, ); }; const selectBrush = (tile) => { state.brush = tile; if (state.tool === 'eraser') setActiveTool('paint'); else renderPalette.call(this, state); state.editLayer = tile.layer; this.$.layerGround.classList.toggle('active', tile.layer === 'ground'); this.$.layerBorder.classList.toggle('active', tile.layer === 'border'); drawGrid.call(this, state); }; const applyToolAt = (cell, options = {}) => { if (!state.config || !cell || !isInGrid(cell)) return false; const { x, y, key } = cell; const force = options.force === true; switch (state.tool) { case 'paint': { if (!tools.canPaintBrush(state)) { if (!force) setStatus('请先在右侧调色板选择瓦片,或按吸管吸取'); return false; } if (!force && state.lastPaintKey === key) return false; tools.setCell(state, key, state.brush.tileKey); state.lastPaintKey = key; scheduleMapPersist(); return true; } case 'eraser': { if (!force && state.lastPaintKey === key) return false; if (tools.hasCell(state, key)) { tools.removeCell(state, key); state.lastPaintKey = key; scheduleMapPersist(); return true; } return false; } case 'picker': { let tileKey = tools.getCell(state, key); if (tileKey === undefined) { setStatus(`(${x},${y}) 当前层为空,无法吸取`); return false; } if (tileKey === true) tileKey = 'WallBlock'; const tile = tools.findPaletteTile(state, tileKey, state.editLayer); if (!tile) { setStatus(`(${x},${y}) 瓦片 ${tileKey} 不在当前主题调色板中`); return false; } selectBrush(tile); setActiveTool('paint'); setStatus(`已吸取 ${tile.display},切换为画笔`); return true; } case 'fill': { if (!tools.canPaintBrush(state)) { setStatus('填充前请先在右侧选择瓦片'); return false; } const n = tools.floodFill(state, key, state.brush.tileKey); setStatus(`油漆桶:已填充 ${n} 格`); if (n > 0) scheduleMapPersist(); return n > 0; } default: return false; } }; const finishBoxSelect = () => { if (!state.boxStart || !state.boxEnd) return; const x0 = Math.max(GRID_MIN, Math.min(state.boxStart.x, state.boxEnd.x)); const y0 = Math.max(GRID_MIN, Math.min(state.boxStart.y, state.boxEnd.y)); const x1 = Math.min(GRID_MAX, Math.max(state.boxStart.x, state.boxEnd.x)); const y1 = Math.min(GRID_MAX, Math.max(state.boxStart.y, state.boxEnd.y)); let n = 0; if (state.tool === 'box') { if (!tools.canPaintBrush(state)) { setStatus('框选填充前请先在右侧选择瓦片'); } else { n = tools.fillRectangle(state, x0, y0, x1, y1, state.brush.tileKey); setStatus(`框选:已填充 ${n} 格(${state.brush.display})`); if (n > 0) scheduleMapPersist(); } } state.boxStart = null; state.boxEnd = null; state.lastPaintKey = null; drawGrid.call(this, state); }; bindOnce(this.$.btnLoad, 'click', () => loadLevel.call(this)); bindOnce(this.$.btnPrev, 'click', () => navigateLevel.call(this, 'prev')); bindOnce(this.$.btnNext, 'click', () => navigateLevel.call(this, 'next')); bindOnce(this.$.btnThemeCtrl, 'click', () => { try { Editor.Panel.open('theme-controller'); } catch (e) { setStatus('请先在扩展管理器中启用 theme-controller 扩展'); } }); bindOnce(this.$.btnNew, 'click', () => createNewLevel.call(this)); bindOnce(this.$.btnSave, 'click', () => saveLevel.call(this)); bindOnce(this.$.btnBake, 'click', () => bakeLevel.call(this)); bindOnce(this.$.btnImportPrefab, 'click', () => importFromPrefab.call(this)); bindOnce(this.$.btnOpenPrefab, 'click', () => openLevelPrefab.call(this)); const snapSceneSelection = async () => { let uuids = []; try { uuids = Editor.Selection.getSelected('node') || []; } catch (e) { setStatus('无法读取场景选中节点'); return; } if (!uuids.length) { setStatus('请先在场景编辑器选中 Ground/Border 下的瓦片节点'); return; } let ok = 0; for (const uuid of uuids) { try { const res = await Editor.Message.request('scene', 'execute-scene-script', { name: 'level-map-editor', method: 'snapNodeByUuid', args: [uuid], }); if (res && res.ok) ok++; } catch (e) { console.warn('[level-map-editor] snap failed', uuid, e); } } setStatus(`场景吸附完成:${ok}/${uuids.length} 个节点已对齐格子`); }; const attachGridSnapHelper = async () => { let uuids = Editor.Selection.getSelected('node') || []; if (!uuids.length) { const prefabUuid = await Editor.Message.request( 'asset-db', 'query-uuid', `db://assets/resources/level-prefabs/Level${state.levelId}.prefab`, ); if (prefabUuid) uuids = [prefabUuid]; } if (!uuids.length) { setStatus('请选中 Level 预制体根节点,或先打开 Level 预制体'); return; } try { await Editor.Message.request('scene', 'execute-scene-script', { name: 'level-map-editor', method: 'attachHelperOnSelection', args: [uuids[0]], }); setStatus('已在 Level 根节点添加 GridSnapHelper(拖动瓦片将自动吸附)'); } catch (e) { setStatus(`添加吸附助手失败: ${e.message}。也可手动添加组件 GridSnapHelper 到 Level 根节点`); } }; bindOnce(this.$.btnSceneSnap, 'click', () => snapSceneSelection.call(this)); bindOnce(this.$.btnAttachHelper, 'click', () => attachGridSnapHelper.call(this)); bindOnce(this.$.themeSelect, 'change', () => { state.theme = this.$.themeSelect.value || 'silu'; if (state.config) state.config.theme = state.theme; applyThemeTiles(); drawGrid.call(this, state); if (state.config) scheduleMapPersist(); setStatus(`关卡地图主题:${THEME_LABELS[state.theme] || state.theme}(修改后自动保存)`); }); bindOnce(this.$.layerGround, 'click', () => setEditLayer.call(this, 'ground')); bindOnce(this.$.layerBorder, 'click', () => setEditLayer.call(this, 'border')); bindOnce(this.$.modeMap, 'click', () => setEditMode.call(this, 'map')); bindOnce(this.$.modeSpawns, 'click', () => setEditMode.call(this, 'spawns')); bindOnce(this.$.spawnPlayer, 'click', () => setSpawnTool.call(this, 'player')); bindOnce(this.$.spawnVehicle, 'click', () => setSpawnTool.call(this, 'vehicle')); bindOnce(this.$.spawnProp, 'click', () => setSpawnTool.call(this, 'prop')); bindOnce(this.$.spawnPropGround, 'click', () => setSpawnTool.call(this, 'prop_ground')); bindOnce(this.$.spawnErase, 'click', () => setSpawnTool.call(this, 'erase')); bindOnce(this.$.spawnClearVehicle, 'click', () => { if (spawnTools.clearVehicleSpawn(state)) { scheduleMapPersist(); syncSpawnDirUi(); drawGrid.call(this, state); setStatus(`已清除载具 · ${spawnTools.spawnSummary(state)}`); } else { setStatus('当前关卡无载具'); } }); bindOnce(this.$.spawnDirection, 'change', () => { state.spawnDirection = this.$.spawnDirection.value || 'Direction.South'; }); bindOnce(this.$.spawnPlacementScale, 'change', () => { const n = spawnDefaults.clampScale(this.$.spawnPlacementScale?.value); state.placementScale[state.spawnTool] = n; if (this.$.spawnPlacementScale) this.$.spawnPlacementScale.value = String(n); }); bindOnce(this.$.toolPaint, 'click', () => setActiveTool('paint')); bindOnce(this.$.toolBox, 'click', () => setActiveTool('box')); bindOnce(this.$.toolPicker, 'click', () => setActiveTool('picker')); bindOnce(this.$.toolEraser, 'click', () => setActiveTool('eraser')); bindOnce(this.$.toolFill, 'click', () => setActiveTool('fill')); bindOnce(this.$.canvas, 'mousedown', (ev) => { if (!state.config) return; if (ev.button === 2) { ev.preventDefault(); state.rmbDown = true; state.panActive = false; state.panLast = { x: ev.clientX, y: ev.clientY }; if (state.rmbTimer) clearTimeout(state.rmbTimer); state.rmbTimer = setTimeout(() => { if (state.rmbDown) { state.panActive = true; if (this.$.canvas) this.$.canvas.style.cursor = 'grabbing'; } }, RMB_HOLD_MS); return; } if (ev.button !== 0) return; state.painting = true; state.lastPaintKey = null; const cell = cellFromEvent.call(this, ev, state); if (!cell || !isInGrid(cell)) return; if (state.editMode === 'spawns') { if (ev.shiftKey && selectSpawnAtCell.call(this, cell)) { state.painting = false; return; } if (applySpawnAt.call(this, cell)) drawGrid.call(this, state); state.painting = false; return; } if (state.tool === 'box') { state.boxStart = cell; state.boxEnd = cell; drawGrid.call(this, state); return; } if (applyToolAt(cell, { force: true })) drawGrid.call(this, state); }); bindOnce(this.$.canvas, 'mousemove', (ev) => { if (state.rmbDown && state.panActive && state.panLast) { const rect = this.$.canvas.getBoundingClientRect(); const scaleX = this.$.canvas.width / rect.width; const scaleY = this.$.canvas.height / rect.height; state.panX += (ev.clientX - state.panLast.x) * scaleX; state.panY += (ev.clientY - state.panLast.y) * scaleY; state.panLast = { x: ev.clientX, y: ev.clientY }; drawGrid.call(this, state); return; } if (!state.config) return; const c = cellFromEvent.call(this, ev, state); const changed = !state.hoverCell || !c || state.hoverCell.key !== c.key; state.hoverCell = c; if (state.painting && state.tool === 'box' && state.boxStart && c && isInGrid(c)) { state.boxEnd = c; drawGrid.call(this, state); return; } if (changed) drawGrid.call(this, state); if (!state.painting && this.$.status && c) { if (state.editMode === 'spawns') { const hits = spawnTools.spawnsAt(state, c.x, c.y); const hitInfo = hits.length ? ` [${hits.map((h) => spawnTools.SPAWN_KIND_LABELS[h.kind] || h.kind).join('+')}]` : ''; const hint = spawnTools.validationHint(state); this.$.status.textContent = `[实体] 格子 (${c.x},${c.y})${hitInfo} · ${spawnTools.spawnSummary(state)}${hint ? ` · ${hint}` : ''}`; } else { const layer = state.editLayer === 'ground' ? 'Ground' : 'Border'; const v = isInGrid(c) ? tools.getCell(state, c.key) : undefined; const info = v !== undefined ? ` 当前=${v === true ? '墙' : v}` : ' 空'; const bounds = isInGrid(c) ? '' : ' [超出 60×60]'; this.$.status.textContent = `[${layer}] 格子 (${c.x},${c.y})${info}${bounds} · 缩放 ${Math.round(state.zoom * 100)}% — ${tools.TOOL_LABELS[state.tool]}`; } } if (state.painting && state.editMode === 'map' && (state.tool === 'paint' || state.tool === 'eraser') && c && isInGrid(c)) { if (applyToolAt(c)) drawGrid.call(this, state); } }); const endPan = () => { if (state.rmbTimer) { clearTimeout(state.rmbTimer); state.rmbTimer = null; } state.rmbDown = false; state.panActive = false; state.panLast = null; if (this.$.canvas) { this.$.canvas.style.cursor = tools.TOOL_CURSORS[state.tool] || 'crosshair'; } }; bindOnce(this.$.canvas, 'mouseup', (ev) => { if (ev.button === 2) { endPan.call(this); return; } if (state.painting && state.tool === 'box') finishBoxSelect.call(this); state.painting = false; state.lastPaintKey = null; }); bindOnce(this.$.canvas, 'mouseleave', () => { if (state.painting && state.tool === 'box') finishBoxSelect.call(this); state.painting = false; state.lastPaintKey = null; endPan.call(this); }); bindOnce(this.$.canvas, 'contextmenu', (ev) => ev.preventDefault()); const onWindowMouseUp = (ev) => { if (ev.button === 2 && state.rmbDown) endPan.call(this); }; window.addEventListener('mouseup', onWindowMouseUp); this._lmeWindowMouseUp = onWindowMouseUp; bindOnceOpts(this.$.canvas, 'wheel', (ev) => { ev.preventDefault(); if (!state.config || !this.$.canvas) return; const canvas = this.$.canvas; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const mx = (ev.clientX - rect.left) * scaleX; const my = (ev.clientY - rect.top) * scaleY; const logical = screenToLogical(mx, my, state, canvas); const factor = ev.deltaY > 0 ? 0.96 : 1.04; const oldZoom = state.zoom; const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, oldZoom * factor)); if (newZoom === oldZoom) return; const cx = canvas.width / 2; const cy = canvas.height / 2; state.zoom = newZoom; state.panX = mx - cx - (logical.x - cx) * newZoom; state.panY = my - cy - (logical.y - cy) * newZoom; drawGrid.call(this, state); }, { passive: false }); setActiveTool('paint'); syncEditModeUi(); setSpawnTool('player'); if (this.$.spawnDirection) { this.$.spawnDirection.innerHTML = ''; const dirLabels = { 'Direction.North': '北 North', 'Direction.East': '东 East', 'Direction.South': '南 South', 'Direction.West': '西 West', }; for (const d of spawnTools.DIRECTIONS) { const opt = document.createElement('option'); opt.value = d; opt.textContent = dirLabels[d] || d; if (d === state.spawnDirection) opt.selected = true; this.$.spawnDirection.appendChild(opt); } } const onKeyDown = (ev) => { if (ev.repeat) return; const tag = (ev.target && ev.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA') return; const shortcuts = { b: 'paint', e: 'eraser', i: 'picker', f: 'fill', r: 'box', }; const t = shortcuts[ev.key && ev.key.toLowerCase()]; if (t) { ev.preventDefault(); setActiveTool(t); } }; window.addEventListener('keydown', onKeyDown); this._lmeKeyHandler = onKeyDown; state._lastDraw = () => drawGrid.call(this, state); const canvasWrap = this.$.canvas?.parentElement; const resizeAndDraw = () => { if (resizeCanvas(this.$.canvas, canvasWrap)) { computeBaseOffset(state, this.$.canvas.width, this.$.canvas.height); if (!state._viewFitted) fitViewToGrid(state, this.$.canvas); } drawGrid.call(this, state); }; if (canvasWrap && typeof ResizeObserver !== 'undefined') { const ro = new ResizeObserver(() => resizeAndDraw()); ro.observe(canvasWrap); this._lmeResizeObserver = ro; } resizeAndDraw(); loadPalettesIndex.call(this); try { state.db = readDatabase(); const ids = Object.keys(state.db.levels || {}) .map((k) => parseInt(k, 10)) .filter((n) => !Number.isNaN(n)) .sort((a, b) => a - b); const openId = ids.length ? ids[ids.length - 1] : levelIdUtil.minLevelId(); if (this.$.levelId) this.$.levelId.value = String(openId); } catch (e) { console.warn('[level-map-editor] init db', e); } loadLevel.call(this); state._viewFitted = true; this._lmeState = state; this._lmeApplySpawnInspector = applySpawnInspector; this._lmeApplyEntityTextures = applyEntityTextures; this._lmePersistMap = () => scheduleMapPersist(); this._lmeReloadFromDisk = reloadFromDisk; const onPrefabSynced = (payload) => { const levelId = payload && typeof payload === 'object' ? payload.levelId : payload; if (!levelId) return; reloadFromDisk(Number(levelId)); }; const addListener = Editor.Message.__protected__?.addBroadcastListener || Editor.Message.addBroadcastListener; if (typeof addListener === 'function') { addListener.call(Editor.Message, 'level-map-editor:prefab-synced', onPrefabSynced); this._lmePrefabSyncListener = onPrefabSynced; } }; const MAP_GRID_SIZE = 60; const GRID_MIN = -Math.floor(MAP_GRID_SIZE / 2); const GRID_MAX = GRID_MIN + MAP_GRID_SIZE - 1; const RMB_HOLD_MS = 200; const ZOOM_MIN = 0.06; const ZOOM_MAX = 4; const INITIAL_VIEW_ZOOM = 0.5; function isInGrid(cell) { if (!cell) return false; return cell.x >= GRID_MIN && cell.x <= GRID_MAX && cell.y >= GRID_MIN && cell.y <= GRID_MAX; } function resizeCanvas(canvas, wrap) { if (!canvas || !wrap) return false; const w = Math.max(320, wrap.clientWidth || 720); const h = Math.max(240, wrap.clientHeight || 520); if (canvas.width === w && canvas.height === h) return false; canvas.width = w; canvas.height = h; return true; } function computeBaseOffset(state, canvasW, canvasH) { const halfH = grid.PANEL_HALF_H; state.baseOffsetX = canvasW / 2; state.baseOffsetY = canvasH / 2 + halfH; state.offsetX = state.baseOffsetX; state.offsetY = state.baseOffsetY; } function fitViewToGrid(state, canvas) { if (!canvas || !canvas.width || !canvas.height) return; state.zoom = INITIAL_VIEW_ZOOM; state.panX = 0; state.panY = 0; computeBaseOffset(state, canvas.width, canvas.height); } function screenToLogical(mx, my, state, canvas) { const cx = canvas.width / 2; const cy = canvas.height / 2; return { x: (mx - cx - state.panX) / state.zoom + cx, y: (my - cy - state.panY) / state.zoom + cy, }; } function visibleGridRange(state, canvas) { const pts = [ screenToLogical(0, 0, state, canvas), screenToLogical(canvas.width, 0, state, canvas), screenToLogical(0, canvas.height, state, canvas), screenToLogical(canvas.width, canvas.height, state, canvas), ]; let minX = GRID_MAX; let maxX = GRID_MIN; let minY = GRID_MAX; let maxY = GRID_MIN; const pad = 3; for (const p of pts) { const c = grid.cellFromCanvas(p.x, p.y, state.offsetX, state.offsetY); minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x); minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y); } return { minX: Math.max(GRID_MIN, minX - pad), maxX: Math.min(GRID_MAX, maxX + pad), minY: Math.max(GRID_MIN, minY - pad), maxY: Math.min(GRID_MAX, maxY + pad), }; } /** 等距菱形:cx,cy 为格子中心(与贴图 pivot / Tilemap tileAnchor 一致) */ function traceIsoDiamondCenter(ctx, cx, cy, halfW, halfH) { ctx.beginPath(); ctx.moveTo(cx, cy - halfH); ctx.lineTo(cx + halfW, cy); ctx.lineTo(cx, cy + halfH); ctx.lineTo(cx - halfW, cy); ctx.closePath(); } function drawIsoDiamondCenter(ctx, cx, cy, halfW, halfH, strokeStyle, fillStyle, lineWidth) { traceIsoDiamondCenter(ctx, cx, cy, halfW, halfH); if (fillStyle) { ctx.fillStyle = fillStyle; ctx.fill(); } if (strokeStyle) { ctx.strokeStyle = strokeStyle; ctx.lineWidth = lineWidth || 1; ctx.stroke(); } } function drawIsoGrid(ctx, state, canvas) { const halfW = grid.PANEL_HALF_W; const halfH = grid.PANEL_HALF_H; const range = canvas ? visibleGridRange(state, canvas) : { minX: GRID_MIN, maxX: GRID_MAX, minY: GRID_MIN, maxY: GRID_MAX, }; for (let x = range.minX; x <= range.maxX; x++) { for (let y = range.minY; y <= range.maxY; y++) { const { x: cx, y: cy } = cellCenterCanvas(x, y, state.offsetX, state.offsetY); const isHover = state.hoverCell && state.hoverCell.x === x && state.hoverCell.y === y; const onEdge = x === GRID_MIN || x === GRID_MAX || y === GRID_MIN || y === GRID_MAX; drawIsoDiamondCenter( ctx, cx, cy, halfW, halfH, isHover ? '#4a9eff' : (onEdge ? '#777' : '#555'), isHover ? 'rgba(74,158,255,0.2)' : 'rgba(60,60,60,0.25)', isHover ? 2 : 1, ); if (isHover) { ctx.fillStyle = '#fff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${x},${y}`, cx, cy + 4); } } } } function textureForTile(state, tileKey, layer) { const t = state.tiles.find((tile) => tile.tileKey === tileKey); if (t) return t.texture; const theme = state.theme || 'silu'; if (layer === 'ground') { return `textures/${theme}/${tileKey === 'JumpBlock' ? 'JumpBlock' : 'Baseblock'}`; } return `textures/${theme}/${tileKey || 'WallBlock'}`; } /** 格子中心(Unity pivot 落点) */ function cellCenterCanvas(cx, cy, offsetX, offsetY) { return grid.cellCenterCanvas(cx, cy, offsetX, offsetY); } function getTileDrawRect(state, cell) { const { x: cx, y: cy } = cellCenterCanvas(cell.x, cell.y, state.offsetX, state.offsetY); const meta = tileMeta.getTileMeta(cell.tileName, state.config?.theme); const texRel = textureForTile(state, cell.tileName, cell.layer || 'ground'); const img = loadImageCache(state, texRel); const w = (img.complete && img.naturalWidth > 0) ? img.naturalWidth : meta.width; const h = (img.complete && img.naturalHeight > 0) ? img.naturalHeight : meta.height; return tileMeta.spriteDrawRect(cx, cy, w, h, meta, grid.PANEL_HALF_W); } function cellHitTestTiles(mx, my, state) { if (!state.config) return null; const cells = allSortedCells(state); for (let i = cells.length - 1; i >= 0; i--) { const cell = cells[i]; const r = getTileDrawRect(state, cell); if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { return { x: cell.x, y: cell.y, key: cell.key }; } } return null; } function drawIsoCell(ctx, state, x, y, texRel, label) { const { x: cx, y: cy } = cellCenterCanvas(x, y, state.offsetX, state.offsetY); const meta = tileMeta.getTileMeta(label, state.config?.theme); const img = loadImageCache(state, texRel); if (img.complete && img.naturalWidth > 0) { const rect = tileMeta.spriteDrawRect( cx, cy, img.naturalWidth, img.naturalHeight, meta, grid.PANEL_HALF_W, ); ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h); } else { const halfW = grid.PANEL_HALF_W; const halfH = grid.PANEL_HALF_H; const { x: cx, y: cy } = cellCenterCanvas(x, y, state.offsetX, state.offsetY); drawIsoDiamondCenter(ctx, cx, cy, halfW, halfH, '#888', '#4a4a5a', 1); ctx.fillStyle = '#ccc'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText((label || '').slice(0, 6), cx, cy + 3); } } function isoDrawOrder(a, b) { const ka = a.x + a.y; const kb = b.x + b.y; if (ka !== kb) return kb - ka; return b.x - a.x; } function sortedCells(mapObj) { return Object.entries(mapObj || {}) .map(([key, tileName]) => { const [xs, ys] = key.split(','); const cx = parseInt(xs, 10); const cy = parseInt(ys, 10); return { key, x: cx, y: cy, tileName, sortKey: cx + cy }; }) .sort(isoDrawOrder); } function allSortedCells(state) { const cells = []; for (const cell of sortedCells(state.config.ground)) { cells.push({ ...cell, layer: 'ground' }); } for (const cell of sortedCells(state.config.border)) { const name = typeof cell.tileName === 'string' ? cell.tileName : 'WallBlock'; cells.push({ ...cell, tileName: name, layer: 'border' }); } cells.sort(isoDrawOrder); return cells; } const LAYER_HIGHLIGHT = { ground: { stroke: '#66bb6a', fill: 'rgba(102,187,106,0.28)', dim: 0.32 }, border: { stroke: '#ffb74d', fill: 'rgba(255,183,77,0.28)', dim: 0.32 }, }; function cellsForLayer(state, layer) { return allSortedCells(state).filter((c) => c.layer === layer); } function drawActiveLayerHighlight(ctx, state) { const layer = state.editLayer || 'ground'; const style = LAYER_HIGHLIGHT[layer]; const halfW = grid.PANEL_HALF_W; const halfH = grid.PANEL_HALF_H; const map = layer === 'ground' ? state.config.ground : state.config.border; ctx.save(); for (const cell of sortedCells(map)) { const { x: cx, y: cy } = cellCenterCanvas(cell.x, cell.y, state.offsetX, state.offsetY); drawIsoDiamondCenter(ctx, cx, cy, halfW, halfH, style.stroke, style.fill, 2); } ctx.restore(); } function drawGrid(state) { const canvas = this.$.canvas; const wrap = canvas?.parentElement; if (!canvas || !state.config) return; resizeCanvas(canvas, wrap); computeBaseOffset(state, canvas.width, canvas.height); const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#3a3a3a'; ctx.fillRect(0, 0, canvas.width, canvas.height); const cx = canvas.width / 2; const cy = canvas.height / 2; ctx.save(); ctx.translate(cx + state.panX, cy + state.panY); ctx.scale(state.zoom, state.zoom); ctx.translate(-cx, -cy); drawIsoGrid(ctx, state, canvas); const active = state.editLayer || 'ground'; const inactive = active === 'ground' ? 'border' : 'ground'; const dim = LAYER_HIGHLIGHT[active].dim; ctx.save(); ctx.globalAlpha = dim; for (const cell of cellsForLayer(state, inactive)) { drawIsoCell( ctx, state, cell.x, cell.y, textureForTile(state, cell.tileName, cell.layer), cell.tileName, ); } ctx.restore(); for (const cell of cellsForLayer(state, active)) { drawIsoCell( ctx, state, cell.x, cell.y, textureForTile(state, cell.tileName, cell.layer), cell.tileName, ); } drawActiveLayerHighlight(ctx, state); drawBoxPreview(ctx, state); drawSpawns(ctx, state); ctx.restore(); } function directionAngle(dir) { switch (dir) { case 'Direction.North': return -Math.PI / 2; case 'Direction.East': return 0; case 'Direction.South': return Math.PI / 2; case 'Direction.West': return Math.PI; default: return Math.PI / 2; } } function drawSpawns(ctx, state) { const spawns = state.config?.spawns || []; if (!spawns.length) return; const grouped = new Map(); for (const s of spawns) { const k = `${s.x},${s.y}`; if (!grouped.has(k)) grouped.set(k, []); grouped.get(k).push(s); } ctx.save(); for (const [, list] of grouped) { const { x, y } = list[0]; const { x: cx, y: cy } = cellCenterCanvas(x, y, state.offsetX, state.offsetY); const n = list.length; list.forEach((s, idx) => { const ox = (idx - (n - 1) / 2) * 18; const px = cx + ox; const py = cy - 6; const isPlayer = s.kind === 'player'; const isVehicle = s.kind === 'vehicle'; const scaleMul = spawnTools.formatSpawnScale(s); const baseR = isPlayer ? 15 : isVehicle ? 13 : 10; const r = baseR * scaleMul; const selected = state.selectedSpawn === s; const fill = isPlayer ? 'rgba(33,150,243,0.92)' : isVehicle ? 'rgba(76,175,80,0.92)' : 'rgba(255,193,7,0.95)'; ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = selected ? '#ff5722' : '#fff'; ctx.lineWidth = selected ? 3 : 2; ctx.stroke(); if (selected) { ctx.beginPath(); ctx.arc(px, py, r + 4, 0, Math.PI * 2); ctx.strokeStyle = '#ff5722'; ctx.lineWidth = 2; ctx.stroke(); } ctx.fillStyle = '#fff'; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(isPlayer ? 'P' : isVehicle ? 'V' : '●', px, py); const dirStr = isPlayer ? s.playerDirection : isVehicle ? s.vehicleDirection : null; if (dirStr) { const ang = directionAngle(dirStr); const len = 12; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px + Math.cos(ang) * len, py + Math.sin(ang) * len); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } }); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${x},${y}`, cx, cy + 16); } ctx.restore(); } function drawBoxPreview(ctx, state) { if (state.tool !== 'box' || !state.boxStart || !state.boxEnd) return; const halfW = grid.PANEL_HALF_W; const halfH = grid.PANEL_HALF_H; const x0 = state.boxStart.x; const y0 = state.boxStart.y; const x1 = state.boxEnd.x; const y1 = state.boxEnd.y; const minX = Math.min(x0, x1); const maxX = Math.max(x0, x1); const minY = Math.min(y0, y1); const maxY = Math.max(y0, y1); ctx.save(); ctx.fillStyle = 'rgba(74, 158, 255, 0.2)'; ctx.strokeStyle = '#4a9eff'; ctx.lineWidth = 2; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const { x: cx, y: cy } = cellCenterCanvas(x, y, state.offsetX, state.offsetY); drawIsoDiamondCenter(ctx, cx, cy, halfW, halfH, '#4a9eff', 'rgba(74, 158, 255, 0.2)', 2); } } ctx.restore(); } function cellFromEvent(ev, state) { const canvas = this.$.canvas; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const mx = (ev.clientX - rect.left) * scaleX; const my = (ev.clientY - rect.top) * scaleY; const { x: lx, y: ly } = screenToLogical(mx, my, state, canvas); if (state.tool === 'picker') { const hit = cellHitTestTiles(lx, ly, state); if (hit) return hit; } return grid.cellFromCanvas(lx, ly, state.offsetX, state.offsetY); } function renderPalette(state) { const list = this.$.paletteList; const title = this.$.paletteTitle; if (!list) return; list.innerHTML = ''; if (state.editMode === 'spawns') { if (title) title.textContent = `实体配置(主题 ${state.theme || state.config?.theme || 'sanxing'})`; if (state.selectedSpawn && !spawnTools.spawnStillExists(state, state.selectedSpawn)) { state.selectedSpawn = null; } const hint = document.createElement('div'); hint.style.cssText = 'grid-column:1/-1;font-size:11px;line-height:1.55;padding:4px;color:var(--color-normal-contrast-weaken);'; hint.innerHTML = [ '关卡贴图:entityTextures(整关默认)· 单个可拾取物可用 spawns[].texture', '
路径相对 assets/resources,不含 .png;留空则按地图主题推断', '
Shift+点击格子选中实体,可改坐标/朝向/缩放/贴图', `
${spawnTools.spawnSummary(state)}`, spawnTools.validationHint(state) ? `
${spawnTools.validationHint(state)}` : '', ].join(''); list.appendChild(hint); const texBox = document.createElement('div'); texBox.style.cssText = 'grid-column:1/-1;display:flex;flex-direction:column;gap:6px;margin-top:6px;padding:8px;border:1px solid var(--color-normal-fill-emphasis);border-radius:4px;'; const texTitle = document.createElement('div'); texTitle.style.fontWeight = 'bold'; texTitle.style.fontSize = '11px'; texTitle.textContent = '关卡实体贴图(entityTextures)'; texBox.appendChild(texTitle); const texRowStyle = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;'; for (const f of entityTexPresets.ENTITY_TEXTURE_FIELDS) { const row = document.createElement('div'); row.style.cssText = texRowStyle; const lab = document.createElement('ui-label'); lab.textContent = f.label; lab.style.minWidth = '72px'; lab.style.fontSize = '11px'; const inp = document.createElement('ui-input'); inp.id = `entity-tex-${f.key}`; inp.placeholder = 'textures/主题/...'; inp.style.flex = '1'; inp.style.minWidth = '120px'; row.appendChild(lab); row.appendChild(inp); texBox.appendChild(row); } const texBtnRow = document.createElement('div'); texBtnRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;'; const fillBtn = document.createElement('ui-button'); fillBtn.textContent = '从当前主题填充'; fillBtn.addEventListener('click', () => { const theme = state.theme || state.config?.theme || 'silu'; if (themeDb) { const themesData = themeDb.readThemesDb(projectPath()); const t = themesData.themes?.[theme]; if (t?.entities) { entityTexPresets.ensureEntityTextures(state); Object.assign(state.config.entityTextures, { ...t.entities }); entityTexPresets.pruneEmptyEntityTextures(state); if (this._lmePersistMap) this._lmePersistMap(); } else { entityTexPresets.applyThemePreset(state, theme); } } else { entityTexPresets.applyThemePreset(state, theme); } renderPalette.call(this, state); }); const saveTexBtn = document.createElement('ui-button'); saveTexBtn.textContent = '应用贴图配置'; saveTexBtn.addEventListener('click', () => { if (this._lmeApplyEntityTextures) this._lmeApplyEntityTextures(); }); const clearTexBtn = document.createElement('ui-button'); clearTexBtn.textContent = '清空'; clearTexBtn.addEventListener('click', () => { if (state.config) delete state.config.entityTextures; if (this._lmePersistMap) this._lmePersistMap(); renderPalette.call(this, state); if (this.$.status) this.$.status.textContent = '已清空 entityTextures'; }); texBtnRow.appendChild(fillBtn); texBtnRow.appendChild(saveTexBtn); texBtnRow.appendChild(clearTexBtn); texBox.appendChild(texBtnRow); list.appendChild(texBox); entityTexPresets.writeEntityTexturesToPanel(list, state.config?.entityTextures); const spawns = state.config?.spawns || []; if (spawns.length) { const listTitle = document.createElement('div'); listTitle.style.cssText = 'grid-column:1/-1;font-size:11px;font-weight:bold;margin-top:6px;'; listTitle.textContent = '实体列表(点击选中)'; list.appendChild(listTitle); for (const s of spawns) { const btn = document.createElement('ui-button'); const label = spawnTools.SPAWN_KIND_LABELS[s.kind] || s.kind; const sc = spawnTools.formatSpawnScale(s); const dir = s.playerDirection || s.vehicleDirection || ''; btn.textContent = `${label} (${s.x},${s.y})${dir ? ` ${dir.replace('Direction.', '')}` : ''}${sc !== 1 ? ` ×${sc}` : ''}${s.texture ? ' 🖼' : ''}`; if (state.selectedSpawn === s) btn.classList.add('active'); btn.style.cssText = 'grid-column:1/-1;justify-content:flex-start;text-align:left;'; btn.addEventListener('click', () => { state.selectedSpawn = s; const d = s.playerDirection || s.vehicleDirection; if (d && this.$.spawnDirection) this.$.spawnDirection.value = d; state.placementScale[s.kind] = sc; if (this.$.spawnPlacementScale) this.$.spawnPlacementScale.value = String(sc); renderPalette.call(this, state); drawGrid.call(this, state); }); list.appendChild(btn); } } const sel = state.selectedSpawn; const panel = document.createElement('div'); panel.style.cssText = 'grid-column:1/-1;display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:8px;border:1px solid var(--color-normal-fill-emphasis);border-radius:4px;'; const panelTitle = document.createElement('div'); panelTitle.style.fontWeight = 'bold'; panelTitle.style.fontSize = '11px'; panelTitle.textContent = sel ? `编辑:${spawnTools.SPAWN_KIND_LABELS[sel.kind] || sel.kind}` : '选中实体后可编辑属性'; panel.appendChild(panelTitle); const rowStyle = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;'; const mkRow = (labelText, inputEl) => { const row = document.createElement('div'); row.style.cssText = rowStyle; const lab = document.createElement('ui-label'); lab.textContent = labelText; lab.style.minWidth = '36px'; row.appendChild(lab); row.appendChild(inputEl); return row; }; const xIn = document.createElement('ui-num-input'); xIn.id = 'spawn-inspect-x'; xIn.step = '1'; xIn.style.width = '72px'; xIn.disabled = !sel; if (sel) xIn.value = String(sel.x); const yIn = document.createElement('ui-num-input'); yIn.id = 'spawn-inspect-y'; yIn.step = '1'; yIn.style.width = '72px'; yIn.disabled = !sel; if (sel) yIn.value = String(sel.y); panel.appendChild(mkRow('X', xIn)); panel.appendChild(mkRow('Y', yIn)); const needDir = sel && (sel.kind === 'player' || sel.kind === 'vehicle'); if (needDir) { const dirSel = document.createElement('ui-select'); dirSel.id = 'spawn-inspect-dir'; dirSel.style.minWidth = '96px'; for (const d of spawnTools.DIRECTIONS) { const opt = document.createElement('option'); opt.value = d; opt.textContent = d.replace('Direction.', ''); dirSel.appendChild(opt); } const curDir = sel.playerDirection || sel.vehicleDirection || 'Direction.South'; dirSel.value = curDir; panel.appendChild(mkRow('朝向', dirSel)); } const scaleIn = document.createElement('ui-num-input'); scaleIn.id = 'spawn-inspect-scale'; scaleIn.step = '0.1'; scaleIn.min = '0.1'; scaleIn.max = '4'; scaleIn.style.width = '72px'; scaleIn.disabled = !sel; scaleIn.value = sel ? String(spawnTools.formatSpawnScale(sel)) : '1'; panel.appendChild(mkRow('缩放', scaleIn)); const texIn = document.createElement('ui-input'); texIn.id = 'spawn-inspect-texture'; texIn.placeholder = '留空=用关卡默认'; texIn.style.flex = '1'; texIn.disabled = !sel; texIn.value = sel?.texture || ''; panel.appendChild(mkRow('贴图', texIn)); const applyBtn = document.createElement('ui-button'); applyBtn.textContent = '应用修改'; applyBtn.disabled = !sel; applyBtn.addEventListener('click', () => { if (this._lmeApplySpawnInspector) this._lmeApplySpawnInspector(); }); panel.appendChild(applyBtn); list.appendChild(panel); return; } if (title) title.textContent = `平铺调色板 — ${THEME_LABELS[state.theme] || state.theme}`; const needBrush = state.tool === 'paint' || state.tool === 'box' || state.tool === 'fill'; const editLayer = state.editLayer || 'ground'; for (const tile of state.tiles) { if (tile.layer !== editLayer) continue; const div = document.createElement('div'); const active = needBrush && state.brush && tileBrushId(state.brush) === tileBrushId(tile); const disabled = state.tool === 'eraser' || state.tool === 'picker'; div.className = 'lme-tile' + (active ? ' active' : '') + (disabled ? ' lme-tile-dim' : ''); if (disabled) div.style.opacity = '0.55'; const img = document.createElement('img'); const texPath = textureFsPath(tile.texture); if (fs.existsSync(texPath)) { img.src = `file://${texPath}`; } const span = document.createElement('span'); span.textContent = tile.display || tile.tileKey; div.appendChild(img); div.appendChild(span); div.addEventListener('click', () => { selectBrushFromPalette.call(this, state, tile); }); list.appendChild(div); } } function selectBrushFromPalette(state, tile) { state.brush = tile; state.editLayer = tile.layer; if (this.$.layerGround && this.$.layerBorder) { this.$.layerGround.classList.toggle('active', tile.layer === 'ground'); this.$.layerBorder.classList.toggle('active', tile.layer === 'border'); } if (state.tool === 'eraser' || state.tool === 'picker') { for (const [id, btn] of Object.entries({ paint: this.$.toolPaint, box: this.$.toolBox, picker: this.$.toolPicker, eraser: this.$.toolEraser, fill: this.$.toolFill, })) { if (btn) btn.classList.toggle('active', id === 'paint'); } state.tool = 'paint'; if (this.$.canvas) this.$.canvas.style.cursor = tools.TOOL_CURSORS.paint; } renderPalette.call(this, state); if (this.$.status) { this.$.status.textContent = `已选 ${tile.display}(${tile.layer})— 可用画笔/框选/填充绘制`; } drawGrid.call(this, state); } exports.close = function () { if (this._lmeState) { const state = this._lmeState; if (state._autoBakeTimer) { clearTimeout(state._autoBakeTimer); state._autoBakeTimer = null; } } if (this._lmeResizeObserver) { this._lmeResizeObserver.disconnect(); this._lmeResizeObserver = null; } if (this._lmeWindowMouseUp) { window.removeEventListener('mouseup', this._lmeWindowMouseUp); this._lmeWindowMouseUp = null; } if (this._lmeKeyHandler) { window.removeEventListener('keydown', this._lmeKeyHandler); this._lmeKeyHandler = null; } if (this._lmePrefabSyncListener) { const rm = Editor.Message.__protected__?.removeBroadcastListener || Editor.Message.removeBroadcastListener; if (typeof rm === 'function') { rm.call(Editor.Message, 'level-map-editor:prefab-synced', this._lmePrefabSyncListener); } this._lmePrefabSyncListener = null; } this._lmeReady = false; };