Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
1970 lines
78 KiB
JavaScript
1970 lines
78 KiB
JavaScript
'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}.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 = [
|
||
'关卡贴图:<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;
|
||
};
|