'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 预制体
场景选中吸附
启用场景吸附助手
主题控制器
地图主题
编辑
地图
实体配置
玩家×1
载具 0/1
可拾取物(砖)
可拾取物(空地)
删除
清除载具
朝向
放置缩放
绘制层
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;
};