// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板) import { TILE } from './util.js'; import { Save } from './save.js'; import { makeCustomLevel } from './levels/index.js'; import * as S from './sprites.js'; const $ = s => document.querySelector(s); const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); export const PALETTE = [ ['#', '方块', '#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; } // 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得 function drawTileReal(ctx, ch, px, py, props, t) { const P = props || {}; switch (ch) { case '#': S.drawBlock(ctx, px, py, 0); break; // 方块 case '@': S.drawBlock(ctx, px, py, 1); break; // 草地 case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块 case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属 case '%': S.drawIce(ctx, px, py); break; // 冰面 case '~': S.drawWater(ctx, px, py, t); break; // 水 case '-': S.drawOneWay(ctx, px, py); break; // 单向台 case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块 case '^': { // 刺:真实三角形,按道具方向 const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0; S.drawSpike(ctx, px, py, di, t); break; } case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果 case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺 case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点 case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点 case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪 case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果 case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新 case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门 case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌 case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台 case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头 case 'S': S.drawKid(ctx, { // 出生点:画一个小人 x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21, facing: 1, grounded: true, hasGun: false, dead: false, }, t); break; default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底 } } // 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见 function drawMarker(ctx, px, py, ch) { const item = PALETTE.find(p => p[0] === ch); const col = item ? item[2] : '#555'; ctx.fillStyle = col; ctx.globalAlpha = 0.85; ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2); ctx.globalAlpha = 1; ctx.fillStyle = '#fff'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(ch, px + TILE / 2, py + TILE / 2); } // 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符 const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' }; const SPIKE_DIR = { '^': 'up', 'v': '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]) => { const d = document.createElement('div'); d.className = 'pal' + (ch === this.sel ? ' on' : ''); // 用真实绘制函数渲染缩略图,所见即所得 const cv = document.createElement('canvas'); cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE; drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0); d.appendChild(cv); const sp = document.createElement('span'); sp.textContent = label; d.appendChild(sp); 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 }; 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); const t = 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 !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t); // 淡网格线(编辑对位用) ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1; ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1); // 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示) if (ch === 'W') { const d = f.doors.find(o => o.tx === x && o.ty === y); ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 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 = '