246 lines
8.7 KiB
JavaScript
246 lines
8.7 KiB
JavaScript
// 简易关卡编辑器
|
||
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 PALETTE = [
|
||
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
|
||
['^', '上刺', '#c8c8d8'], ['v', '下刺', '#a0a0b4'], ['<', '左刺', '#9090a8'], ['>', '右刺', '#9090a8'],
|
||
['*', '美味水果', '#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'],
|
||
];
|
||
|
||
export const Editor = {
|
||
api: {},
|
||
map: null, w: 25, sel: '#',
|
||
scroll: 0, drag: 0, cur: null,
|
||
canvas: null, ctx: 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 || 25));
|
||
this.renderMyLevels();
|
||
},
|
||
|
||
buildPalette() {
|
||
const p = $('#palette');
|
||
p.innerHTML = '';
|
||
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.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 { 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);
|
||
this.draw();
|
||
};
|
||
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.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();
|
||
},
|
||
|
||
open() {
|
||
if (!this.map) { this.w = +$('#ed-w').value || 25; this.map = blank(this.w); }
|
||
this.draw();
|
||
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;
|
||
this.draw();
|
||
},
|
||
|
||
draw() {
|
||
const ctx = this.ctx, c = this.canvas;
|
||
c.width = 800; c.height = H_ROWS * 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];
|
||
const px = x * TILE, py = y * TILE;
|
||
if (ch !== '.') {
|
||
const item = PALETTE.find(p => p[0] === ch);
|
||
ctx.fillStyle = item ? item[2] : '#555';
|
||
ctx.globalAlpha = ch === '.' ? 0.2 : 0.85;
|
||
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
ctx.strokeStyle = 'rgba(255,255,255,.07)';
|
||
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
|
||
if (ch !== '.') {
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 14px monospace';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
},
|
||
|
||
// ---------- 数据 ----------
|
||
buildData() {
|
||
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: ['自制关卡'],
|
||
};
|
||
},
|
||
|
||
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(终点)';
|
||
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; }
|
||
const lv = makeCustomLevel(this.buildData());
|
||
Save.addCustom(lv);
|
||
this.cur = lv.id;
|
||
this.renderMyLevels();
|
||
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');
|
||
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');
|
||
d.className = 'my-lv' + (l.id === this.cur ? ' on' : '');
|
||
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}</span><span class="x">✕</span>`;
|
||
d.querySelector('span').onclick = () => {
|
||
this.cur = l.id;
|
||
this.load(l);
|
||
this.renderMyLevels();
|
||
};
|
||
d.querySelector('.x').onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (!confirm('删除 ' + l.name + '?')) return;
|
||
Save.removeCustom(l.id);
|
||
this.renderMyLevels();
|
||
this.api.refresh?.();
|
||
};
|
||
box.appendChild(d);
|
||
});
|
||
},
|
||
|
||
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();
|
||
},
|
||
};
|
||
|
||
function blank(w) {
|
||
return Array.from({ length: H_ROWS }, () => Array.from({ length: w }, () => '.'));
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|