fix: 刺放置方向问题

This commit is contained in:
cancerai committed 2026-10-22 21:42:43 +08:00
1 parent 6ba47dfd88
commit bf0f065432
8 files changed
+580 -120

No files matched your search

+335 -89
View File
@@ -1,26 +1,61 @@
// 简易关卡编辑器
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
import { TILE } from './util.js';
import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js';
const $ = s => document.querySelector(s);
const H_ROWS = 19;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const PALETTE = [
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '上刺', '#c8c8d8'], ['v', '下刺', '#a0a0b4'], ['<', '左刺', '#9090a8'], ['>', '右刺', '#9090a8'],
['^', '刺', '#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; }
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
const SPIKE_ARROW = { up: '↑', 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;
@@ -28,7 +63,8 @@ export const Editor = {
this.ctx = this.canvas.getContext('2d');
this.buildPalette();
this.bind();
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value || 25));
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
$('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
this.renderMyLevels();
},
@@ -38,7 +74,7 @@ export const Editor = {
PALETTE.forEach(([ch, label, col]) => {
const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : '');
d.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch}</span><span>${label}</span>`;
d.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch === '?' ? '⌖' : ch}</span><span>${label}</span>`;
d.onclick = () => {
this.sel = ch;
Array.from(p.children).forEach(c => c.classList.remove('on'));
@@ -57,10 +93,25 @@ export const Editor = {
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 >= this.w || ty >= H_ROWS) return;
this.map[ty][tx] = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
// 选中该格(属性面板依据它渲染)
this.selTile = { tx, ty };
// 「查看属性」工具:只选中,不修改地图
if (this.sel === '?') { this.renderProps(); return; }
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();
@@ -72,55 +123,118 @@ export const Editor = {
c.addEventListener('contextmenu', e => e.preventDefault());
c.addEventListener('wheel', e => {
e.preventDefault();
this.scroll = Math.max(0, Math.min((this.w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
this.scroll = Math.max(0, Math.min((this.curFace().w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
this.draw();
}, { passive: false });
document.querySelector('[data-act="ed-resize"]').onclick = () => this.resize(+$('#ed-w').value || 25);
document.querySelector('[data-act="ed-fill"]').onclick = () => {
for (let x = 0; x < this.w; x++) this.map[H_ROWS - 1][x] = '#';
this.draw();
};
document.querySelector('[data-act="ed-clear"]').onclick = () => {
if (!confirm('清空当前编辑的关卡?')) return;
this.map = blank(this.w); this.draw();
};
document.querySelector('[data-act="ed-test"]').onclick = () => this.test();
document.querySelector('[data-act="ed-save"]').onclick = () => this.save();
document.querySelector('[data-act="ed-export"]').onclick = () => this.exportSel();
document.querySelector('[data-act="ed-import"]').onclick = () => this.importJSON();
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-addface', () => this.addFace());
on('ed-copyface', () => this.copyFace());
on('ed-delface', () => this.delFace());
},
// ---------------- 多面地图状态 ----------------
curFace() { return this.faces[this.face]; },
open() {
if (!this.map) { this.w = +$('#ed-w').value || 25; this.map = blank(this.w); }
if (!this.faces) {
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.selTile = null;
$('#ed-w').value = this.curFace().w;
$('#ed-h').value = this.curFace().h;
this.draw();
this.renderFaces();
this.renderProps();
this.renderMyLevels();
},
resize(w) {
w = Math.max(12, Math.min(120, w || 25));
const old = this.map;
this.w = w;
this.map = blank(w);
if (old) for (let y = 0; y < H_ROWS; y++) for (let x = 0; x < Math.min(w, old[0].length); x++) this.map[y][x] = old[y][x];
$('#ed-w').value = w;
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;
c.width = 800; c.height = H_ROWS * TILE;
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);
for (let y = 0; y < H_ROWS; y++) {
for (let x = 0; x < this.w; x++) {
const ch = this.map[y][x];
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 !== '.') {
let col = '#555';
const item = PALETTE.find(p => p[0] === ch);
ctx.fillStyle = item ? item[2] : '#555';
ctx.globalAlpha = ch === '.' ? 0.2 : 0.85;
if (item) col = item[2];
if (ch === 'W') { const d = f.doors.find(o => o.tx === x && o.ty === y); col = (d && d.to) ? '#3ddc84' : '#8a8a9a'; }
ctx.fillStyle = col;
ctx.globalAlpha = 0.85;
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
ctx.globalAlpha = 1;
}
@@ -130,32 +244,158 @@ export const Editor = {
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
if (ch === 'W') {
const d = f.doors.find(o => o.tx === x && o.ty === y);
ctx.fillText('W' + (d ? d.id : ''), px + TILE / 2, py + TILE / 2);
} else if (ch === '^') {
// 刺:用箭头直观显示方向
const dir = ((f.props[y][x] || {}).dir) || 'up';
ctx.fillText(SPIKE_ARROW[dir] || '↑', px + TILE / 2, py + TILE / 2);
} else ctx.fillText(ch, px + TILE / 2, py + TILE / 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 STAGE').toUpperCase(),
type: $('#ed-type').value,
width: this.w,
grid: this.map.map(r => r.join('')),
bg: 'custom',
hint: '自制关卡',
messages: ['自制关卡'],
name: ($('#ed-name').value || 'MY MAP').toUpperCase(),
type: 'custom',
startFace: 0,
faces,
};
},
validate() {
const g = this.map.map(r => r.join(''));
const s = g.join('').split('S').length - 1;
const x = g.join('').split('X').length - 1;
if (s !== 1) return '必须且只能有一个 S(出生点),当前 ' + s + ' 个';
if (x < 1) return '至少要有一个 X(终点)';
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;
},
@@ -175,46 +415,28 @@ export const Editor = {
this.api.refresh?.();
},
exportSel() {
const list = Save.customs();
const lv = list.find(l => l.id === this.cur) || list[list.length - 1];
$('#ed-json').value = lv ? JSON.stringify(lv) : JSON.stringify(makeCustomLevel(this.buildData()));
},
importJSON() {
try {
const o = JSON.parse($('#ed-json').value);
if (!o.grid) throw new Error('缺少 grid');
this.w = o.width || o.grid[0].length;
$('#ed-w').value = this.w;
$('#ed-name').value = o.name || 'IMPORTED';
$('#ed-type').value = ['jump', 'endur', 'boss', 'troll'].includes(o.type) ? o.type : 'jump';
this.map = blank(this.w);
for (let y = 0; y < Math.min(H_ROWS, o.grid.length); y++) {
const line = o.grid[y];
for (let x = 0; x < Math.min(this.w, line.length); x++) this.map[y][x] = line[x] || '.';
}
this.draw();
} catch (e) { alert('导入失败:' + e.message); }
},
// ---------------- 我的关卡 ----------------
renderMyLevels() {
const box = $('#my-levels');
if (!box) return;
box.innerHTML = '';
const list = Save.customs();
if (!list.length) box.innerHTML = '<div style="color:#666;font-size:.8em">还没有保存的关卡</div>';
list.forEach((l, i) => {
const d = document.createElement('div');
const fn = (l.faces || []).length;
d.className = 'my-lv' + (l.id === this.cur ? ' on' : '');
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}</span><span class="x">✕</span>`;
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}${fn ? ` <b style="color:#a86bff">[${fn}面]</b>` : ''}</span><span class="x">✕</span>`;
d.querySelector('span').onclick = () => {
this.cur = l.id;
this.load(l);
this.hydrate(l);
$('#ed-name').value = l.name || '';
this.afterFaceSwitch();
this.renderMyLevels();
};
d.querySelector('.x').onclick = (e) => {
e.stopPropagation();
if (!confirm('删除 ' + l.name + '?')) return;
if (typeof confirm === 'function' && !confirm('删除 ' + l.name + '?')) return;
Save.removeCustom(l.id);
this.renderMyLevels();
this.api.refresh?.();
@@ -223,22 +445,46 @@ export const Editor = {
});
},
load(l) {
this.w = l.width || l.grid[0].length;
$('#ed-w').value = this.w;
$('#ed-name').value = l.name || '';
$('#ed-type').value = ['jump', 'endur', 'boss', 'troll'].includes(l.type) ? l.type : 'jump';
this.map = blank(this.w);
for (let y = 0; y < Math.min(H_ROWS, l.grid.length); y++) {
const line = l.grid[y];
for (let x = 0; x < Math.min(this.w, line.length); x++) this.map[y][x] = line[x] || '.';
}
this.draw();
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 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) {
return Array.from({ length: H_ROWS }, () => Array.from({ length: w }, () => '.'));
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]));
+5 -1
View File
@@ -127,8 +127,12 @@ export class Level {
}
if (s.type === 'warp') {
const cfg = this.warpSrc[e.idx] || {};
// 自制多面地图:门的目标直接挂在 door.to 上
// door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送)
const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty);
e.doorId = door ? door.id : e.idx;
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
e.to = cfg.to || null; // {x,y} 绝对像素 / 'spawn'
e.to = (door && door.to) ? door.to : (cfg.to || null);
e.cool = 0;
e.hidden = !!cfg.hidden;
}
+84 -9
View File
@@ -32,21 +32,44 @@ const G = {
state: 'menu', // menu | playing | paused | result | editor
level: null, player: null, cam: null,
data: null, idx: -1,
map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标
deaths: 0, ms: 0, hudTick: 0,
time: 0,
lastLevelId: null,
};
// ---------------- 关卡流程 ----------------
// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors)
function faceData(map, face, i) {
return {
id: (map.id || 'map') + '-' + i,
name: face.name || ('面' + (i + 1)),
type: 'custom',
width: face.width || (face.grid[0] ? face.grid[0].length : 25),
grid: face.grid,
doors: face.doors || [],
bg: 'custom', hint: '', messages: [],
};
}
function startLevel(data, idx = -1) {
unlockAudio();
G.data = data; G.idx = idx; G.lastLevelId = data.id;
G.level = new Level(data);
// 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理
if (data.faces && data.faces.length) {
G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; });
G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1));
} else {
G.map = [new Level(data)]; G.face = 0;
}
G.level = G.map[G.face];
G.player = new Player(G.level);
G.cam = new Camera(W, H);
G.cam.reset(G.level, G.player);
G.player.onDeath = () => { G.deaths++; };
G.player.onClear = () => finish();
G.player.onPortal = (e) => doPortal(e);
G.player.onRespawnFace = () => doRespawnFace();
G.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
G.deaths = 0; G.ms = 0; G.hudTick = 0;
G.state = 'playing';
@@ -56,6 +79,53 @@ function startLevel(data, idx = -1) {
if (data.hint) UI.toast(data.hint);
}
// 把玩家放到某面某门上(不切面的同面传送也用)
function placeOnDoor(dest) {
const P = G.player;
P.x = dest.x + dest.w / 2 - P.w / 2;
P.y = dest.y + dest.h - P.h;
P.vx = 0; P.vy = 0; P.jumps = 0;
}
// 踩到门:e.to = {face, door}
function doPortal(e) {
const to = e.to;
const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0));
if (tp === G.face) {
const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door);
if (dest) { placeOnDoor(dest); dest.cool = 40; }
sfx.warp();
} else {
switchFace(tp, to.door);
}
}
// 切换到第 f 面,并落到该面的目标门上
function switchFace(f, doorId) {
G.face = f;
G.level = G.map[f];
G.level.reset(true); // 进入该面时重置其运行时
G.player.level = G.level;
const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId);
if (dest) { placeOnDoor(dest); dest.cool = 40; }
else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; }
G.cam.reset(G.level, G.player);
sfx.warp();
G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
}
// 复活回到存档点所在面(跨面)
function doRespawnFace() {
const sp = G.player.savePoint;
if (sp && typeof sp.face === 'number' && sp.face !== G.face) {
G.face = sp.face;
G.level = G.map[sp.face];
G.level.reset(true);
G.player.level = G.level;
G.cam.reset(G.level, G.player);
}
}
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
function restart() {
if (!G.data) return;
@@ -103,26 +173,31 @@ function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame
// ---------------- 更新 ----------------
function update() {
const L = G.level, P = G.player;
const P = G.player;
// R = 自杀回存档点;Backspace = 整关重开
if (Input.hit('suicide')) { suicide(); return; }
if (Input.hit('restart')) { restart(); return; }
if (Input.hit('pause')) { pause(); return; }
const wasDead = P.dead;
L.update(1, P, G.cam);
for (const b of L.bosses) b.update(1, P);
G.level.update(1, P, G.cam);
for (const b of G.level.bosses) b.update(1, P);
P.update(1);
// 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去
if (P.justRespawned || (wasDead && !P.dead)) G.cam.snapTo(P, L);
P.justRespawned = false;
G.cam.update(L, P, 1);
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
if (P.justRespawned) {
if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace();
G.cam.snapTo(P, G.level);
P.justRespawned = false;
}
G.cam.update(G.level, P, 1);
G.ms += STEP;
G.time++;
if (++G.hudTick % 5 === 0) {
const L = G.level;
UI.hud({
name: G.data.name, deaths: G.deaths, ms: G.ms,
name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''),
deaths: G.deaths, ms: G.ms,
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
});
+10 -3
View File
@@ -297,7 +297,7 @@ export class Player {
case 'save':
if (!e.active) {
e.active = true;
this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h };
this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h, face: (this.level.faceIndex ?? 0) };
this.saveIdx = e.no || 1;
sfx.save();
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
@@ -326,8 +326,15 @@ export class Player {
case 'warp': {
if (e.cool > 0) break;
e.cool = 40;
const tgt = e.to === 'spawn' ? L.spawn
: e.to ? { x: e.to[0] * TILE, y: e.to[1] * TILE }
const to = e.to;
// 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面
if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') {
if (this.onPortal) this.onPortal(e);
break;
}
// 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对)
const tgt = to === 'spawn' ? L.spawn
: to && ('x' in to) ? { x: to.x * TILE, y: to.y * TILE }
: (() => {
const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null;