Files
cocos/extensions/level-map-editor/dist/panels/level-map-editor.js
刘宇飞 d393302388 Complete Cocos Creator port with level bundles, themes, and tooling.
Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:30:58 +08:00

1970 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 = `
<div class="lme-root">
<div class="lme-toolbar">
<ui-label>关卡 ID</ui-label>
<ui-num-input id="level-id" step="1" min="91601" value="91601"></ui-num-input>
<ui-button id="btn-prev">上一关</ui-button>
<ui-button id="btn-next">下一关</ui-button>
<ui-button id="btn-load">加载</ui-button>
<ui-button id="btn-new">新建</ui-button>
<ui-button id="btn-save" type="primary">保存 JSON</ui-button>
<ui-button id="btn-bake">烘焙预制体</ui-button>
<ui-button id="btn-import-prefab">从预制体同步</ui-button>
<ui-button id="btn-open-prefab">打开 Level 预制体</ui-button>
<ui-button id="btn-scene-snap">场景选中吸附</ui-button>
<ui-button id="btn-attach-helper">启用场景吸附助手</ui-button>
<span style="width:4px"></span>
<ui-button id="btn-theme-ctrl">主题控制器</ui-button>
<span style="width:4px"></span>
<ui-label>地图主题</ui-label>
<ui-select id="theme-select" style="min-width:140px"></ui-select>
<span style="width:4px"></span>
<ui-label>编辑</ui-label>
<ui-button class="lme-mode-btn active" id="mode-map">地图</ui-button>
<ui-button class="lme-mode-btn" id="mode-spawns">实体配置</ui-button>
<span class="lme-spawn-tools" id="spawn-tools-wrap" style="display:none">
<ui-button id="spawn-player" class="active">玩家×1</ui-button>
<ui-button id="spawn-vehicle">载具 0/1</ui-button>
<ui-button id="spawn-prop">可拾取物(砖)</ui-button>
<ui-button id="spawn-prop-ground">可拾取物(空地)</ui-button>
<ui-button id="spawn-erase">删除</ui-button>
<ui-button id="spawn-clear-vehicle">清除载具</ui-button>
<span class="lme-spawn-dir" id="spawn-dir-wrap">
<ui-label>朝向</ui-label>
<ui-select id="spawn-direction" style="min-width:96px"></ui-select>
</span>
<span class="lme-spawn-scale" id="spawn-scale-wrap">
<ui-label>放置缩放</ui-label>
<ui-num-input id="spawn-placement-scale" step="0.1" min="0.1" max="4" value="1" style="width:64px"></ui-num-input>
</span>
</span>
<span style="width:4px"></span>
<ui-label>绘制层</ui-label>
<ui-button class="lme-layer-btn active" id="layer-ground">Ground</ui-button>
<ui-button class="lme-layer-btn" id="layer-border">Border</ui-button>
<span class="lme-tools">
<ui-button id="tool-paint" class="active" title="画笔">画笔</ui-button>
<ui-button id="tool-box" title="框选填充">框选</ui-button>
<ui-button id="tool-picker" title="吸管">吸管</ui-button>
<ui-button id="tool-eraser" title="橡皮擦">橡皮擦</ui-button>
<ui-button id="tool-fill" title="油漆桶">填充</ui-button>
</span>
</div>
<div class="lme-body">
<div class="lme-center">
<div class="lme-canvas-wrap">
<canvas id="lme-canvas" width="720" height="520"></canvas>
</div>
<div class="lme-status" id="status">地图/出生点编辑 · 自动保存 JSON 并烘焙 · 60×60 网格 · 滚轮缩放 · 长按右键拖动</div>
</div>
<div class="lme-palette">
<h4 id="palette-title">平铺调色板</h4>
<div class="lme-palette-grid" id="palette-list"></div>
</div>
</div>
</div>
`;
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}.prefabGround / 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 = [
'关卡贴图:<code>entityTextures</code>(整关默认)· 单个可拾取物可用 <code>spawns[].texture</code>',
'<br>路径相对 <code>assets/resources</code>,不含 <code>.png</code>;留空则按地图主题推断',
'<br>Shift+点击格子选中实体,可改坐标/朝向/缩放/贴图',
`<br>${spawnTools.spawnSummary(state)}`,
spawnTools.validationHint(state) ? `<br><span style="color:#ffb74d">${spawnTools.validationHint(state)}</span>` : '',
].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;
};