Files
kidgo/js/editor.js
T

556 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
import { TILE } from './util.js';
import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js';
import * as S from './sprites.js';
const $ = s => document.querySelector(s);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
export const PALETTE = [
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '刺', '#c8c8d8'],
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
['P', '手枪', '#b8b8c8'], ['F', '苹果', '#ff8095'], ['J', '跳刷新', '#3ddc84'], ['W', '传送门', '#a86bff'],
['T', '提示牌', '#c9a86b'], ['O', '炮台', '#6a6a80'], ['D', '掉落刺', '#8a5a6a'], ['C', '摄像头', '#7ae0ff'],
['B', 'BOSS', '#ff5c7a'], ['!', '触发器', '#ff9a3a'], ['.', '橡皮', '#20202c'],
];
// 每个方块类型可编辑的属性(属性面板按此渲染)
// type: portal(传送门目标) | text | num | select
const SCHEMA = {
'^': [{ type: 'select', key: 'dir', label: '方向', def: 'up', opts: [['up', '上'], ['down', '下'], ['left', '左'], ['right', '右']] }],
W: [{ type: 'portal' }],
T: [{ type: 'text', key: 'msg', label: '提示文字', placeholder: '这块牌子显示的话' }],
O: [
{ type: 'select', key: 'dir', label: '朝向', opts: [['left', '左'], ['right', '右'], ['up', '上'], ['down', '下']] },
{ type: 'num', key: 'period', label: '发射间隔(帧)', min: 10, max: 600, def: 90 },
{ type: 'num', key: 'speed', label: '子弹速度', min: 0.5, max: 10, step: 0.1, def: 2.4 },
{ type: 'num', key: 'offset', label: '初始延迟(帧)', min: 0, max: 600, def: 0 },
],
C: [
{ type: 'num', key: 'w', label: '区域宽(格)', min: 1, max: 40, def: 3 },
{ type: 'num', key: 'h', label: '区域高(格)', min: 1, max: 40, def: 3 },
{ type: 'select', key: 'mode', label: '镜头模式', opts: [['lock', '锁定'], ['zoom', '缩放'], ['follow-off', '跟随关闭']] },
{ type: 'num', key: 'zoom', label: '缩放倍数', min: 0.3, max: 3, step: 0.1, def: 1 },
],
B: [
{ type: 'select', key: 'kind', label: 'BOSS 类型', opts: [['guy', '小兵'], ['mecha', '机甲']] },
{ type: 'num', key: 'hp', label: '血量', min: 1, max: 999, def: 40 },
],
};
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
// 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得
function drawTileReal(ctx, ch, px, py, props, t) {
const P = props || {};
switch (ch) {
case '#': S.drawBlock(ctx, px, py, 0); break; // 方块
case '@': S.drawBlock(ctx, px, py, 1); break; // 草地
case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块
case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属
case '%': S.drawIce(ctx, px, py); break; // 冰面
case '~': S.drawWater(ctx, px, py, t); break; // 水
case '-': S.drawOneWay(ctx, px, py); break; // 单向台
case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块
case '^': { // 刺:真实三角形,按道具方向
const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0;
S.drawSpike(ctx, px, py, di, t);
break;
}
case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果
case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺
case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点
case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点
case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪
case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果
case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新
case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门
case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌
case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台
case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头
case 'S': S.drawKid(ctx, { // 出生点:画一个小人
x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21,
facing: 1, grounded: true, hasGun: false, dead: false,
}, t); break;
default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底
}
}
// 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见
function drawMarker(ctx, px, py, ch) {
const item = PALETTE.find(p => p[0] === ch);
const col = item ? item[2] : '#555';
ctx.fillStyle = col; ctx.globalAlpha = 0.85;
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
}
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
export const Editor = {
api: {},
map: null, w: 25, sel: '#',
scroll: 0, drag: 0, cur: null,
canvas: null, ctx: null,
// 多面地图状态
faces: null, face: 0,
// 当前选中的方块坐标(属性面板依据它渲染)
selTile: null,
init(api) {
this.api = api;
this.canvas = $('#ed-canvas');
this.ctx = this.canvas.getContext('2d');
this.buildPalette();
this.bind();
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
$('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
},
buildPalette() {
const p = $('#palette');
p.innerHTML = '';
PALETTE.forEach(([ch, label]) => {
const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : '');
// 用真实绘制函数渲染缩略图,所见即所得
const cv = document.createElement('canvas');
cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE;
drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0);
d.appendChild(cv);
const sp = document.createElement('span');
sp.textContent = label;
d.appendChild(sp);
d.onclick = () => {
this.sel = ch;
Array.from(p.children).forEach(c => c.classList.remove('on'));
d.classList.add('on');
};
p.appendChild(d);
});
},
bind() {
const c = this.canvas;
const pos = e => {
const r = c.getBoundingClientRect();
const sx = (e.clientX - r.left) * (c.width / r.width);
const sy = (e.clientY - r.top) * (c.height / r.height);
return { tx: Math.floor((sx + this.scroll) / TILE), ty: Math.floor(sy / TILE) };
};
const act = (e, erase) => {
const f = this.curFace();
const { tx, ty } = pos(e);
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
// 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
this.selTile = { tx, ty };
const prev = f.grid[ty][tx];
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
// 放置不覆盖:放实方块时若该格已有方块则跳过,仅选中查看属性
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty);
if (ch === 'W' && prev !== 'W') { f.seq = (f.seq || 0) + 1; f.doors.push({ id: f.seq, tx, ty, to: null }); }
// 改变方块种类时清空该格属性(属性按类型存储)
if (prev !== ch) f.props[ty][tx] = null;
f.grid[ty][tx] = ch;
this.draw();
this.renderProps();
};
c.addEventListener('mousedown', e => {
e.preventDefault();
this.drag = e.button === 2 ? 2 : 1;
act(e, e.button === 2);
});
c.addEventListener('mousemove', e => { if (this.drag) act(e, this.drag === 2); });
addEventListener('mouseup', () => { this.drag = 0; });
c.addEventListener('contextmenu', e => e.preventDefault());
c.addEventListener('wheel', e => {
e.preventDefault();
this.scroll = Math.max(0, Math.min((this.curFace().w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
this.draw();
}, { passive: false });
const on = (actName, fn) => { const el = document.querySelector(`[data-act="${actName}"]`); if (el) el.onclick = fn; };
on('ed-resize', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
on('ed-fill', () => { const f = this.curFace(); for (let x = 0; x < f.w; x++) f.grid[f.h - 1][x] = '#'; this.draw(); });
on('ed-clear', () => { if (typeof confirm === 'function' && !confirm('清空当前面?')) return; const f = this.curFace(); f.grid = blank(f.w, f.h); f.doors = []; f.props = propsBlank(f.w, f.h); this.selTile = null; this.draw(); this.renderProps(); });
on('ed-test', () => this.test());
on('ed-save', () => this.save());
on('ed-import', () => this.openImport());
on('ed-import-cancel', () => this.closeImport());
on('ed-addface', () => this.addFace());
on('ed-copyface', () => this.copyFace());
on('ed-delface', () => this.delFace());
},
// ---------------- 多面地图状态 ----------------
curFace() { return this.faces[this.face]; },
open() {
// 从菜单进入编辑器:始终是新地图(宽高沿用输入框里的值)
const w = +$('#ed-w').value || 25, h = +$('#ed-h').value || 19;
this.faces = [{ name: '面1', w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) }];
this.face = 0;
this.cur = null; // 未关联任何已存地图 → 首次保存为新建
this.show();
},
/** 仅重绘当前状态(不从零重置):用于试玩后「返回编辑器」等场景,保留正在编辑的地图 */
show() {
if (!this.faces) { this.open(); return; }
this.selTile = null;
$('#ed-w').value = this.curFace().w;
$('#ed-h').value = this.curFace().h;
this.draw();
this.renderFaces();
this.renderProps();
this.updateSaveState();
},
/** 「保存」按钮旁的提示:会更新哪张图,还是另存为新图 */
updateSaveState() {
const el = $('#ed-savestate');
if (!el) return;
const l = this.cur ? Save.customs().find(o => o.id === this.cur) : null;
el.textContent = l ? '→ 更新「' + (l.name || 'MY MAP') + '」' : '→ 另存为新图';
el.classList.toggle('on', !!l);
},
resize(w, h) {
w = clamp(w || 25, 12, 160);
h = clamp(h || 19, 8, 80);
const f = this.curFace();
const og = f.grid, op = f.props;
f.grid = blank(w, h);
f.props = propsBlank(w, h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
if (og[y] && og[y][x] !== undefined) f.grid[y][x] = og[y][x];
if (op[y] && op[y][x] !== undefined) f.props[y][x] = op[y][x];
}
f.doors = f.doors.filter(d => d.tx < w && d.ty < h);
f.w = w; f.h = h;
$('#ed-w').value = w; $('#ed-h').value = h;
this.canvas.height = h * TILE;
this.selTile = null;
this.draw();
this.renderProps();
},
addFace() {
const w = this.curFace().w, h = this.curFace().h;
this.faces.push({ name: '面' + (this.faces.length + 1), w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) });
this.face = this.faces.length - 1;
this.afterFaceSwitch();
},
copyFace() {
const f = this.curFace();
const copy = {
name: f.name + ' 副本', w: f.w, h: f.h, seq: f.seq,
grid: f.grid.map(r => r.slice()),
doors: f.doors.map(d => ({ ...d })),
props: f.props.map(r => r.map(c => (c ? { ...c } : null))),
};
this.faces.push(copy);
this.face = this.faces.length - 1;
this.afterFaceSwitch();
},
delFace() {
if (this.faces.length <= 1) { alert('至少保留一个面'); return; }
if (typeof confirm === 'function' && !confirm('删除「' + this.curFace().name + '」?该面的门与连接都会消失')) return;
this.faces.splice(this.face, 1);
this.face = Math.max(0, this.face - 1);
this.afterFaceSwitch();
},
afterFaceSwitch() {
$('#ed-w').value = this.curFace().w;
$('#ed-h').value = this.curFace().h;
this.scroll = 0;
this.selTile = null;
this.draw();
this.renderFaces();
this.renderProps();
},
removeDoor(f, tx, ty) {
const i = f.doors.findIndex(d => d.tx === tx && d.ty === ty);
if (i >= 0) f.doors.splice(i, 1);
},
// ---------------- 绘制 ----------------
draw() {
const ctx = this.ctx, c = this.canvas;
const f = this.curFace();
c.width = 800; c.height = f.h * TILE;
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
ctx.save();
ctx.translate(-this.scroll, 0);
const t = 0; // 编辑器为静态预览,不跑动画
for (let y = 0; y < f.h; y++) {
for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x];
const px = x * TILE, py = y * TILE;
// 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
// 淡网格线(编辑对位用)
ctx.strokeStyle = 'rgba(255,255,255,.06)';
ctx.lineWidth = 1;
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
// 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示)
if (ch === 'W') {
const d = f.doors.find(o => o.tx === x && o.ty === y);
ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
ctx.font = 'bold 9px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 2);
}
}
}
// 选中方块高亮
if (this.selTile && this.selTile.tx >= 0 && this.selTile.ty >= 0 && this.selTile.tx < f.w && this.selTile.ty < f.h) {
const sx = this.selTile.tx * TILE, sy = this.selTile.ty * TILE;
ctx.strokeStyle = '#ffd23a'; ctx.lineWidth = 2;
ctx.strokeRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
}
ctx.restore();
},
// ---------------- 面列表面板 ----------------
renderFaces() {
const box = $('#ed-faces');
if (!box) return;
box.innerHTML = '';
this.faces.forEach((f, i) => {
const d = document.createElement('div');
d.className = 'face-row' + (i === this.face ? ' on' : '');
d.innerHTML = `<input class="face-name" value="${escapeHtml(f.name)}" title="面名称"><span class="face-wh">${f.w}×${f.h} · ${f.doors.length}门</span>`;
d.querySelector('.face-name').addEventListener('change', e => { f.name = e.target.value; });
d.querySelector('.face-name').addEventListener('click', e => e.stopPropagation());
d.onclick = () => { this.face = i; this.afterFaceSwitch(); };
box.appendChild(d);
});
},
// ---------------- 属性面板(选中方块) ----------------
renderProps() {
const box = $('#ed-props');
if (!box) return;
const f = this.curFace();
if (!this.selTile) { box.innerHTML = '<div class="prop-empty">点击画布上的方块,查看并设置它的属性</div>'; return; }
const { tx, ty } = this.selTile;
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '<div class="prop-empty">(越界)</div>'; return; }
const ch = f.grid[ty][tx];
const label = charLabel(ch);
const fields = SCHEMA[ch];
if (!fields) {
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div><div class="prop-empty">该方块没有可设置的属性</div>`;
return;
}
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div>`;
for (const fd of fields) {
const row = document.createElement('div');
row.className = 'prop-row';
if (fd.type === 'portal') {
const door = f.doors.find(d => d.tx === tx && d.ty === ty);
if (!door) { row.innerHTML = '<span class="prop-lab">跳转到下一关</span><span class="prop-note">(此格未登记为门,请重新放置 W)</span>'; box.appendChild(row); continue; }
const cur = door.to || null;
const tf = cur ? cur.face : this.face;
const td = cur ? cur.door : -1;
const faceOpts = this.faces.map((gf, gi) => `<option value="${gi}" ${gi === tf ? 'selected' : ''}>面${gi + 1}${gf.name !== ('面' + (gi + 1)) ? '·' + escapeHtml(gf.name) : ''}</option>`).join('');
const tface = this.faces[tf];
const doorOpts = (tface.doors.length
? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => `<option value="${od.id}" ${td === od.id ? 'selected' : ''}>门${od.id}</option>`).join('')
: '') || '<option value="-1">(该面暂无其他门)</option>';
row.innerHTML = `<span class="prop-lab">跳转到下一关</span><div class="prop-ctl"><select class="p-face">${faceOpts}</select><select class="p-door">${doorOpts}</select></div>`;
const fsel = row.querySelector('.p-face');
const dsel = row.querySelector('.p-door');
const apply = () => {
const ntf = +fsel.value, ntd = +dsel.value;
door.to = (ntd >= 0) ? { face: ntf, door: ntd } : null;
this.draw();
this.renderProps();
};
fsel.onchange = apply;
dsel.onchange = apply;
} else if (fd.type === 'text') {
const val = (f.props[ty][tx] || {})[fd.key] || '';
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="text" placeholder="${fd.placeholder || ''}" value="${escapeHtml(val)}">`;
const inp = row.querySelector('.prop-in');
inp.oninput = () => { ensureProp(f, tx, ty)[fd.key] = inp.value; };
} else if (fd.type === 'num') {
const raw = (f.props[ty][tx] || {})[fd.key];
const val = raw !== undefined && raw !== null && raw !== '' ? raw : (fd.def !== undefined ? fd.def : '');
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="number" min="${fd.min !== undefined ? fd.min : ''}" max="${fd.max !== undefined ? fd.max : ''}" step="${fd.step !== undefined ? fd.step : 1}" value="${val}">`;
const inp = row.querySelector('.prop-in');
inp.oninput = () => {
const v = inp.value === '' ? (fd.def !== undefined ? fd.def : null) : (fd.step && String(fd.step).includes('.') ? +inp.value : +inp.value);
ensureProp(f, tx, ty)[fd.key] = v;
};
} else if (fd.type === 'select') {
const cur = (f.props[ty][tx] || {})[fd.key];
const val = cur !== undefined && cur !== '' ? cur : (fd.def !== undefined ? fd.def : '');
const opts = fd.opts.map(([v, t]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${t}</option>`).join('');
row.innerHTML = `<span class="prop-lab">${fd.label}</span><select class="prop-in">${opts}</select>`;
const sel = row.querySelector('.prop-in');
sel.onchange = () => { ensureProp(f, tx, ty)[fd.key] = sel.value; };
}
box.appendChild(row);
}
},
// ---------------- 数据 ----------------
buildData() {
const faces = this.faces.map(f => {
// 编辑器刺统一为 '^'+props.dir,导出时按方向翻译成引擎字符 ^ v < >
const grid = f.grid.map((r, y) => r.map((ch, x) => {
if (ch === '^') return SPIKE_CHAR[((f.props[y][x] || {}).dir) || 'up'];
return ch;
}).join(''));
// 按扫描顺序收集各类型属性,整型数组(引擎按扫描下标读取)
const messages = [], turrets = [], camzones = [], bosses = [];
for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) {
const ch = f.grid[y][x];
const p = f.props[y][x];
if (ch === 'T' && p && p.msg !== undefined && p.msg !== '') messages.push(p.msg);
if (ch === 'O' && p) turrets.push({ dir: p.dir || 'left', period: +(p.period || 90), speed: +(p.speed || 2.4), offset: +(p.offset || 0), kind: p.kind || 'ball' });
if (ch === 'C' && p) camzones.push({ w: +(p.w || 3), h: +(p.h || 3), mode: p.mode || 'lock', zoom: +(p.zoom || 1) });
if (ch === 'B' && p) bosses.push({ kind: p.kind || 'guy', hp: +(p.hp || 40) });
}
return {
name: f.name,
width: f.w,
height: f.h,
grid,
doors: f.doors.map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
messages, turrets, camzones, bosses,
};
});
return {
name: ($('#ed-name').value || 'MY MAP').toUpperCase(),
type: 'custom',
startFace: 0,
faces,
};
},
validate() {
for (let i = 0; i < this.faces.length; i++) {
const g = this.faces[i].grid.map(r => r.join(''));
const s = g.join('').split('S').length - 1;
const x = g.join('').split('X').length - 1;
if (s !== 1) return `面${i + 1}(${this.faces[i].name})必须且只能有 1 个 S,当前 ${s} 个`;
if (x < 1) return `面${i + 1}(${this.faces[i].name})至少要 1 个 X(终点)`;
}
// 门不能指向自己所在格子
for (let i = 0; i < this.faces.length; i++) {
for (const d of this.faces[i].doors) {
if (d.to && d.to.face === i && d.to.door === d.id) return `面${i + 1} 的门${d.id} 不能指向自己`;
}
}
return null;
},
test() {
const err = this.validate();
if (err) { alert(err); return; }
this.api.startLevel(makeCustomLevel(this.buildData()));
},
save() {
const err = this.validate();
if (err) { alert(err); return; }
let lv = makeCustomLevel(this.buildData());
if (this.cur) lv.id = this.cur; // 保存当前地图:沿用已有 id,就地更新
Save.updateCustom(lv);
this.cur = lv.id;
this.updateSaveState();
this.api.refresh?.();
},
// ---------------- 导入地图(从已保存的地图里选) ----------------
openImport() {
const box = $('#ed-importbox');
if (!box) return;
const list = $('#ed-import-list');
const customs = Save.customs();
list.innerHTML = '';
if (!customs.length) {
list.innerHTML = '<div class="imp-empty">还没有保存过的地图,先「保存」一个吧</div>';
} else {
customs.forEach(l => {
const fn = (l.faces || []).length;
const d = document.createElement('div');
d.className = 'imp-row' + (l.id === this.cur ? ' on' : '');
d.innerHTML = `<span class="imp-nm">${escapeHtml(l.name || 'MY MAP')}</span><span class="imp-meta">${fn ? fn + ' 面' : '单关'}</span>`;
d.onclick = () => this.pickImport(l.id);
list.appendChild(d);
});
}
box.classList.remove('hidden');
},
closeImport() { $('#ed-importbox')?.classList.add('hidden'); },
pickImport(id) {
const l = Save.customs().find(o => o.id === id);
if (!l) return;
this.cur = l.id;
this.hydrate(l);
$('#ed-name').value = l.name || '';
this.closeImport();
this.afterFaceSwitch();
this.updateSaveState();
},
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 messages/turrets/camzones/bosses)
hydrate(o) {
this.faces = o.faces.map(f => {
const w = f.width || (f.grid[0] ? f.grid[0].length : 25);
const h = f.height || f.grid.length;
const grid = f.grid.map(r => r.split(''));
const props = propsBlank(w, h);
const face = {
name: f.name || '面',
w, h,
seq: (f.doors || []).reduce((m, d) => Math.max(m, d.id), 0),
grid,
doors: (f.doors || []).map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
props,
};
let mi = 0, ti = 0, ci = 0, bi = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const ch = grid[y][x];
if (ch === 'T') { const m = (f.messages || [])[mi++]; if (m !== undefined) props[y][x] = { msg: m }; }
else if (ch === 'O') { const t = (f.turrets || [])[ti++]; if (t) props[y][x] = { ...t }; }
else if (ch === 'C') { const c = (f.camzones || [])[ci++]; if (c) props[y][x] = { ...c }; }
else if (ch === 'B') { const b = (f.bosses || [])[bi++]; if (b) props[y][x] = { ...b }; }
// 引擎的四个刺字符 ^ v < > 统一还原为编辑器 '^' + props.dir
else if (SPIKE_DIR[ch] !== undefined) { grid[y][x] = '^'; props[y][x] = { dir: SPIKE_DIR[ch] }; }
}
return face;
});
this.face = 0;
},
};
function blank(w, h) {
return Array.from({ length: h }, () => Array.from({ length: w }, () => '.'));
}
function propsBlank(w, h) {
return Array.from({ length: h }, () => Array.from({ length: w }, () => null));
}
function ensureProp(f, tx, ty) {
if (!f.props[ty][tx]) f.props[ty][tx] = {};
return f.props[ty][tx];
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}