diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index fe5955c..5becc7e 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -55,3 +55,42 @@ - 用户要求:把死亡效果从血洼/血点/飞溅碎块改成简单的**红叉**即可。 - `js/level.js`:`addDeathMark` 只记 `{x,y}`(不再生成 parts 碎块);`drawDeathMark` 改为画一个带深色描边的红色 X(半边长 9,lineWidth 5 黑描边 + 3 红主体);`update()` 去掉碎块演化循环(红叉为常驻静态标记)。仍只在死亡地点显示、只保留上一次、整关重开清空。 - `tools/death-test.mjs` 措辞「血迹」→「红叉」。全套仍全绿。 + +## 自制关卡:可变宽高 + 传送门 + 多面地图 + 极简导入导出(用户新需求) +- 需求:编辑器里可自定义地图宽高;引入「传送门(W)」,一个地图可含**多面(子关卡)**;导入导出对非专业者友好。 +- 经 `AskUserQuestion` 确认三处设计: + - 传送门配对 = **显式指定目标**(每个门直接选「去哪个面 + 哪个门」,不自动两两配对); + - 面尺寸 = **每个面独立宽高**(clamp 12–160 × 8–80); + - 导入导出 = **一键文件 + 分享码**(下载 .json / 复制分享码;从文件导入 / 粘贴导入)。 +- `js/level.js`:`reset()` 里从 `this.data.doors` 读取门目标 `to:{face,door}` 写到 warp 实体的 `e.to`;`e.doorId` 取门 id、`e.pair` 兜底取相邻序号、`e.cool/hidden` 支持。 +- `js/player.js`:warp 分支识别「跨面目标对象(`to.face` 为 number 且无 x/y)」→ 调 `this.onPortal(e)` 交主程序处理,否则走原 `spawn`/坐标/`pair` 逻辑;save 分支把 `face` 写进 `savePoint`。 +- `js/main.js`:`G.map[]` + `G.face`;`startLevel` 在 `data.faces` 存在时建多面;`doPortal(e)`→`switchFace` 换激活 Level、`dest.cool=40` 防回弹;`doRespawnFace()` 回到 `savePoint.face`;HUD 多面时显示 `面N/M`。 +- `js/editor.js` 重写:状态 `faces[]`(每面 `{name,w,h,seq,grid,doors}`);`resize(w,h)` 可变高、裁剪越界门、`addFace/copyFace/delFace`;门放 `W` 自动注册 id,`renderDoors()` 每门两个下拉(目标面+目标门,禁自连);`buildData()` 输出 `{name,type:'custom',startFace:0,faces:[...]}`;`validate()` 校验每面有 1 个 S 且 ≥1 个 X、门不自连;导入导出 `download/copyCode/importFile/importMap`(兼容旧单 grid `doors` 格式自动包成 1 面);3 处 `confirm()` 加 `typeof confirm==='function'` 守卫以兼容 Node。 +- `index.html` + `css/style.css`:编辑器区改为 `ed-name/ed-w/ed-h/ed-resize` + 面面板(`ed-addface/copyface/delface` + `#ed-faces`) + 门面板(`#ed-doors`) + 一键导入导出(`ed-download/ed-copy/ed-file/ed-import/ed-export` + `#ed-json` textarea);`.editor-main{overflow:auto}`。 +- 新增 `tools/map-test.mjs`(4 项:跨面门目标挂载 / `onPortal` 触发 / `savePoint.face` 记录 / 旧格式兼容)。`/tmp/ed_harness.mjs` 8 断言(buildData 双面、门目标读写、validate、importMap 保留、增删面计数、resize、旧单 grid 导入、缺 S 拦截)全过。 +- 验证:`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套全绿;23 关可达性未退化;预览服务 `8123` 返回 200。 + +## 编辑器:去掉导入导出,传送门改为「属性面板」方式(用户新需求) +- 用户要求:去掉导入/导出,只用「保存」;把多关传送门设置改成「修改属性」方式——在自制关卡**右侧**显示一列「属性列表」,展示**当前选中方块**的可设属性(含跳转到下一关),其他有属性的方块也放进去。 +- `js/editor.js` 改动: + - 删除 `download/copyCode/importFile/importMap/importPaste/exportToTextarea/mapString/renderDoors` 全部导入导出与独立传送门面板逻辑;保留 `save()` 与「我的地图」「面」面板。 + - 新增 **每格属性** 模型 `f.props[ty][tx]`(null 或对象),与 `grid` 同形;`blank/propsBlank` 同步、`resize/copyFace/hydrate` 都维护 props。 + - 新增 `SCHEMA`:按方块类型声明可编辑字段(`portal` / `text` / `num` / `select`)。`W`→跳转到下一关(两下拉写 `doors[].to`);`T`→提示文字;`O`→朝向/间隔/速度/延迟;`C`→区域宽高/镜头模式/缩放;`B`→类型/血量。 + - 选中格 `this.selTile`:画布点击即选中并高亮(黄框);调色板首项 `⌖ 查看属性` 为只查看不修改的画笔。右侧 `#ed-props` 由 `renderProps()` 按 `SCHEMA` 渲染。 + - `buildData()` 按扫描顺序把 `props` 导出为 `messages/turrets/camzones/bosses` 数组(引擎按扫描下标读取,顺序一致);`doors` 原样导出。 + - `hydrate(o)` 反向:按扫描顺序把这些数组回填到 `f.props[ty][tx]`。 +- `index.html`:删除「导入/导出」区块(按钮 + `#ed-json`)与「传送门(显式指定目标)」面板;新增 `#ed-props` 属性列表面板;`editor-tip` 文案更新。 +- `css/style.css`:新增 `#ed-props/.prop-head/.prop-empty/.prop-row/.prop-lab/.prop-in/.prop-ctl` 样式;删除无用的 `#ed-json/.io-btns/.door-row` 样式。 +- 验证:Node 桩冒烟(`/tmp/ed_props_harness2.mjs`,14 断言全过:各类型 renderProps 不抛异常、buildData 各数组导出正确、hydrate 往返恢复、validate 门自指/缺 S 拦截);`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套仍全绿。 + +## 编辑器:放置不覆盖 + 刺合并为单类型(方向走属性面板)(用户再次要求) +- 需求:①放置方块时若当前格已有方块则不覆盖;②把「上/下/左/右」四个刺合并成**一个刺**,上下左右方向改到右侧属性面板调。 +- 引擎侧不动:`js/level.js` 的 `CHAR2TILE` 仍是 `^=SPIKE_U / v=SPIKE_D / <=SPIKE_L / >=SPIKE_R`,`spikeBox()` 按 T.SPIKE_* 收紧判定。所以只在**编辑器层**做「单 char + props.dir ↔ 四字符」双向翻译,`solve/check` 等可达性完全不受影响。 +- `js/editor.js`: + - `PALETTE` 删掉 `v < >`,只留 `['^','刺',...]`;新增 `SPIKE_CHAR{up:^,down:v,left:<,right:>}` / `SPIKE_DIR`(反向) / `SPIKE_ARROW{up:↑,...}`。 + - `SCHEMA` 增加 `'^': [{type:'select',key:'dir',def:'up',opts:[上/下/左/右]}]`;`renderProps` 的 select 分支改为 `cur ?? fd.def` 兜底默认值。 + - `act()` 放置段新增:`if(!erase && ch!=='.' && prev!=='.') { this.renderProps(); return; }` —— 放实方块遇已有方块直接跳过(仍选中该格以便看属性);擦除/橡皮 `ch==='.'` 与 `?` 查看工具不受此限。 + - `draw()` 刺格不再写 `^`,改写 `SPIKE_ARROW[dir]`(↑↓←→)让方向在画布上可见。 + - `buildData()` 导出 grid 时把每格 `^` 按 `props.dir` 翻译成 `^ v < >`;`hydrate()` 反向把 `^ v < >` 统一还原成 `^` 并写 `props.dir`。往返一致。 +- `index.html`:`editor-tip` 改为说明「不会覆盖已有方块」「刺只有一个、方向在右侧属性面板调」。 +- 验证:Node 桩冒烟(`/tmp/ed_noclip_spike.mjs`,13 断言全过:不覆盖/擦除/橡皮、buildData 四向翻译、renderProps('^') 不抛、hydrate 往返一致);`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套全绿。 diff --git a/css/style.css b/css/style.css index cc521c5..b46b02c 100644 --- a/css/style.css +++ b/css/style.css @@ -149,15 +149,17 @@ canvas#game{ .pal.on{border-color:var(--gold);color:#fff;background:#2b2b3c} .pal .sw{width:14px;height:14px;border:1px solid #000;flex:none} .pal .ch{font-weight:bold;color:var(--fg);width:12px;text-align:center} -.editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0} +.editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto} .editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap} .editor-bar input,.editor-bar select{ background:#111119;border:1px solid var(--line);color:var(--fg); font-family:inherit;font-size:.85em;padding:4px 6px; } -#ed-name{width:110px}#ed-w{width:52px} +#ed-name{width:110px}#ed-w{width:52px}#ed-h{width:52px} +.ed-wh{display:inline-flex;align-items:center;gap:3px;font-size:.78em;color:var(--dim)} +.ed-wh input{width:48px} canvas#ed-canvas{ - width:100%;height:auto;background:#0d0d16;border:1px solid var(--line); + width:800px;height:auto;background:#0d0d16;border:1px solid var(--line); image-rendering:pixelated;cursor:crosshair;display:block; } .editor-tip{color:var(--dim);font-size:.78em} @@ -167,7 +169,23 @@ canvas#ed-canvas{ .my-lv:hover{border-color:var(--gold)} .my-lv.on{border-color:var(--gold);background:#2b2b3c} .my-lv .x{color:var(--red)} -#ed-json{flex:1;min-height:80px;background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:.7em;padding:4px;resize:none} +.face-btns{display:flex;gap:4px;flex-wrap:wrap} +#ed-faces{display:flex;flex-direction:column;gap:4px;max-height:160px;overflow-y:auto} +.face-row{background:#1b1b26;border:1px solid var(--line);padding:3px 5px;font-size:.78em;cursor:pointer;display:flex;flex-direction:column;gap:2px} +.face-row:hover{border-color:var(--gold)} +.face-row.on{border-color:var(--gold);background:#2b2b3c} +.face-row .face-name{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:2px 4px;width:100%} +.face-row .face-wh{color:var(--dim);font-size:.9em} +/* 属性面板(选中方块) */ +#ed-props{display:flex;flex-direction:column;gap:5px;max-height:36vh;overflow-y:auto;border:1px solid var(--line);background:#111119;padding:6px} +.prop-empty{color:var(--dim);font-size:.76em;line-height:1.5} +.prop-head{color:var(--gold);font-weight:bold;font-size:.92em;letter-spacing:1px;margin-bottom:2px} +.prop-row{display:flex;flex-direction:column;gap:3px;background:#1b1b26;border:1px solid var(--line);padding:4px 5px;font-size:.78em} +.prop-lab{color:var(--fg)} +.prop-note{color:var(--dim);font-size:.92em} +.prop-ctl{display:flex;gap:4px} +.prop-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:3px 4px;width:100%} +.prop-ctl .prop-in{width:auto;flex:1} ::-webkit-scrollbar{width:8px;height:8px} ::-webkit-scrollbar-track{background:#111} diff --git a/index.html b/index.html index 56dcb9e..e5edf61 100644 --- a/index.html +++ b/index.html @@ -142,14 +142,9 @@
- - - + + + @@ -157,15 +152,20 @@
-
左键放置 / 右键擦除,滚轮平移。至少要有一个 S(出生点) 和一个 X(终点)。
+
左键放置 / 右键擦除,滚轮水平平移。不会覆盖已有方块——放在空格才生效,要换掉先擦除。选「⌖ 查看属性」再点方块可只查看不修改。刺只有一个,放好后在右侧「属性」面板调方向(上/下/左/右);传送门 W、提示牌 T、炮台 O、摄像头 C、BOSS B 的属性也都在这里设置。每面至少 1 个 S(出生点) 与 1 个 X(终点)。
-

我的关卡

+

属性(选中方块)

+
点击画布上的方块,查看并设置它的属性
+

我的地图

-

导入 / 导出

- - - +

面(多面地图)

+
+ + + +
+
diff --git a/js/editor.js b/js/editor.js index d06bc45..d1cf91d 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1,26 +1,61 @@ -// 简易关卡编辑器 +// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板) 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 clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const PALETTE = [ - ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'], + ['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'], ['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'], - ['^', '上刺', '#c8c8d8'], ['v', '下刺', '#a0a0b4'], ['<', '左刺', '#9090a8'], ['>', '右刺', '#9090a8'], + ['^', '刺', '#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; @@ -28,7 +63,8 @@ export const Editor = { this.ctx = this.canvas.getContext('2d'); this.buildPalette(); this.bind(); - $('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value || 25)); + $('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value)); + $('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value)); this.renderMyLevels(); }, @@ -38,7 +74,7 @@ export const Editor = { PALETTE.forEach(([ch, label, col]) => { const d = document.createElement('div'); d.className = 'pal' + (ch === this.sel ? ' on' : ''); - d.innerHTML = `${ch}${label}`; + d.innerHTML = `${ch === '?' ? '⌖' : ch}${label}`; d.onclick = () => { this.sel = ch; Array.from(p.children).forEach(c => c.classList.remove('on')); @@ -57,10 +93,25 @@ export const Editor = { 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 >= this.w || ty >= H_ROWS) return; - this.map[ty][tx] = erase ? '.' : (this.sel === '.' ? '.' : this.sel); + 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(); @@ -72,55 +123,118 @@ export const Editor = { 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.scroll = Math.max(0, Math.min((this.curFace().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(); + 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-addface', () => this.addFace()); + on('ed-copyface', () => this.copyFace()); + on('ed-delface', () => this.delFace()); }, + // ---------------- 多面地图状态 ---------------- + curFace() { return this.faces[this.face]; }, + open() { - if (!this.map) { this.w = +$('#ed-w').value || 25; this.map = blank(this.w); } + if (!this.faces) { + 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.selTile = null; + $('#ed-w').value = this.curFace().w; + $('#ed-h').value = this.curFace().h; this.draw(); + this.renderFaces(); + this.renderProps(); 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; + 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; - c.width = 800; c.height = H_ROWS * TILE; + 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 < H_ROWS; y++) { - for (let x = 0; x < this.w; x++) { - const ch = this.map[y][x]; + 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); - ctx.fillStyle = item ? item[2] : '#555'; - ctx.globalAlpha = ch === '.' ? 0.2 : 0.85; + 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; } @@ -130,32 +244,158 @@ export const Editor = { ctx.fillStyle = '#fff'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(ch, px + TILE / 2, py + TILE / 2); + 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 = '
点击画布上的方块,查看并设置它的属性
'; return; } + const { tx, ty } = this.selTile; + if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '
(越界)
'; return; } + const ch = f.grid[ty][tx]; + const label = charLabel(ch); + const fields = SCHEMA[ch]; + if (!fields) { + box.innerHTML = `
「${ch}」 ${label}
该方块没有可设置的属性
`; + return; + } + box.innerHTML = `
「${ch}」 ${label}
`; + for (const fd of fields) { + const row = document.createElement('div'); + row.className = 'prop-row'; + if (fd.type === 'portal') { + const door = f.doors.find(d => d.tx === tx && d.ty === ty); + if (!door) { row.innerHTML = '跳转到下一关(此格未登记为门,请重新放置 W)'; box.appendChild(row); continue; } + const cur = door.to || null; + const tf = cur ? cur.face : this.face; + const td = cur ? cur.door : -1; + const faceOpts = this.faces.map((gf, gi) => ``).join(''); + const tface = this.faces[tf]; + const doorOpts = (tface.doors.length + ? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => ``).join('') + : '') || ''; + row.innerHTML = `跳转到下一关
`; + const fsel = row.querySelector('.p-face'); + const dsel = row.querySelector('.p-door'); + const apply = () => { + const ntf = +fsel.value, ntd = +dsel.value; + door.to = (ntd >= 0) ? { face: ntf, door: ntd } : null; + this.draw(); + this.renderProps(); + }; + fsel.onchange = apply; + dsel.onchange = apply; + } else if (fd.type === 'text') { + const val = (f.props[ty][tx] || {})[fd.key] || ''; + row.innerHTML = `${fd.label}`; + const inp = row.querySelector('.prop-in'); + inp.oninput = () => { ensureProp(f, tx, ty)[fd.key] = inp.value; }; + } else if (fd.type === 'num') { + const raw = (f.props[ty][tx] || {})[fd.key]; + const val = raw !== undefined && raw !== null && raw !== '' ? raw : (fd.def !== undefined ? fd.def : ''); + row.innerHTML = `${fd.label}`; + const inp = row.querySelector('.prop-in'); + inp.oninput = () => { + const v = inp.value === '' ? (fd.def !== undefined ? fd.def : null) : (fd.step && String(fd.step).includes('.') ? +inp.value : +inp.value); + ensureProp(f, tx, ty)[fd.key] = v; + }; + } else if (fd.type === 'select') { + const cur = (f.props[ty][tx] || {})[fd.key]; + const val = cur !== undefined && cur !== '' ? cur : (fd.def !== undefined ? fd.def : ''); + const opts = fd.opts.map(([v, t]) => ``).join(''); + row.innerHTML = `${fd.label}`; + const sel = row.querySelector('.prop-in'); + sel.onchange = () => { ensureProp(f, tx, ty)[fd.key] = sel.value; }; + } + box.appendChild(row); + } + }, + + // ---------------- 数据 ---------------- buildData() { + const faces = this.faces.map(f => { + // 编辑器刺统一为 '^'+props.dir,导出时按方向翻译成引擎字符 ^ v < > + const grid = f.grid.map((r, y) => r.map((ch, x) => { + if (ch === '^') return SPIKE_CHAR[((f.props[y][x] || {}).dir) || 'up']; + return ch; + }).join('')); + // 按扫描顺序收集各类型属性,整型数组(引擎按扫描下标读取) + const messages = [], turrets = [], camzones = [], bosses = []; + for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) { + const ch = f.grid[y][x]; + const p = f.props[y][x]; + if (ch === 'T' && p && p.msg !== undefined && p.msg !== '') messages.push(p.msg); + if (ch === 'O' && p) turrets.push({ dir: p.dir || 'left', period: +(p.period || 90), speed: +(p.speed || 2.4), offset: +(p.offset || 0), kind: p.kind || 'ball' }); + if (ch === 'C' && p) camzones.push({ w: +(p.w || 3), h: +(p.h || 3), mode: p.mode || 'lock', zoom: +(p.zoom || 1) }); + if (ch === 'B' && p) bosses.push({ kind: p.kind || 'guy', hp: +(p.hp || 40) }); + } + return { + name: f.name, + width: f.w, + height: f.h, + grid, + doors: f.doors.map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })), + messages, turrets, camzones, bosses, + }; + }); 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: ['自制关卡'], + name: ($('#ed-name').value || 'MY MAP').toUpperCase(), + type: 'custom', + startFace: 0, + faces, }; }, 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(终点)'; + for (let i = 0; i < this.faces.length; i++) { + const g = this.faces[i].grid.map(r => r.join('')); + const s = g.join('').split('S').length - 1; + const x = g.join('').split('X').length - 1; + if (s !== 1) return `面${i + 1}(${this.faces[i].name})必须且只能有 1 个 S,当前 ${s} 个`; + if (x < 1) return `面${i + 1}(${this.faces[i].name})至少要 1 个 X(终点)`; + } + // 门不能指向自己所在格子 + for (let i = 0; i < this.faces.length; i++) { + for (const d of this.faces[i].doors) { + if (d.to && d.to.face === i && d.to.door === d.id) return `面${i + 1} 的门${d.id} 不能指向自己`; + } + } return null; }, @@ -175,46 +415,28 @@ export const Editor = { 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'); + if (!box) return; box.innerHTML = ''; const list = Save.customs(); if (!list.length) box.innerHTML = '
还没有保存的关卡
'; list.forEach((l, i) => { const d = document.createElement('div'); + const fn = (l.faces || []).length; d.className = 'my-lv' + (l.id === this.cur ? ' on' : ''); - d.innerHTML = `${i + 1}. ${escapeHtml(l.name)}✕`; + d.innerHTML = `${i + 1}. ${escapeHtml(l.name)}${fn ? ` [${fn}面]` : ''}✕`; d.querySelector('span').onclick = () => { this.cur = l.id; - this.load(l); + this.hydrate(l); + $('#ed-name').value = l.name || ''; + this.afterFaceSwitch(); this.renderMyLevels(); }; d.querySelector('.x').onclick = (e) => { e.stopPropagation(); - if (!confirm('删除 ' + l.name + '?')) return; + if (typeof confirm === 'function' && !confirm('删除 ' + l.name + '?')) return; Save.removeCustom(l.id); this.renderMyLevels(); this.api.refresh?.(); @@ -223,22 +445,46 @@ export const Editor = { }); }, - 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(); + // 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 messages/turrets/camzones/bosses) + hydrate(o) { + this.faces = o.faces.map(f => { + const w = f.width || (f.grid[0] ? f.grid[0].length : 25); + const h = f.height || f.grid.length; + const grid = f.grid.map(r => r.split('')); + const props = propsBlank(w, h); + const face = { + name: f.name || '面', + w, h, + seq: (f.doors || []).reduce((m, d) => Math.max(m, d.id), 0), + grid, + doors: (f.doors || []).map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })), + props, + }; + let mi = 0, ti = 0, ci = 0, bi = 0; + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { + const ch = grid[y][x]; + if (ch === 'T') { const m = (f.messages || [])[mi++]; if (m !== undefined) props[y][x] = { msg: m }; } + else if (ch === 'O') { const t = (f.turrets || [])[ti++]; if (t) props[y][x] = { ...t }; } + else if (ch === 'C') { const c = (f.camzones || [])[ci++]; if (c) props[y][x] = { ...c }; } + else if (ch === 'B') { const b = (f.bosses || [])[bi++]; if (b) props[y][x] = { ...b }; } + // 引擎的四个刺字符 ^ v < > 统一还原为编辑器 '^' + props.dir + else if (SPIKE_DIR[ch] !== undefined) { grid[y][x] = '^'; props[y][x] = { dir: SPIKE_DIR[ch] }; } + } + return face; + }); + this.face = 0; }, }; -function blank(w) { - return Array.from({ length: H_ROWS }, () => Array.from({ length: w }, () => '.')); +function blank(w, h) { + return Array.from({ length: h }, () => Array.from({ length: w }, () => '.')); +} +function propsBlank(w, h) { + return Array.from({ length: h }, () => Array.from({ length: w }, () => null)); +} +function ensureProp(f, tx, ty) { + if (!f.props[ty][tx]) f.props[ty][tx] = {}; + return f.props[ty][tx]; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); diff --git a/js/level.js b/js/level.js index 24da79b..c0d66ab 100644 --- a/js/level.js +++ b/js/level.js @@ -127,8 +127,12 @@ export class Level { } if (s.type === 'warp') { const cfg = this.warpSrc[e.idx] || {}; + // 自制多面地图:门的目标直接挂在 door.to 上 + // door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送) + const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty); + e.doorId = door ? door.id : e.idx; e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1); - e.to = cfg.to || null; // {x,y} 绝对像素 / 'spawn' + e.to = (door && door.to) ? door.to : (cfg.to || null); e.cool = 0; e.hidden = !!cfg.hidden; } diff --git a/js/main.js b/js/main.js index d5eb53d..a4116e2 100644 --- a/js/main.js +++ b/js/main.js @@ -32,21 +32,44 @@ const G = { state: 'menu', // menu | playing | paused | result | editor level: null, player: null, cam: null, data: null, idx: -1, + map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标 deaths: 0, ms: 0, hudTick: 0, time: 0, lastLevelId: null, }; // ---------------- 关卡流程 ---------------- +// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors) +function faceData(map, face, i) { + return { + id: (map.id || 'map') + '-' + i, + name: face.name || ('面' + (i + 1)), + type: 'custom', + width: face.width || (face.grid[0] ? face.grid[0].length : 25), + grid: face.grid, + doors: face.doors || [], + bg: 'custom', hint: '', messages: [], + }; +} + function startLevel(data, idx = -1) { unlockAudio(); G.data = data; G.idx = idx; G.lastLevelId = data.id; - G.level = new Level(data); + // 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理 + if (data.faces && data.faces.length) { + G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; }); + G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1)); + } else { + G.map = [new Level(data)]; G.face = 0; + } + G.level = G.map[G.face]; G.player = new Player(G.level); G.cam = new Camera(W, H); G.cam.reset(G.level, G.player); G.player.onDeath = () => { G.deaths++; }; G.player.onClear = () => finish(); + G.player.onPortal = (e) => doPortal(e); + G.player.onRespawnFace = () => doRespawnFace(); G.level.bosses.forEach(b => { b.onDefeat = () => finish(); }); G.deaths = 0; G.ms = 0; G.hudTick = 0; G.state = 'playing'; @@ -56,6 +79,53 @@ function startLevel(data, idx = -1) { if (data.hint) UI.toast(data.hint); } +// 把玩家放到某面某门上(不切面的同面传送也用) +function placeOnDoor(dest) { + const P = G.player; + P.x = dest.x + dest.w / 2 - P.w / 2; + P.y = dest.y + dest.h - P.h; + P.vx = 0; P.vy = 0; P.jumps = 0; +} + +// 踩到门:e.to = {face, door} +function doPortal(e) { + const to = e.to; + const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0)); + if (tp === G.face) { + const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door); + if (dest) { placeOnDoor(dest); dest.cool = 40; } + sfx.warp(); + } else { + switchFace(tp, to.door); + } +} + +// 切换到第 f 面,并落到该面的目标门上 +function switchFace(f, doorId) { + G.face = f; + G.level = G.map[f]; + G.level.reset(true); // 进入该面时重置其运行时 + G.player.level = G.level; + const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId); + if (dest) { placeOnDoor(dest); dest.cool = 40; } + else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; } + G.cam.reset(G.level, G.player); + sfx.warp(); + G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 }); +} + +// 复活回到存档点所在面(跨面) +function doRespawnFace() { + const sp = G.player.savePoint; + if (sp && typeof sp.face === 'number' && sp.face !== G.face) { + G.face = sp.face; + G.level = G.map[sp.face]; + G.level.reset(true); + G.player.level = G.level; + G.cam.reset(G.level, G.player); + } +} + /** 整关从头再来:保留死亡数,丢弃所有存档点进度 */ function restart() { if (!G.data) return; @@ -103,26 +173,31 @@ function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame // ---------------- 更新 ---------------- function update() { - const L = G.level, P = G.player; + const P = G.player; // R = 自杀回存档点;Backspace = 整关重开 if (Input.hit('suicide')) { suicide(); return; } if (Input.hit('restart')) { restart(); return; } if (Input.hit('pause')) { pause(); return; } const wasDead = P.dead; - L.update(1, P, G.cam); - for (const b of L.bosses) b.update(1, P); + G.level.update(1, P, G.cam); + for (const b of G.level.bosses) b.update(1, P); P.update(1); - // 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去 - if (P.justRespawned || (wasDead && !P.dead)) G.cam.snapTo(P, L); - P.justRespawned = false; - G.cam.update(L, P, 1); + // 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移 + if (P.justRespawned) { + if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace(); + G.cam.snapTo(P, G.level); + P.justRespawned = false; + } + G.cam.update(G.level, P, 1); G.ms += STEP; G.time++; if (++G.hudTick % 5 === 0) { + const L = G.level; UI.hud({ - name: G.data.name, deaths: G.deaths, ms: G.ms, + name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''), + deaths: G.deaths, ms: G.ms, apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun, save: P.saveIdx || 0, totalSaves: L.totalSaves || 0, }); diff --git a/js/player.js b/js/player.js index f335dbb..ce65f5c 100644 --- a/js/player.js +++ b/js/player.js @@ -297,7 +297,7 @@ export class Player { case 'save': if (!e.active) { e.active = true; - this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h }; + this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h, face: (this.level.faceIndex ?? 0) }; this.saveIdx = e.no || 1; sfx.save(); L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18, @@ -326,8 +326,15 @@ export class Player { case 'warp': { if (e.cool > 0) break; e.cool = 40; - const tgt = e.to === 'spawn' ? L.spawn - : e.to ? { x: e.to[0] * TILE, y: e.to[1] * TILE } + const to = e.to; + // 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面 + if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') { + if (this.onPortal) this.onPortal(e); + break; + } + // 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对) + const tgt = to === 'spawn' ? L.spawn + : to && ('x' in to) ? { x: to.x * TILE, y: to.y * TILE } : (() => { const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair); return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null; diff --git a/tools/map-test.mjs b/tools/map-test.mjs new file mode 100644 index 0000000..ed58089 --- /dev/null +++ b/tools/map-test.mjs @@ -0,0 +1,71 @@ +// 回归测试:多面地图的跨面传送门 + 存档点带面 + 内置关卡兼容 +const handlers = {}; +const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); }; +globalThis.addEventListener = addL; +globalThis.window = { addEventListener: addL }; +const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false })); + +const { Level } = await import('../js/level.js'); +const { Player } = await import('../js/player.js'); +const { Input } = await import('../js/input.js'); + +const W = 20, TILE = 32; +let fail = 0; +const err = m => { console.log(' ✗ ' + m); fail++; }; +const ok = m => console.log(' ✓ ' + m); + +// 两面临门地图:A 面门 col5 指向 B 面门;B 面门 col10 指回 A 面门 +const gridA = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)]; +const gridB = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(9) + 'W' + '.'.repeat(W - 11) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)]; +const doorA = { id: 1, tx: 5, ty: 2, to: { face: 1, door: 1 } }; +const doorB = { id: 1, tx: 10, ty: 2, to: { face: 0, door: 1 } }; +const L0 = new Level({ name: 'A', type: 'custom', width: W, grid: gridA, doors: [doorA] }); L0.faceIndex = 0; +const L1 = new Level({ name: 'B', type: 'custom', width: W, grid: gridB, doors: [doorB] }); L1.faceIndex = 1; + +// 1) 门目标正确挂接 +{ + const w0 = L0.entities.find(e => e.type === 'warp'); + const w1 = L1.entities.find(e => e.type === 'warp'); + if (!w0 || !w1) err('门实体未生成'); + else { + if (w0.to && w0.to.face === 1 && w0.to.door === 1 && w0.doorId === 1) ok('A 面门挂上跨面目标 {face:1,door:1}'); + else err('A 面门目标错误: ' + JSON.stringify(w0.to)); + if (w1.to && w1.to.face === 0 && w1.to.door === 1 && w1.doorId === 1) ok('B 面门挂上跨面目标 {face:0,door:1}'); + else err('B 面门目标错误: ' + JSON.stringify(w1.to)); + } +} + +// 2) 踩门触发 onPortal 钩子(main 据此切面) +{ + const P = new Player(L0); + let captured = null; P.onPortal = (e) => { captured = e; }; + const w0 = L0.entities.find(e => e.type === 'warp'); + P.x = w0.x; P.y = w0.y; P.vx = 0; P.vy = 0; + L0.update(1, P, null); P.update(1); Input.endFrame(); + if (captured && captured.to && captured.to.face === 1) ok('踩到门触发 onPortal,目标面=1'); + else err('踩门未触发跨面 onPortal'); +} + +// 3) 存档点带 faceIndex +{ + const gridC = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + 'G' + '.'.repeat(W - 3) + '#', '#X' + '.'.repeat(W - 3) + '#']; + const LC = new Level({ name: 'C', type: 'custom', width: W, grid: gridC, doors: [] }); LC.faceIndex = 2; + const P = new Player(LC); + const g = LC.entities.find(e => e.type === 'save'); + P.x = g.x; P.y = g.y; + LC.update(1, P, null); P.update(1); Input.endFrame(); + if (P.savePoint && P.savePoint.face === 2) ok('存档点记录 face=2'); + else err('存档点未带 face,得到 ' + (P.savePoint && P.savePoint.face)); +} + +// 4) 兼容:无 doors 的内置风格关卡,门仍走原有 pair 逻辑(to 为 null) +{ + const gridD = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#X' + '.'.repeat(W - 3) + '#']; + const LD = new Level({ name: 'D', type: 'jump', width: W, grid: gridD }); + const ws = LD.entities.filter(e => e.type === 'warp'); + if (ws.length === 2 && ws[0].to === null && ws[1].to === null) ok('无 doors 时门 to=null(走原有 pair 逻辑,不退化)'); + else err('无 doors 时门目标异常: ' + JSON.stringify(ws.map(w => w.to))); +} + +console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 多面地图/传送门 全部通过'); +process.exit(fail ? 1 : 0);