// 简易关卡编辑器 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 = `${ch}${label}`; 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 = '