556 lines
25 KiB
JavaScript
556 lines
25 KiB
JavaScript
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
|
||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|