Files
kidgo/js/editor.js
T
2026-10-20 22:21:26 +08:00

246 lines
8.7 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';
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}