fix: 刺放置方向问题

This commit is contained in:
cancerai committed 2026-10-22 21:42:43 +08:00
1 parent 6ba47dfd88
commit bf0f065432
8 files changed
+576 -116

No files matched your search

+39
View File
@@ -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` 九套全绿。
+22 -4
View File
@@ -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}
+14 -14
View File
@@ -142,14 +142,9 @@
<div class="palette" id="palette"></div>
<div class="editor-main">
<div class="editor-bar">
<input id="ed-name" placeholder="关卡名" value="MY STAGE">
<select id="ed-type">
<option value="jump">跳刺</option>
<option value="endur">耐久</option>
<option value="boss">BOSS</option>
<option value="troll">坑向</option>
</select>
<input id="ed-w" type="number" value="25" min="12" max="120" title="宽度(格)">
<input id="ed-name" placeholder="地图名" value="MY MAP">
<label class="ed-wh">宽<input id="ed-w" type="number" value="25" min="12" max="160"></label>
<label class="ed-wh">高<input id="ed-h" type="number" value="19" min="8" max="80"></label>
<button class="btn tiny" data-act="ed-resize">调整</button>
<button class="btn tiny" data-act="ed-fill">铺底</button>
<button class="btn tiny" data-act="ed-clear">清空</button>
@@ -157,15 +152,20 @@
<button class="btn tiny go" data-act="ed-save">保存</button>
</div>
<canvas id="ed-canvas" width="800" height="608"></canvas>
<div class="editor-tip">左键放置 / 右键擦除,滚轮平移。至少要有一个 <b>S</b>(出生点) 和一个 <b>X</b>(终点)。</div>
<div class="editor-tip">左键放置 / 右键擦除,滚轮水平平移。<b>不会覆盖已有方块</b>——放在空格才生效,要换掉先擦除。选「⌖ 查看属性」再点方块可只查看不修改。<b>刺</b>只有一个,放好后在右侧「属性」面板调<b>方向(上/下/左/右)</b>;传送门 <b>W</b>、提示牌 <b>T</b>、炮台 <b>O</b>、摄像头 <b>C</b>、BOSS <b>B</b> 的属性也都在这里设置。每面至少 1 个 <b>S</b>(出生点) 与 1 个 <b>X</b>(终点)。</div>
</div>
<div class="editor-side">
<h3>我的关卡</h3>
<h3>属性(选中方块)</h3>
<div id="ed-props"><div class="prop-empty">点击画布上的方块,查看并设置它的属性</div></div>
<h3>我的地图</h3>
<div id="my-levels"></div>
<h3 style="margin-top:14px">导入 / 导出</h3>
<textarea id="ed-json" placeholder="粘贴关卡 JSON 后点导入"></textarea>
<button class="btn tiny" data-act="ed-import">导入</button>
<button class="btn tiny" data-act="ed-export">导出选中</button>
<h3>面(多面地图)</h3>
<div class="face-btns">
<button class="btn tiny" data-act="ed-addface">+ 新增面</button>
<button class="btn tiny" data-act="ed-copyface">复制面</button>
<button class="btn tiny" data-act="ed-delface">删除面</button>
</div>
<div id="ed-faces"></div>
</div>
</div>
</div>
+332 -86
View File
@@ -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 = `<span class="sw" style="background:${col}"></span><span class="ch">${ch}</span><span>${label}</span>`;
d.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch === '?' ? '⌖' : ch}</span><span>${label}</span>`;
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 = `<input class="face-name" value="${escapeHtml(f.name)}" title="面名称"><span class="face-wh">${f.w}×${f.h} · ${f.doors.length}门</span>`;
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 = '<div class="prop-empty">点击画布上的方块,查看并设置它的属性</div>'; return; }
const { tx, ty } = this.selTile;
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '<div class="prop-empty">(越界)</div>'; return; }
const ch = f.grid[ty][tx];
const label = charLabel(ch);
const fields = SCHEMA[ch];
if (!fields) {
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div><div class="prop-empty">该方块没有可设置的属性</div>`;
return;
}
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div>`;
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 = '<span class="prop-lab">跳转到下一关</span><span class="prop-note">(此格未登记为门,请重新放置 W)</span>'; 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) => `<option value="${gi}" ${gi === tf ? 'selected' : ''}>面${gi + 1}${gf.name !== ('面' + (gi + 1)) ? '·' + escapeHtml(gf.name) : ''}</option>`).join('');
const tface = this.faces[tf];
const doorOpts = (tface.doors.length
? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => `<option value="${od.id}" ${td === od.id ? 'selected' : ''}>门${od.id}</option>`).join('')
: '') || '<option value="-1">(该面暂无其他门)</option>';
row.innerHTML = `<span class="prop-lab">跳转到下一关</span><div class="prop-ctl"><select class="p-face">${faceOpts}</select><select class="p-door">${doorOpts}</select></div>`;
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 = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="text" placeholder="${fd.placeholder || ''}" value="${escapeHtml(val)}">`;
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 = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="number" min="${fd.min !== undefined ? fd.min : ''}" max="${fd.max !== undefined ? fd.max : ''}" step="${fd.step !== undefined ? fd.step : 1}" value="${val}">`;
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]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${t}</option>`).join('');
row.innerHTML = `<span class="prop-lab">${fd.label}</span><select class="prop-in">${opts}</select>`;
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: ($('#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: 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 MAP').toUpperCase(),
type: 'custom',
startFace: 0,
faces,
};
},
validate() {
const g = this.map.map(r => r.join(''));
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 '必须且只能有一个 S(出生点),当前 ' + s + ' 个';
if (x < 1) return '至少要有一个 X(终点)';
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 = '<div style="color:#666;font-size:.8em">还没有保存的关卡</div>';
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 = `<span>${i + 1}. ${escapeHtml(l.name)}</span><span class="x">✕</span>`;
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}${fn ? ` <b style="color:#a86bff">[${fn}面]</b>` : ''}</span><span class="x">✕</span>`;
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] || '.';
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 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] }; }
}
this.draw();
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+5 -1
View File
@@ -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;
}
+83 -8
View File
@@ -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);
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
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(L, P, 1);
}
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,
});
+10 -3
View File
@@ -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;
+71
View File
@@ -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);