// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板) import { TILE } from './util.js'; import { Save } from './save.js'; import { makeCustomLevel } from './levels/index.js'; const $ = s => document.querySelector(s); const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const PALETTE = [ ['?', '查看属性', '#8a8a9a'], ['#', '方块', '#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; } // 编辑器中刺统一用 '^',方向存在 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; 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, col]) => { const d = document.createElement('div'); d.className = 'pal' + (ch === this.sel ? ' on' : ''); d.innerHTML = `${ch === '?' ? '⌖' : 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 f = this.curFace(); const { tx, ty } = pos(e); 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(); 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); 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); 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; } 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'; 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 = `${f.w}×${f.h} · ${f.doors.length}门`; 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 = '