refactor: 使用flutter对原先的web项目重构,正在进行中

This commit is contained in:
cancerai committed 2026-10-25 00:06:51 +08:00
1 parent 273224f5cf
commit 18017f819a
205 files changed
+13563

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
// 机器人试玩:真实物理下向右推进,看存档点是否会被自然触发
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const id = process.argv[2] || 'j1';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
down('ArrowRight');
let jumpPhase = 0;
for (let i = 0; i < 3000; i++) {
// 简易 AI:遇到前方障碍/刺就跳,滞空一会儿再补二段跳
const ahead = L.solidRect(P.x + 3, P.y, P.w, P.h) || L.deadlyRect(P.x + 20, P.y + P.h - 6, 12, 8);
const gap = !L.solidRect(P.x + 6, P.y + P.h + 2, P.w, 4);
if (P.grounded && (ahead || gap)) { down('ShiftLeft'); jumpPhase = i; }
if (!P.grounded && i - jumpPhase === 12) { up('ShiftLeft'); down('ShiftLeft'); }
if (i - jumpPhase === 30) up('ShiftLeft');
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
if (P.savePoint) break;
}
const saves = L.entities.filter(e => e.type === 'save');
console.log(`${id}: 存档点 ${saves.filter(s => s.active).length}/${saves.length} 被激活, x=${P.x.toFixed(0)}, 死亡=${deaths}`);
saves.forEach(s => console.log(` (${s.tx},${s.ty}) active=${s.active}`));
console.log(` savePoint=${P.savePoint ? `(${P.savePoint.x.toFixed(0)},${P.savePoint.y.toFixed(0)})` : 'null'} spawn=(${L.spawn.x},${L.spawn.y})`);
+55
View File
@@ -0,0 +1,55 @@
// 关卡数据格式校验
import tutorial from '../js/levels/tutorial.js';
import jump from '../js/levels/jump.js';
import endurance from '../js/levels/endurance.js';
import boss from '../js/levels/boss.js';
import troll from '../js/levels/troll.js';
const KNOWN = new Set([...'#@=M%~-*V^v<>SGFXPJWTOCBD! .']);
const ALL = [...tutorial, ...jump, ...endurance, ...boss, ...troll];
let bad = 0;
console.log('=== 关卡格式 ===');
for (const l of ALL) {
const w = l.width;
const h = l.grid.length;
const tag = `${l.id} ${l.name}`;
if (h < 19) { console.log(`! ${tag}: 高度 ${h} 行(建议 >=19,画布高 19 格)`); bad++; }
if (h > 60) { console.log(`! ${tag}: 高度 ${h} 行过大`); bad++; }
l.grid.forEach((row, y) => {
if (row.length !== w) { console.log(`! ${tag} row${y}: len=${row.length} 应为 ${w}`); bad++; }
for (const ch of row) if (!KNOWN.has(ch)) { console.log(`! ${tag} row${y}: 未知字符 '${ch}'`); bad++; }
});
const all = l.grid.join('');
const cnt = c => all.split(c).length - 1;
const s = cnt('S'), x = cnt('X'), b = cnt('B');
if (s !== 1) { console.log(`! ${tag}: S=${s}(必须且只能有一个出生点)`); bad++; }
if (l.type === 'boss') { if (b < 1) { console.log(`! ${tag}: BOSS关缺 B`); bad++; } }
else if (x < 1) { console.log(`! ${tag}: X=${x}(缺终点)`); bad++; }
if (l.turrets) {
const o = cnt('O');
if (o !== l.turrets.length) { console.log(`! ${tag}: O=${o} 但 turrets=${l.turrets.length}`); bad++; }
}
if (l.camzones) {
const c = cnt('C');
if (c > l.camzones.length) { console.log(`! ${tag}: C=${c} camzones=${l.camzones.length}`); bad++; }
}
if (l.warps) {
const ww = cnt('W');
if (ww !== l.warps.length) { console.log(`! ${tag}: W=${ww} 但 warps=${l.warps.length}`); bad++; }
}
if (l.bosses) {
if (b !== l.bosses.length) { console.log(`! ${tag}: B=${b} 但 bosses=${l.bosses.length}`); bad++; }
}
if (l.messages) {
const t = cnt('T');
if (t > l.messages.length) { console.log(`! ${tag}: T=${t} 但 messages=${l.messages.length}`); bad++; }
}
const g = cnt('G'), f = cnt('F');
console.log(` ${tag.padEnd(24)} ${String(w).padStart(3)}x${String(h).padStart(2)} S=${s} X=${x} G=${g} F=${f} B=${b} ${l.type}`);
}
console.log(`\n共 ${ALL.length} 关`);
console.log(bad ? `❌ 发现 ${bad} 个问题` : '✅ 全部关卡格式 OK');
process.exit(bad ? 1 : 0);
+272
View File
@@ -0,0 +1,272 @@
// 回归测试:死亡流程(2026-10-24 新语义,推翻旧的"即时复活、无动画"需求)
// 新需求(用户原话):
// ① "当角色死亡后不要立马重生,需要用户手动按重生键重生"
// ② "死亡时除了原先的红叉,还要附加一个小的死亡动画"
// 本测试覆盖:死亡定格等待手动重生 / 死亡动画要素(粒子+震动+冲击环圆心)/
// 手动 respawn 回存档点 / 连续死亡 / 红叉常驻与去重 / 掉坑死亡位置 clamp
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 down = c => key('keydown', c);
const up = c => key('keyup', c);
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 20, H = 8, TILE = 32;
// row1 有存档点 'G'(col1);row2 放两枚上刺(col5/col10);row3 实心地板
const grid = [
'#'.repeat(W),
'#' + 'G' + '.'.repeat(W - 3) + '#',
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
'#'.repeat(W),
];
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const ok = m => console.log(' ✓ ' + m);
const step = (L, P, n) => { for (let i = 0; i < n; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); } };
function fresh() {
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
step(L, P, 12);
return { L, P, deaths: () => deaths };
}
// 1) 踩刺:当帧进入死亡状态并定格(不自动复活),动画要素齐全
{
const { L, P, deaths } = fresh();
const sx = L.spawn.x;
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
L.update(1, P, null); P.update(1); Input.endFrame();
if (P.alive || !P.dead) err('踩刺后没有进入死亡定格状态');
else ok('踩刺后当帧死亡并定格(等待手动重生)');
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
else ok('死亡计数 = 1(正常累加)');
if (P.vx !== 0 || P.vy !== 0) err('死亡时速度未清零');
if (!(L.particles.list.length > 0)) err('死亡没有触发粒子爆发(小死亡动画缺失)');
else ok(`死亡动画:粒子爆发 ${L.particles.list.length} 个`);
if (!(L.shake > 0)) err('死亡没有触发屏幕轻震');
else ok(`死亡动画:屏幕轻震 shake=${L.shake.toFixed(1)}`);
if (Math.abs(P.deathX - (5 * TILE + 5.5)) > 12 || Math.abs(P.deathY - (2 * TILE + 21.5)) > 12)
err(`死亡动画圆心偏离死亡点 (${P.deathX.toFixed(0)},${P.deathY.toFixed(0)})`);
else ok('死亡动画圆心 = 死亡点');
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
else {
const dm = L.deathMarks[0];
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
if (Math.abs(dm.x - ex) > 30 || Math.abs(dm.y - ey) > 30) err(`血迹偏离死亡点 (${dm.x.toFixed(0)},${dm.y.toFixed(0)})`);
else ok('在死亡地点留下红色死亡叉(X)');
}
if (P.x !== 5 * TILE) err('死亡后位置被改动(应定格在原处)');
else ok('死亡后定格在死亡位置');
}
// 2) 死亡后 60 帧:仍然 dead、不自动重生、输入完全无效
{
const { L, P, deaths } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
const x0 = P.x;
down('ArrowRight'); down('ShiftLeft');
step(L, P, 60);
up('ArrowRight'); up('ShiftLeft');
if (P.alive) err('死亡 60 帧后自动复活了(违反"手动重生"需求)');
else ok('死亡后 60 帧仍定格等待手动重生');
if (deaths() !== 1) err(`死亡计数被重复触发(${deaths()})`);
if (P.deadT < 60) err(`deadT 未随死亡时间递增(${P.deadT})`);
else ok(`deadT 随时间递增(${P.deadT})`);
if (P.x !== x0) err('死亡后按方向键仍能移动');
else ok('死亡中输入无效(方向/跳跃均不响应)');
}
// 3) 手动 respawn:回到最近存档点、justRespawned 置位、立即可操作
{
const { L, P } = fresh();
// 先碰存档点(col1)
const s = L.entities.find(e => e.type === 'save');
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(L, P, 3);
const cp = P.savePoint;
if (!cp) { err('存档点未激活(前置失败)'); }
else {
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) err('踩刺未死');
P.respawn(); // ← 模拟玩家按 R 手动重生
if (!P.alive || P.dead) err('手动 respawn 后未复活');
else ok('手动 respawn 成功复活');
if (Math.abs(P.x - cp.x) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
else ok('复活位置 = 最近存档点');
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
else ok('justRespawned 已置位(镜头瞬移)');
const x0 = P.x;
down('ArrowRight');
step(L, P, 6);
up('ArrowRight');
if (P.x <= x0) err('重生后当帧无法操作');
else ok(`重生后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
}
}
// 4) 连续死亡 5 次:每次都须手动重生才能继续,计数准确
{
const { L, P, deaths } = fresh();
for (let k = 0; k < 5; k++) {
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) { err(`第 ${k + 1} 次踩刺未死`); break; }
step(L, P, 10); // 死亡中跑 10 帧:不许自动复活
if (P.alive) { err(`第 ${k + 1} 次死亡后自动复活`); break; }
P.respawn(); // 手动重生后才有下一次
}
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
else ok('连续 5 次死亡均等待手动重生,计数 = 5');
}
// 5) 红叉常驻保留;整关重开清空
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
P.respawn();
step(L, P, 30);
if (!L.deathMarks.length) err('重生后红叉被清掉了(应常驻保留)');
else ok(`死亡红叉常驻保留(${L.deathMarks.length} 处,重生 30 帧后仍在)`);
L.reset(true);
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
else ok('整关重开(reset(true))后血迹清空');
}
// 6) 多次死亡:只显示最后一次死亡的红叉(不累积)
{
const { L, P } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
P.respawn();
P.x = 10 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame();
if (L.deathMarks.length !== 1) err(`多次死亡应只显示最后一次血迹,实际 ${L.deathMarks.length} 处`);
else {
const dm = L.deathMarks[0];
if (Math.abs(dm.x - (10 * TILE + 5.5)) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
else ok('多次死亡只保留上一次死亡地点的血迹');
}
}
// 7) 掉坑死亡:动画圆心/红叉 clamp 到关卡内(玩家已在屏幕外也要可见)
{
const { L, P } = fresh();
P.y = L.pxH + 300; // 深坑底(屏幕外)
L.update(1, P, null); P.update(1); Input.endFrame();
if (!P.dead) err('掉坑未触发死亡');
else ok('掉坑触发死亡并定格');
if (P.deathY > L.pxH - 10) err(`掉坑死亡的动画圆心未 clamp 到关卡内(deathY=${P.deathY.toFixed(0)} > ${L.pxH - 10})`);
else ok(`掉坑死亡圆心 clamp 到关卡底缘(deathY=${P.deathY.toFixed(0)})`);
if (P.deathX < 10 || P.deathX > L.pxW - 10) err('动画圆心 X 未 clamp 到关卡内');
if (!L.deathMarks.length) err('掉坑死亡未留红叉');
else if (L.deathMarks[0].y > L.pxH) err('掉坑死亡的红叉画到了关卡外');
else ok('掉坑死亡的红叉在关卡内可见');
}
// 8) 死亡动画渲染冒烟:冲击环(Level 层)+ 粒子逐帧 draw 不抛错(canvas 用代理桩)
{
const { L } = fresh();
const ctx2d = new Proxy({ canvas: { width: 800, height: 608 } },
{ get(t, k) { return k in t ? t[k] : (() => { }); }, set(t, k, v) { t[k] = v; return true; } });
try {
for (let i = 0; i < 20; i++) {
L.drawDeathRing(ctx2d, { x: 100, y: 100, t: i });
L.particles.spawn(100, 100, 4, { life: 20 });
L.particles.draw(ctx2d);
}
ok('死亡动画(冲击环+粒子)20 帧渲染无异常');
} catch (e) { err(`死亡动画渲染抛错:${e.message}`); }
}
// 9) 主动按重生键(main.js suicide):当帧重生 + 演出留在死亡位置播完(不清粒子/震屏)
{
const { L, P, deaths } = fresh();
const s = L.entities.find(e => e.type === 'save');
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(L, P, 3);
const cp = P.savePoint;
if (!cp) { err('存档点未激活(前置失败)'); }
else {
P.x = 12 * TILE; P.y = 2 * TILE - P.h; // 走到右侧空地(col5/col10 有刺,避开)
step(L, P, 5);
const marks0 = L.deathMarks.length;
const burstX = P.cx, burstY = P.cy;
P.deathBurst(); // ← 模拟活着按 R 的演出部分(不进死亡状态)
if (P.dead || !P.alive) err('死亡演出把玩家带进了死亡状态(应纯演出)');
else ok('死亡演出为纯演出(不进死亡状态,可当帧重生)');
if (deaths() !== 0) err(`主动重生键不应计死亡,实际 ${deaths()}`);
else ok('主动重生键不计死亡数');
if (L.deathMarks.length !== marks0) err('主动重生键不应留红叉');
else ok('主动重生键不留红叉');
if (!(L.particles.list.length > 0) || !(L.shake > 0)) err('主动重生键的死亡动画缺失(粒子/震屏)');
else ok('主动重生键播死亡动画(粒子+轻震+冲击环)');
if (!L.deathFX.length) err('冲击环未记录到关卡层');
else if (Math.abs(L.deathFX[0].x - burstX) > 12) err('冲击环圆心偏离死亡点');
else ok('冲击环记录在死亡位置(Level 层)');
// 当帧重生:不等动画
P.respawn();
if (!P.alive || P.dead) err('未当帧重生');
else ok('当帧重生(不等待动画)');
if (Math.abs(P.x - cp.x) > 1) err(`重生位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
else ok('重生回最近存档点');
if (P.justRespawned !== true) err('未标记 justRespawned,镜头不会瞬移');
// 动画在死亡位置继续播完:粒子未被清、冲击环在推进
if (!(L.particles.list.length > 0)) err('重生把演出粒子清掉了(应留在死亡位置播完)');
else ok('重生不清场:粒子留在死亡位置继续播');
step(L, P, 5);
if (!L.deathFX.length) err('冲击环被提前清掉了');
step(L, P, 12); // 累计 17 帧 > 14
if (L.deathFX.length) err('冲击环 14 帧后未移除');
else ok('冲击环 14 帧扩散淡出后自动移除');
step(L, P, 25); // 粒子 life 34,累计 42 帧应自然播完
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length} 个)`);
else ok('粒子自然播完(34 帧寿命)');
if (!(L.shake < 0.6)) err(`震屏未自然衰减(${L.shake.toFixed(2)})`);
else ok('震屏自然衰减归零');
}
}
// 10) 真死亡(被动)后玩家很快按 R:特效不被重生清场,留在死亡位置自然播完
{
const { L, P, deaths } = fresh();
P.x = 5 * TILE; P.y = 2 * TILE + 11;
L.update(1, P, null); P.update(1); Input.endFrame(); // 踩刺真死
if (!P.dead) err('踩刺未死(前置失败)');
else {
step(L, P, 3); // 玩家只等了 3 帧就按 R
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
P.respawn();
if (!P.alive || P.dead) err('快速重生失败');
else ok('真死亡后 3 帧即按 R:成功重生');
if (!(L.particles.list.length > 0)) err('快速重生把死亡粒子清掉了(应留在死亡位置播完)');
else ok('快速重生不清场:粒子留在死亡位置继续播');
if (!L.deathFX.length) err('快速重生把冲击环清掉了');
else ok('快速重生不清场:冲击环继续扩散');
if (!(L.shake > 0)) err('快速重生把震屏清掉了');
else ok('快速重生不清场:震屏继续衰减');
step(L, P, 50); // 粒子 34 / 环 14 / 震屏衰减,全部应自然结束
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length})`);
else if (L.deathFX.length) err('冲击环未自然播完');
else if (L.shake > 0.01) err(`震屏未自然衰减(${L.shake.toFixed(3)})`);
else ok('特效全部自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减)');
}
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡流程全部通过:定格等待手动重生 + 小红死亡动画 + 主动重生键当帧重生');
process.exit(fail ? 1 : 0);
+95
View File
@@ -0,0 +1,95 @@
// 二段跳时机回归(tools/dj-test.mjs)
// 历史 bug 链:
// ① 全额抵消(旧 `vy = max(vy,0) - DJUMP`):满力一段到顶点后晚几帧接二段,二段塌到接近 0(用户报"二段非常短")
// ② 部分抵消(DJ_INERTIA 0.5→0.35):仍有"好沉+高度短"——起跳初速被下落速度从 6.7 吃到 3~4,
// 起跳那一下没劲才是"沉"的根源,与上升重力无关
// ③ 现行(DJ_INERTIA = 0,同 IWBT 原版):任何时机接二段都满力起跳,初速恒 -DJUMP
// 本测试用真实引擎测量:不同时机接二段时,二段自身的提升高度(djRise)。
// 通过标准:
// · 任意时机接二段都 ≈ 满力(≈60px ≈ 1.9 格)——INERTIA=0 语义,曲线应基本持平
// · 满力一段 ≈ 84px(≈2.6 格);顶点接二段总高 ≈ 4.5 格
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
// 与 jumpdist.mjs 同一块平地:row17 是实心平台
const flat = {
id: 'test', name: 'T', type: 'jump', width: 44,
grid: (() => {
const rows = [];
rows.push('#'.repeat(44));
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
rows.push('#' + '#'.repeat(42) + '#');
rows.push('#'.repeat(44));
return rows;
})(),
};
// 满力一段跳(起跳后一直按住);第 djFrame 帧松键、djFrame+1 帧接二段(之后保持按住)
// 返回:firstRise 一段顶点提升 / djRise 二段自身提升(接二段位置→二段后最高点)/ total 二段顶点离平台高度
function measure(djFrame) {
const L = new Level(flat);
const P = new Player(L);
P.x = 8 * 32; P.y = 17 * 32 - P.h; // 站在 row17 实心平台上
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
const y0 = P.y;
key('keydown', 'ShiftLeft'); // 起跳并按住 = 满力一段
let apex1 = y0, apex2 = y0, djDone = false, vyAtDj = null, yAtDj = y0;
for (let i = 0; i < 240; i++) {
const f = i + 1;
if (f === djFrame) key('keyup', 'ShiftLeft');
if (f === djFrame + 1) { key('keydown', 'ShiftLeft'); vyAtDj = P.vy; yAtDj = P.y; }
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (djFrame && f === djFrame + 1 && P.jumps === 2) djDone = true;
if (djDone) apex2 = Math.min(apex2, P.y);
else apex1 = Math.min(apex1, P.y);
if (i > 2 && P.grounded) break;
if (P.dead) break;
}
key('keyup', 'ShiftLeft');
Input.endFrame();
return { firstRise: y0 - apex1, djRise: djDone ? yAtDj - apex2 : 0, total: y0 - apex2, vyAtDj, djDone };
}
// 满力一段顶点≈第 27 帧;落地≈第 50 帧。取 27~46 帧覆盖"顶点 → 落地前"整个二段窗口
const FRAMES = [27, 30, 34, 38, 42, 46];
const T = 32;
let fail = 0;
const rows = [];
console.log('满力一段跳后,不同时机接二段:二段自身提升高度(真实引擎实测)');
console.log(' 时机 接二段时vy 一段顶点 二段自身提升 二段顶点离平台');
for (const f of FRAMES) {
const r = measure(f);
rows.push({ f, ...r });
const dj = r.djDone
? `${r.djRise.toFixed(1).padStart(6)}px = ${(r.djRise / T).toFixed(2)} 格`
: ' —— 已落地,二段窗口已过 ——';
console.log(` 第${String(f).padStart(2)}帧 ${(r.vyAtDj ?? 0).toFixed(2).padStart(5)} ${r.firstRise.toFixed(1).padStart(6)}px ${dj} ${(r.total / T).toFixed(2)} 格`);
}
const at = f => rows.find(r => r.f === f);
const need = (cond, msg) => { if (!cond) { fail++; console.log(' ❌ ' + msg); } };
for (const r of rows) need(r.djDone, `第 ${r.f} 帧应还在二段窗口内(未落地)`);
const apex = at(27);
need(apex.firstRise > 79 && apex.firstRise < 90, `满力一段高度应 ≈84px(2.6格),实测 ${apex.firstRise.toFixed(1)}`);
need(apex.djRise > 52 && apex.djRise < 68, `顶点接二段自身提升应 ≈59px(1.85格),实测 ${apex.djRise.toFixed(1)}`);
need(apex.total > 138 && apex.total < 150, `顶点接二段总高应 ≈4.5格,实测 ${(apex.total / T).toFixed(2)}`);
// INERTIA=0:任何时机接二段都满力起跳,曲线应基本持平(与顶点差异 ≤3px)
for (const r of rows) {
if (!r.f) continue;
need(r.djRise >= 55, `第 ${r.f} 帧接二段自身提升应 ≈满力(≥55px),实测 ${r.djRise.toFixed(1)}`);
need(Math.abs(r.djRise - apex.djRise) <= 3, `第 ${r.f} 帧与顶点接二段差异应 ≤3px(INERTIA=0 曲线持平),实测差 ${Math.abs(r.djRise - apex.djRise).toFixed(1)}`);
}
need(at(46).djRise >= 55, `即使摔得快二段也不应衰减(INERTIA=0),实测 ${at(46).djRise.toFixed(1)}`);
if (fail) { console.log(`\n❌ dj-test 失败 ${fail} 项`); process.exit(1); }
console.log('\n✅ dj-test 通过:任意时机接二段均满力起跳(INERTIA=0),顶点总高 ≈4.5 格');
+94
View File
@@ -0,0 +1,94 @@
// DOM 环境冒烟:mock 浏览器 API,跑通入口 main.js 的初始化 + 若干帧
const noop = () => { };
const els = new Map();
function mkEl(id = '') {
const el = {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d,
focus: noop, click: noop,
};
return el;
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800,
location: globalThis.location, navigator: globalThis.navigator,
matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = {
_d: {},
getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = String(v); },
removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector(s) { return mkEl(s); },
querySelectorAll() { return []; },
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: addL,
body: mkEl('body'),
};
let frames = 0;
globalThis.requestAnimationFrame = (fn) => {
if (frames++ > 12) return 0;
setTimeout(() => fn(performance.now() + frames * 16.7), 0);
return frames;
};
try {
await import('../js/main.js');
console.log('✅ main.js 初始化成功');
} catch (e) {
console.log('❌ main.js 初始化失败: ' + e.message);
console.log(e.stack.split('\n').slice(0, 5).join('\n'));
process.exit(1);
}
// 等几帧
await new Promise(r => setTimeout(r, 200));
const { LEVELS } = await import('../js/levels/index.js');
const api = globalThis.window.__IW;
console.log('✅ 已跑 ' + frames + ' 帧,状态=' + (api ? api.G.state : '?'));
if (api) {
// 逐个关卡启动一次,确认不炸
let bad = 0;
for (const lv of LEVELS) {
try {
api.startLevel(lv, lv.index ?? -1);
for (let i = 0; i < 90; i++) { /* 由 rAF 驱动,这里只验证构造 */ }
if (!api.G.level || !api.G.player) throw new Error('未创建关卡');
} catch (e) { console.log(' ✗ ' + lv.id + ': ' + e.message); bad++; }
}
console.log(bad ? `❌ ${bad} 个关卡启动失败` : `✅ 全部 ${LEVELS.length} 关均可启动`);
}
process.exit(0);
+136
View File
@@ -0,0 +1,136 @@
// 编辑器交互回归:调色板无「查看属性」工具;无论手上拿什么方块(橡皮除外),
// 点击画布上任意方块都选中该格并在属性面板显示其属性(不覆盖已有方块)。
const noop = () => { };
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
// ---------- 最小假 DOM(addEventListener 收句柄,children 真实入列)----------
const els = new Map();
function mkEl(id = '') {
const e = {
id, tagName: 'DIV', value: '', _html: '', textContent: '',
style: {}, dataset: {}, children: [],
width: 800, height: 608,
classList: {
_s: new Set(),
add(...c) { c.forEach(x => this._s.add(x)); },
remove(...c) { c.forEach(x => this._s.delete(x)); },
toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
contains(c) { return this._s.has(c); },
},
_h: {},
addEventListener(t, fn) { (this._h[t] = this._h[t] || []).push(fn); },
removeEventListener: noop,
appendChild(c) { this.children.push(c); return c; },
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
preventDefault: noop,
};
Object.defineProperty(e, 'innerHTML', {
get() { return this._html; },
set(v) { this._html = String(v); this.children = []; },
});
return e;
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
const getEl = s => { if (!els.has(s)) els.set(s, mkEl(s)); return els.get(s); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = noop;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.document = {
getElementById: getEl, querySelector: getEl, querySelectorAll: () => [],
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: noop, body: mkEl('body'),
};
const { Editor, PALETTE } = await import('../js/editor.js');
// 画布坐标 → 格子:pos() 用 clientX/Y(scroll=0,缩放 1:1),格心 (tx*32+16, ty*32+16)
const ev = (tx, ty, button = 0) => ({ clientX: tx * 32 + 16, clientY: ty * 32 + 16, button, preventDefault: noop });
const clickTile = (tx, ty, button = 0) => {
for (const fn of getEl('#ed-canvas')._h.mousedown) fn(ev(tx, ty, button));
};
// renderProps 先写面板 innerHTML(标题),再把每个属性字段 appendChild 成子行
const propsHtml = () => {
const box = getEl('#ed-props');
return box.innerHTML + box.children.map(c => c.innerHTML || '').join('');
};
console.log('=== 调色板不再有「查看属性」工具 ===');
ok(!PALETTE.some(p => p[0] === '?'), "PALETTE 中已无 '?'(查看属性)");
ok(!PALETTE.some(p => p[1] === '查看属性'), 'PALETTE 中已无「查看属性」条目');
Editor.init({ startLevel: noop });
const palLabels = getEl('#palette').children.map(c => c.textContent || '');
ok(palLabels.length === PALETTE.length, `调色板渲染 ${PALETTE.length} 项(无工具项),实际 ${palLabels.length}`);
ok(!palLabels.some(t => t.includes('查看属性')), '调色板 UI 无「查看属性」按钮');
console.log('=== 打开地图,构造一块带属性的摄像头 C ===');
Editor.open();
const f = Editor.curFace();
f.grid[5][7] = 'C'; // 摄像头:SCHEMA 里有 4 个可编辑属性
f.props[5][7] = { w: 4, h: 4, mode: 'zoom', zoom: 1.5 };
console.log('=== 手上拿刺(^),点击已有摄像头 → 只看属性,不覆盖 ===');
Editor.sel = '^';
clickTile(7, 5);
ok(f.grid[5][7] === 'C', '格子内容未被覆盖(仍是 C)');
ok(Editor.selTile && Editor.selTile.tx === 7 && Editor.selTile.ty === 5, 'selTile 指向被点方块 (7,5)');
const html1 = propsHtml();
ok(html1.includes('区域宽') && html1.includes('镜头模式'), '属性面板显示该方块的属性(区域宽/镜头模式)');
console.log('=== 手上拿方块(#),点击同一格 → 同样只查看,不覆盖 ===');
Editor.sel = '#';
clickTile(7, 5);
ok(f.grid[5][7] === 'C', '拿 # 点击也不覆盖已有方块');
ok(propsHtml().includes('区域宽'), '属性面板仍显示 C 的属性');
console.log('=== 手上拿炮台(O),点击空格子 → 放置并显示新方块属性 ===');
Editor.sel = 'O';
clickTile(3, 9);
ok(f.grid[9][3] === 'O', '空格子正常放置 O');
ok(propsHtml().includes('发射间隔'), '放置后属性面板显示 O 的属性');
console.log('=== 提示牌 T 的文本属性可随点击查看 ===');
f.grid[9][4] = 'T'; f.props[9][4] = { msg: '小心刺' };
Editor.sel = '%'; // 拿着冰面点提示牌
clickTile(4, 9);
ok(f.grid[9][4] === 'T' && propsHtml().includes('提示文字'), '非相关工具点击 T 也能查看文字属性');
console.log('=== 橡皮擦例外:选中橡皮点击 → 擦除 ===');
Editor.sel = '.';
clickTile(4, 9);
ok(f.grid[9][4] === '.', '橡皮擦点击后 T 被擦除');
Editor.sel = '^'; // 恢复非橡皮工具
clickTile(7, 5); // 顺手确认 C 还在
console.log('=== 右键擦除与工具无关 ===');
Editor.sel = '#';
clickTile(3, 9, 2); // button=2 右键
ok(f.grid[9][3] === '.', '右键擦除不受手上工具影响');
console.log('=== 属性面板空态提示仍存在 ===');
Editor.show(); // 重置 selTile
ok(propsHtml().includes('点击画布上的方块'), '未选中时显示引导文案');
console.log(fail === 0 ? '\n✅ 编辑器点击查看属性 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+97
View File
@@ -0,0 +1,97 @@
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
const noop = () => { };
const els = new Map();
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, {
get(t, k) { return k in t ? t[k] : noop; },
set(t, k, v) { t[k] = v; return true; },
});
// 装上计数器
ctx2d.lineTo = () => C.lineTo++;
ctx2d.fillRect = () => C.fillRect++;
ctx2d.beginPath = () => C.beginPath++;
ctx2d.moveTo = () => C.moveTo++;
ctx2d.arc = () => C.arc++;
function mkEl(id = '') {
return {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
};
}
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector(s) { return mkEl(s); },
querySelectorAll() { return []; },
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
addEventListener: addL, body: mkEl('body'),
};
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const resetC = () => { for (const k in C) C[k] = 0; };
const { Editor } = await import('../js/editor.js');
const { PALETTE } = await import('../js/editor.js');
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
try {
Editor.init({ startLevel: noop });
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
console.log('=== 打开空白地图并绘制 ===');
Editor.open();
const f = Editor.curFace();
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
console.log('=== 刺在编辑器里以三角形绘制 ===');
f.grid[1][1] = '^';
resetC();
Editor.draw();
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
f.grid[1][1] = '#';
resetC();
Editor.draw();
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
let bad = 0;
for (const [ch] of PALETTE) {
if (ch === '.') continue;
try { f.grid[2][2] = ch; Editor.draw(); }
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
}
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+103
View File
@@ -0,0 +1,103 @@
// 冰面惯性回归测试:滑出冰块后必须保留惯性(空中几乎不衰减,地面缓慢收住)
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 down = c => key('keydown', c);
const up = c => key('keyup', c);
const { Level } = await import('../js/level.js');
const { Player, PHY } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const W = 44;
// 地板 row 11:冰面 col1..6,之后 tail(普通地面或空)
function grid(tail) {
const g = [];
g.push('#'.repeat(W));
for (let y = 1; y <= 10; y++) {
g.push((y === 10 ? '#S' + '.'.repeat(W - 3) : '#' + '.'.repeat(W - 2)) + '#');
}
g.push('#' + '%'.repeat(6) + tail + '#');
g.push('#'.repeat(W));
g.push('#'.repeat(W));
return g;
}
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
const L = new Level({ id: 'ice', name: 'ICE', type: 'jump', width: W, grid: grid(tail) });
const P = new Player(L);
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
down('ArrowRight');
const log = [];
for (let i = 0; i < 90; i++) {
if (i === releaseAt) up('ArrowRight');
if (i === jumpAt) down('ShiftLeft');
if (i === jumpAt + 1) up('ShiftLeft');
L.update(1, P, null); P.update(1); Input.endFrame();
log.push({
f: i, x: +P.x.toFixed(1), vx: +P.vx.toFixed(2), y: +P.y.toFixed(1),
g: P.grounded, sl: P.sliding,
ice: P.grounded && L.iceAtBottom(P.x, P.y, P.w, P.h),
});
}
up('ArrowRight');
console.log(`\n=== ${label} ===`);
console.log('帧 x vx 地 滑 冰');
let crossAt = -1;
for (const r of log) {
const cross = !r.ice && log.find(o => o.f === r.f - 1)?.ice;
if (cross && crossAt < 0) { crossAt = r.f; console.log('--- 离开冰面 ---'); }
if (r.f % 3 === 0 || cross) console.log(`${String(r.f).padStart(3)} ${String(r.x).padStart(6)} ${String(r.vx).padStart(6)} ${r.g ? 'Y' : '.'} ${r.sl ? 'Y' : '.'} ${r.ice ? 'Y' : '.'}`);
}
const off = log.filter(r => r.f >= crossAt && crossAt >= 0);
const dist = off.length ? Math.max(...off.map(r => r.x)) - off[crossAt === 0 ? 0 : 0].x : 0;
console.log(`离冰时 vx=${off[0]?.vx ?? 0},离冰后位移=${dist.toFixed(1)}px`);
return { log, crossAt, off };
}
// A:一直按住右键冲出冰面 —— 惯性要把 4.32 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
{
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
if (v0 < PHY.HSPD) err(`A 离冰瞬间就丢了速度 vx=${v0}`);
if (v10 < 2) err(`A 离冰 10 帧后速度掉太快 vx=${v10}(应仍在滑行)`);
if (Math.abs(v30) > PHY.HSPD + 0.01) err(`A 离冰 30 帧后仍超速 vx=${v30}(应收敛回 ${PHY.HSPD})`);
}
// B:松手靠惯性滑出到普通地面 —— 地面摩擦收住,但不能一帧刹死
{
const { off } = run('='.repeat(34), 'B 冰面 -> 普通地面(第30帧松开方向键)', 30);
const v0 = off[0].vx, v5 = off[5]?.vx ?? 0;
if (v5 <= 0) err(`B 离冰 5 帧内就刹死了(v0=${v0} v5=${v5})`);
if (v5 > v0) err(`B 松手后速度反而上升`);
}
// C:滑出冰块边缘掉下去 —— 空中必须几乎不衰减,能一路飞出去
{
const { off } = run('.'.repeat(34), 'C 冰面 -> 悬空(第30帧松开后滑出边缘)', 30);
const air = off.filter(r => !r.g);
if (air.length < 5) { err('C 没掉下去(场景构造失败)'); }
else {
const k = Math.min(10, air.length - 1);
const v0 = air[0].vx, vk = air[k].vx;
if (vk < v0 - 0.5) err(`C 空中惯性衰减过快:${v0} -> ${vk}(${k} 帧)`);
else console.log(` ✓ 空中 ${k} 帧后仍保留 vx=${vk}(起 ${v0})`);
}
}
// D:冰面上滑行中起跳 —— 空中保持滑行速度
{
const { off } = run('.'.repeat(34), 'D 冰面滑行中起跳(第30帧松开 + 第34帧跳)', 30, 34);
const v0 = off[0]?.vx ?? 0, v12 = off[12]?.vx ?? 0;
if (v0 < 2) err(`D 起跳时速度太小 vx=${v0}`);
if (v12 < v0 - 0.6) err(`D 跳跃后空中丢失惯性:${v0} -> ${v12}`);
else console.log(` ✓ 起跳后 12 帧仍保留 vx=${v12}(起 ${v0})`);
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 冰面惯性全部通过');
process.exit(fail ? 1 : 0);
+54
View File
@@ -0,0 +1,54 @@
// 用真实引擎(Player/Level)测量跳跃水平距离,核对求解器的物理近似是否可信
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
// 一块 40 格长的平地,右端断开
const flat = {
id: 'test', name: 'T', type: 'jump', width: 44,
grid: (() => {
const rows = [];
rows.push('#'.repeat(44));
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
rows.push('#' + '#'.repeat(42) + '#'); // 整条实心平台
rows.push('#'.repeat(44));
return rows;
})(),
};
function measure(djFrame) {
const L = new Level(flat);
const P = new Player(L);
P.x = 8 * 32; P.y = 16 * 32 - P.h; // 站在 row17 平台上(平台行 17)
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
const x0 = P.x;
key('keydown', 'ArrowRight');
key('keydown', 'ShiftLeft');
let f = 0, landed = -1, released = false;
for (let i = 0; i < 200; i++) {
f++;
// 二段跳需要先松开再按下(跨帧),否则不算一次新的按下
if (djFrame && f === djFrame) { key('keyup', 'ShiftLeft'); released = true; }
if (released && f === djFrame + 1) { key('keydown', 'ShiftLeft'); released = false; }
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (i > 2 && P.grounded) { landed = P.x; break; }
if (P.dead) break;
}
key('keyup', 'ArrowRight'); key('keyup', 'ShiftLeft');
Input.endFrame();
return { dist: landed >= 0 ? landed - x0 : null, f };
}
console.log('真实引擎跳跃水平距离(起跳点 x 到落点 x,单位 px / 格)');
for (const dj of [0, 6, 12, 18, 24, 30]) {
const r = measure(dj);
console.log(` 二段跳时机=${String(dj).padStart(2)} 帧 -> ${r.dist === null ? '未落地' : r.dist.toFixed(1) + 'px = ' + (r.dist / 32).toFixed(2) + ' 格 (' + r.f + ' 帧)'}`);
}
+120
View File
@@ -0,0 +1,120 @@
// 回归测试:R 键必须"重生回存档点",不能把整关重开(否则存档点白碰)
const noop = () => { };
const els = new Map();
function mkEl(id = '') {
return {
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
style: {}, dataset: {}, children: [], width: 800, height: 608,
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
addEventListener: noop, removeEventListener: noop, appendChild: noop,
querySelector: () => mkEl(), querySelectorAll: () => [],
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
getContext: () => ctx2d, focus: noop, click: noop,
};
}
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
let clock = 1000;
globalThis.performance = { now: () => clock };
let rafCb = null;
globalThis.requestAnimationFrame = fn => { rafCb = fn; return 1; };
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
location: globalThis.location, navigator: globalThis.navigator,
matchMedia: globalThis.matchMedia,
};
globalThis.addEventListener = addL;
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
globalThis.alert = noop;
globalThis.localStorage = {
_d: {}, getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
querySelector: s => mkEl(s), querySelectorAll: () => [],
createElement: t => mkEl(), addEventListener: addL, body: mkEl('body'),
};
await import('../js/main.js');
const { LEVELS } = await import('../js/levels/index.js');
const api = globalThis.window.__IW;
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
const step = n => { for (let i = 0; i < n; i++) { clock += 17; const f = rafCb; rafCb = null; if (f) f(clock); } };
const d = LEVELS.find(l => l.id === 'j1');
api.startLevel(d, 0);
step(10);
const L0 = api.G.level, P = api.G.player;
// 手动吃一个存档点(模拟玩家走到那里)
const s = L0.entities.filter(e => e.type === 'save')[1];
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
step(3);
const cp = P.savePoint;
console.log(` 存档点激活=${!!cp} 位置=(${cp?.x.toFixed(0)},${cp?.y.toFixed(0)}) 关卡出生点x=${L0.spawn.x.toFixed(0)}`);
if (!cp) err('存档点未激活');
// ---- 按 R(活着)= 原地播死亡动画后当帧重生回存档点(不计死亡、不留红叉、不等动画)----
const deaths0 = api.G.deaths;
const marks0 = L0.deathMarks.length;
P.x = L0.spawn.x; P.y = L0.spawn.y; // 先站到出生点,验证能回到存档点而非原地不动
step(5);
const beforeX = api.G.player.x;
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
console.log(` 活着按 R 后:玩家x=${beforeX.toFixed(0)}→${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} dead=${api.G.player.dead} 死亡数=${deaths0}→${api.G.deaths} 红叉数=${marks0}→${L0.deathMarks.length} 粒子=${L0.particles.list.length} 冲击环=${L0.deathFX.length}`);
if (api.G.player.dead || !api.G.player.alive) err('活着按 R 没有当帧重生');
if (api.G.deaths !== deaths0) err('主动重生被计入了死亡数(主动重生不是死亡)');
if (L0.deathMarks.length !== marks0) err('主动重生留下了红叉(不应留血迹)');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`按 R 没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (!(L0.particles.list.length > 0)) err('死亡演出粒子缺失(应留在死亡位置播完)');
if (!L0.deathFX.length) err('死亡演出冲击环缺失(应留在死亡位置播完)');
// ---- 死亡中按 R = 手动重生回存档点 ----
api.G.player.kill();
step(3);
if (!api.G.player.dead) err('kill 后未进入死亡定格状态');
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
step(10);
const survivedLevel = api.G.level === L0;
console.log(` 死亡中按 R 重生后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} 残余粒子=${L0.particles.list.length}`);
if (!survivedLevel) err('R 键把整关重开了,存档进度丢失');
if (api.G.player.dead || !api.G.player.alive) err('死亡中按 R 未手动重生');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`重生后没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) err('重生后被送回了关卡起点');
if (!(L0.particles.list.length > 0)) err('死亡中按 R 重生把特效清掉了(应留在死亡位置播完)');
// ---- 按 Backspace = 整关重来 ----
key('keydown', 'Backspace');
step(2);
key('keyup', 'Backspace');
step(10);
console.log(` 按 Backspace 后:关卡对象被重建=${api.G.level !== L0} 玩家x=${api.G.player.x.toFixed(0)}`);
if (api.G.level === L0) err('Backspace 没有整关重开');
if (Math.abs(api.G.player.x - api.G.level.spawn.x) > 4) err('Backspace 后玩家不在出生点');
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 按键语义正确:R=回存档点,Backspace=整关重来');
process.exit(fail ? 1 : 0);
+169
View File
@@ -0,0 +1,169 @@
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
export const TILE = 32, PW = 11, PH = 21;
export const GRAV = 0.25, GRAV_FALL = 0.32, GRAV_DJ = 0.35, MAXFALL = 9, JUMP = 6.6, DJUMP = 6.7, HSPD = 2.6, DJ_INERTIA = 0;
export function setupGlobals() {
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
return handlers;
}
export function makeSim(L) {
// 水:近似为可站立
for (let y = 0; y < L.h; y++)
for (let x = 0; x < L.w; x++)
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
// 移动平台:整条路径当桥(乐观近似)
const br = [];
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]);
const ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
function solidAt(x, y, w, h, down, prevBottom) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
const t = L.tileAt(tx, ty);
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
if (L.isSolidTile(t, tx, ty)) return true;
}
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
return false;
}
function canStand(fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
function jump(fx, fy, vx0, djump, flip, rel, targets) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false, djUp = false;
while (t++ < 240) {
// 与 player.js 一致:上升中按二段 = 直接 -DJUMP;下落中按只抵消 DJ_INERTIA 比例的下落速度
if (djump && !used && t === djump) { vy = Math.max(vy, 0) * DJ_INERTIA - DJUMP; used = true; djUp = vy < 0; }
if (flip && t === flip) vx = -vx;
if (rel && t === rel && vy < -3) vy = -3;
vy = Math.min(vy + (vy < 0 ? (djUp ? GRAV_DJ : GRAV) : GRAV_FALL), MAXFALL);
if (djUp && vy >= 0) djUp = false;
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(nx, y, PW, PH, false)) x = nx;
}
const prevBottom = y + PH;
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + dy * m;
if (!solidAt(x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
else { if (dy > 0) landed = true; vy = 0; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + (vy < 0 ? GRAV : GRAV_FALL), MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + m;
if (!solidAt(x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
return { solidAt, canStand, jump, fall, overlap };
}
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
// 2026-10-23:加 rel=5/6。vgap 夹缝(缝高 32px、盒高 21px)要求短跳 apex 精确落在 32~43px,
// 且上升早期要躲开地刺左坡:rel=4 apex 35.7 但 t=7 蹭坡 2.6px,rel=6 apex 44.7 但 t=11 擦垂刺尖 0.6px,
// 只有 rel=5(apex 38.9)同时满足(j2 x16 夹缝实证);j7 顶层 x52 夹缝则要 rel=6
export const DEF_REL = [0, 4, 5, 6, 8, 14];
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
const sim = makeSim(L);
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) {
if (sim.canStand(sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
// 3px 状态桶:6px 桶会把"贴右腰 1~3px"的起拍/落点窗量化吞掉(j2 连续三段因此误判不可达)
// fy 规范化:jump/fall 返回的落点常比地面高零点几 px(如 1535.5),与精确落点 1536 同桶——
// 先占桶者赢,导致走链被错位几 px 的跳链顶掉,芝麻距离的跳跃窗口翻转(加 rel=6 后 j2 状态数反而暴跌的根因)。
// 真实游戏里重力下一帧即把玩家按到底(地面顶 = 32 的倍数),故按 canStand 门控 snapping
const key = s => `${Math.round(s.fx / 3)},${Math.round(s.fy / 3)}`;
const canon = (fx, fy) => {
const r = Math.round(fy / TILE) * TILE;
return (r !== fy && sim.canStand(fx, r)) ? r : fy;
};
start.fy = canon(start.fx, start.fy);
const seen = new Set([key(start)]);
const q = [start];
let n = 0;
while (q.length && n < maxStates) {
const s = q.shift(); n++;
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
const push = r => {
if (!r || !sim.canStand(r.fx, r.fy)) return;
r.fy = canon(r.fx, r.fy);
const k = key(r);
if (seen.has(k)) return;
seen.add(k); q.push(r);
if (onState) onState(r);
};
for (const d of [-HSPD, HSPD]) push(sim.canStand(s.fx + d, s.fy) ? { fx: s.fx + d, fy: s.fy } : null);
for (const d of [-1, 1]) push(sim.fall(s.fx, s.fy, d, targets));
for (const vx of [-HSPD, 0, HSPD]) for (const dj of DJSET) for (const rel of RELSET) {
if (dj && rel) continue;
push(sim.jump(s.fx, s.fy, vx, dj, 0, rel, targets));
if (vx !== 0) push(sim.jump(s.fx, s.fy, vx, dj, 22, rel, targets));
}
}
return { states: seen.size, targets };
}
export function makeTargets(L) {
const targets = [];
for (const e of L.entities) {
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
targets.push({ type: e.type, idx: e.idx, no: e.no, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
return targets;
}
+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);
+31
View File
@@ -0,0 +1,31 @@
// 诊断:打印可达站立点范围(按行),找出断连位置
// 用法: node reach-diag.mjs <levelId>
import { setupGlobals, reach, TILE } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const id = process.argv[2] || 'j6';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const byRow = new Map();
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
const r = reach(L, {
DJSET, RELSET,
onState: s => {
const row = Math.round(s.fy / TILE);
const cur = byRow.get(row) || { max: -1, min: 1e9 };
byRow.set(row, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
},
});
console.log(`${id} ${d.name} ${L.w}x${L.h} 状态=${r.states}${r.err ? ' ERR:' + r.err : ''}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(row => {
const v = byRow.get(row);
console.log(` row ${String(row).padStart(2)} (y=${row * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
});
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
+31
View File
@@ -0,0 +1,31 @@
// 诊断:每个存档点的放置环境(脚下是什么 / 重生点是否致命 / 是否在路径上)
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
const { T } = await import('../js/level.js');
const NAME = { 0: '空', 1: '块', 2: '草', 3: '砖', 4: '金属', 5: '冰', 6: '水', 7: '单向', 8: '即死', 9: '消失', 10: '上刺', 11: '下刺', 12: '左刺', 13: '右刺' };
for (const d of LEVELS) {
const L = new Level(d);
const P = new Player(L);
const saves = L.entities.filter(e => e.type === 'save');
if (!saves.length) { console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ⚠ 无存档点`); continue; }
const lines = [];
for (const s of saves) {
const below = L.tileAt(s.tx, s.ty + 1);
const above = L.tileAt(s.tx, s.ty - 1);
// 重生点
const rx = s.x + s.w / 2 - P.w / 2, ry = s.y + s.h - P.h;
const deadly = L.deadlyRect(rx, ry, P.w, P.h);
const solid = L.solidRect(rx, ry, P.w, P.h);
lines.push(`(${s.tx},${s.ty}) 脚=${NAME[below] || below} 头=${NAME[above] || above}${deadly ? ' ❌重生即死' : ''}${solid ? ' ❌卡墙里' : ''}`);
}
console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ${saves.length} 个: ${lines.join(' | ')}`);
}
+59
View File
@@ -0,0 +1,59 @@
// 存档点专项测试:触碰 -> 激活 -> 死亡 -> 回到存档点
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 down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('../js/player.js');
const { Input } = await import('../js/input.js');
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
for (const d of LEVELS) {
const L = new Level(d);
const P = new Player(L);
const saves = L.entities.filter(e => e.type === 'save');
if (!saves.length) { console.log(` ${d.id} 无存档点`); continue; }
// 依次把玩家瞬移到每个存档点,检查是否激活
for (const s of saves) {
P.x = s.x + s.w / 2 - P.w / 2;
P.y = s.y + s.h - P.h;
P.vx = 0; P.vy = 0; P.dead = false; P.alive = true;
L.update(1, P, null);
P.update(1);
Input.endFrame();
if (!s.active) err(`${d.id} 存档点(${s.tx},${s.ty}) 触碰后未激活`);
if (!P.savePoint) err(`${d.id} 存档点(${s.tx},${s.ty}) 未写入 player.savePoint`);
}
// 死亡后应回到最后一个存档点
const last = saves[saves.length - 1];
const want = { x: last.x + last.w / 2 - P.w / 2, y: last.y + last.h - P.h };
const spawn = L.spawn;
P.kill(); // 死亡后不再自动重生(新语义:定格等待手动重生)
P.respawn(); // 模拟玩家按 R 手动重生,应回到最后一个存档点
for (let i = 0; i < 45; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
const dx = Math.abs(P.x - want.x), dy = Math.abs(P.y - want.y);
const backToStart = Math.abs(P.x - spawn.x) < 3 && Math.abs(P.y - spawn.y) < 3;
const ok = dx < 2 && dy < 2;
console.log(` ${d.id.padEnd(3)} 存档=${saves.length} 重生=(${P.x.toFixed(0)},${P.y.toFixed(0)}) 期望=(${want.x.toFixed(0)},${want.y.toFixed(0)}) ${ok ? 'OK' : (backToStart ? '❌回到了起点' : '❌位置不符')}`);
if (!ok) err(`${d.id} 死亡后未回到存档点`);
// 重生后是否立刻死亡
const alive0 = P.alive;
for (let i = 0; i < 20; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (alive0 && !P.alive) err(`${d.id} 在存档点重生后立刻死亡`);
// 存档点状态是否在软重置后保留
if (!last.active) err(`${d.id} 软重置后存档点激活状态丢失`);
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 存档点全部正常');
process.exit(fail ? 1 : 0);
+223
View File
@@ -0,0 +1,223 @@
// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认
const noop = () => { };
class El {
constructor(tag = 'DIV') {
this.tagName = tag.toUpperCase();
this.value = '';
this.textContent = '';
this._html = '';
this._classes = new Set();
this._style = {};
this.dataset = {};
this.children = [];
this._ev = {};
this.parent = null;
const self = this;
this.classList = {
add: c => self._classes.add(c),
remove: c => self._classes.delete(c),
contains: c => self._classes.has(c),
toggle: (c, f) => { const on = f === undefined ? !self._classes.has(c) : f; on ? self._classes.add(c) : self._classes.delete(c); return on; },
};
}
get style() { return this._style; }
set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); }
get className() { return [...this._classes].join(' '); }
set innerHTML(v) { this._html = v; if (v === '') this.children = []; }
get innerHTML() { return this._html; }
appendChild(c) { c.parent = this; this.children.push(c); return c; }
remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
querySelector() { return new El(); }
querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; }
closest(sel) { const cls = sel.replace(/^\./, ''); let n = this; while (n) { if (n._classes && n._classes.has(cls)) return n; n = n.parent; } return null; }
addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
removeEventListener() { }
dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
setPointerCapture() { }
releasePointerCapture() { }
getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 608 }; }
}
const byId = new Map();
const touchMap = new Map();
const docListeners = {};
globalThis.localStorage = {
_d: {},
getItem(k) { return k in this._d ? this._d[k] : null; },
setItem(k, v) { this._d[k] = String(v); },
removeItem(k) { delete this._d[k]; },
};
globalThis.document = {
getElementById(id) { if (!byId.has(id)) byId.set(id, new El()); return byId.get(id); },
createElement(t) { return new El(t); },
querySelector(sel) {
const m = sel.match(/data-touch="([^"]+)"/);
if (m) { const k = m[1]; if (!touchMap.has(k)) touchMap.set(k, new El()); return touchMap.get(k); }
return new El();
},
querySelectorAll() { return []; },
addEventListener(type, fn) { (docListeners[type] ||= []).push(fn); },
removeEventListener() { },
dispatch(type, ev) { (docListeners[type] || []).forEach(fn => fn(ev)); },
};
globalThis.window = { addEventListener: noop, removeEventListener: noop };
globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); };
globalThis.removeEventListener = noop;
globalThis.confirm = () => true;
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const { Save } = await import('../js/save.js');
const { Settings } = await import('../js/settings.js');
const { applyBindings, defaultBindings } = await import('../js/input.js');
console.log('=== input 键位 API ===');
ok(defaultBindings().left.includes('ArrowLeft'), 'defaultBindings 含出厂左移键');
applyBindings({ left: ['KeyA'], jump: ['Space'] });
ok(true, 'applyBindings 调用不报错');
console.log('=== 初始化 / 默认布局(PC 模式)===');
Settings.init(false);
const s0 = Save.data.settings;
ok(!!s0 && !!s0.touch, '存档含 settings.touch');
const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide'];
ok(keys.every(k => s0.touch[k]), '7 个手柄键都有默认布局');
ok(s0.touch.left.cx === 12 && s0.touch.jump.size === 94, '默认坐标/尺寸正确');
ok(byId.get('title-user').textContent === '玩家:无名氏', '标题默认显示「无名氏」');
console.log('=== apply 把布局写到真实手柄 ===');
const L = touchMap.get('left'), J = touchMap.get('jump');
ok(L._style.left === '12%' && L._style.top === '80%' && L._style.width === '62px', 'left 按钮定位/尺寸已应用');
ok(J._style.width === '94px' && J._style.left === '88%', 'jump 按钮定位/尺寸已应用');
console.log('=== 进入 / 确定 保存用户名与布局 ===');
Settings.enter();
byId.get('set-username').value = '阿测玩家';
Settings.confirm();
ok(Save.data.settings.username === '阿测玩家', '用户名已保存到存档');
ok(byId.get('title-user').textContent === '玩家:阿测玩家', '标题用户名已更新');
ok(localStorage.getItem('iwbtc_fan_v1').includes('阿测玩家'), '已写入 localStorage');
console.log('=== 恢复默认 ===');
byId.get('set-username').value = '随便';
Settings.reset();
ok(Settings._state().username === '', '恢复默认后用户名为空');
ok(Settings._state().touch.jump.size === 94 && Settings._state().touch.left.cx === 12, '恢复默认后布局回到出厂值');
// 恢复默认后必须再点确定才落盘
ok(Save.data.settings.username === '阿测玩家', '恢复默认未自动落盘(需点确定)');
Settings.confirm();
ok(Save.data.settings.username === '', '点确定后空用户名落盘');
console.log('=== 布局越长越界会被限制(place 约束在交互层,这里校验默认在合法区间)===');
ok(keys.every(k => { const p = Save.data.settings.touch[k]; return p.cx >= 4 && p.cx <= 96 && p.size >= 24 && p.size <= 160; }), '默认布局均在合法范围');
console.log('=== UI SCREENS 注册(防止设置屏永远不显示)===');
const uiSrc = (await import('node:fs')).readFileSync(new URL('../js/ui.js', import.meta.url), 'utf8');
ok(/SCREENS\s*=\s*\[[^\]]*'scr-settings'/.test(uiSrc), 'scr-settings 已加入 SCREENS 列表');
ok(/data-act="settings"/.test((await import('node:fs')).readFileSync(new URL('../index.html', import.meta.url), 'utf8')), '标题菜单有「设置」入口');
console.log('=== 模拟拖动 / 缩放(防「按下即跳角」)===');
Settings.enter(); // 重新同步工作副本
const ev = (t, x, y) => ({ target: t, clientX: x, clientY: y, pointerId: 1, preventDefault: noop, stopPropagation: noop });
const left = Settings._els().left;
const handle = left.children.find(c => c._classes.has('tc-rsz')) || left.children[0];
const wt = Settings._state().touch;
const cx0 = wt.left.cx, cy0 = wt.left.cy;
// 在按钮中心 (96,486) 按下,向右拖 40px(预览宽 800 → +5%)
left.dispatch('pointerdown', ev(left, 96, 486));
left.dispatch('pointermove', ev(left, 136, 486));
ok(Math.abs(wt.left.cx - (cx0 + 5)) < 0.01 && wt.left.cy === cy0, '拖动:cx 按位移增量变化,无跳角');
// 再拖回原处
left.dispatch('pointermove', ev(left, 96, 486));
ok(Math.abs(wt.left.cx - cx0) < 0.01, '拖动:逆向位移可精确归位');
left.dispatch('pointerup', ev(left, 96, 486));
// 缩放手柄:中心 (96,486),起点距中心 ~56,拉远到 ~113 → 尺寸翻倍
const size0 = wt.left.size;
handle.dispatch('pointerdown', ev(handle, 136, 526));
handle.dispatch('pointermove', ev(handle, 176, 566));
ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大(${size0}→${wt.left.size.toFixed(0)})`);
handle.dispatch('pointerup', ev(handle, 176, 566));
ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置');
console.log('=== 设备模式:PC 显示键盘映射,移动端显示手柄 ===');
Settings.init(true); // isTouch=true → 移动端
Settings.enter();
ok(byId.get('set-keys-row')._classes.has('hidden'), '移动端:键盘映射面板隐藏');
ok(!byId.get('set-touch-row')._classes.has('hidden'), '移动端:手柄面板显示');
Settings.init(false); // PC
Settings.enter();
ok(!byId.get('set-keys-row')._classes.has('hidden'), 'PC:键盘映射面板显示');
ok(byId.get('set-touch-row')._classes.has('hidden'), 'PC:手柄面板隐藏');
console.log('=== 键盘重绑:点击按键 → 按新键 ===');
Settings.enter();
const keysBox = byId.get('set-keys');
const firstRow = keysBox.children[0]; // 左移行
const kkeys = firstRow.children[1]; // kkeys 容器
const chip = kkeys.children[0]; // 第一个按键 chip(left[0])
ok(chip.dataset.kact === 'left' && String(chip.dataset.kidx) === '0', '首行首键为 left[0]');
keysBox.dispatch('click', { target: chip }); // 进入重绑监听
document.dispatch('keydown', { code: 'KeyB', preventDefault: noop }); // KeyB 未被任何动作占用
ok(Settings._state().keys.left[0] === 'KeyB', '重绑:left[0] 变为空闲键 KeyB');
// 添加第二个键
const before = Settings._state().keys.left.length;
const addBtn = kkeys.children[kkeys.children.length - 1]; // +添加
keysBox.dispatch('click', { target: addBtn });
document.dispatch('keydown', { code: 'KeyF', preventDefault: noop }); // KeyF 空闲
ok(Settings._state().keys.left.length === before + 1 && Settings._state().keys.left.includes('KeyF'), '添加:left 多绑定了一个键 KeyF');
// 删除第一个键(即刚重绑的 KeyB)
const chip1 = kkeys.children[0];
const rm = chip1.children.find(c => c._classes.has('krm'));
keysBox.dispatch('click', { target: rm });
ok(!Settings._state().keys.left.includes('KeyB') && Settings._state().keys.left.includes('KeyF'), '删除:left[0] 被移除(KeyB 消失,KeyF 仍在)');
// 确定后落盘
Settings.confirm();
ok(Save.data.settings.keys.left.includes('KeyF'), '确定:键位变更已保存到存档');
console.log('=== 键盘冲突检测 ===');
Settings.reset(); // 出厂:left=[ArrowLeft,KeyA]
Settings.enter();
const kb = byId.get('set-keys');
const jumpRow = kb.children[3]; // 跳跃行(KEY_ACTIONS 第 4 项,down 已移除)
const jumpKeys = jumpRow.children[1];
const jumpChip = jumpKeys.children[0];
ok(jumpChip.dataset.kact === 'jump', '找到跳跃行的按键');
kb.dispatch('click', { target: jumpChip }); // 监听跳跃键
document.dispatch('keydown', { code: 'KeyA', preventDefault: noop }); // KeyA 已被左移占用
const st2 = Settings._state();
ok(!st2.keys.jump.includes('KeyA'), '冲突:jump 不会绑定到被【左移】占用的 KeyA');
ok(Settings._conflict() && Settings._conflict().act === 'jump' && Settings._conflict().label === '左移', '冲突:记录到被【左移】占用');
ok(kb.children[0] && kb.children[0]._classes.has('kwarb'), '冲突:面板顶部显示警告横幅');
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); // 换空闲键
ok(st2.keys.jump[0] === 'KeyK', '冲突解除:改用空闲键 KeyK 成功');
ok(Settings._conflict() === null, '冲突:解除后警告清空');
// 同一动作内重复键也算冲突(jump 已含 KeyK,再绑到第二个槽应被拒)
kb.dispatch('click', { target: jumpKeys.children[jumpKeys.children.length - 1] }); // +添加
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop });
ok(st2.keys.jump.filter(c => c === 'KeyK').length === 1, '冲突:同一动作内重复键不重复写入');
// Esc 取消监听
kb.dispatch('click', { target: jumpChip });
document.dispatch('keydown', { code: 'Escape', preventDefault: noop });
ok(Settings._listening() === null && Settings._conflict() === null, 'Esc:退出监听且无残留警告');
// 系统动作(菜单 KeyQ)也算冲突
kb.dispatch('click', { target: jumpChip });
document.dispatch('keydown', { code: 'KeyQ', preventDefault: noop });
ok(Settings._conflict() && Settings._conflict().label === '菜单', '冲突:KeyQ 已被系统「菜单」占用');
console.log('=== 恢复默认:键位回到出厂 ===');
Settings.reset();
ok(Settings._state().keys.left.includes('ArrowLeft'), '恢复默认:left 含出厂 ArrowLeft');
ok(Settings._state().keys.jump.includes('ShiftLeft'), '恢复默认:jump 含出厂 ShiftLeft');
console.log('=== 回归:main.js 中「确定」应返回上一页 ===');
const mainSrc = (await import('node:fs')).readFileSync(new URL('../js/main.js', import.meta.url), 'utf8');
ok(/settings:\s*\(\)\s*=>\s*\{\s*settingsFrom\s*=\s*UI\.cur/.test(mainSrc), '进入设置前记录来源界面');
ok(/'set-ok':\s*\(\)\s*=>\s*\{\s*Settings\.confirm\(\);\s*UI\.show\(settingsFrom\)/.test(mainSrc), '确定后 UI.show(settingsFrom) 返回上一页');
ok(/Settings\.init\(useTouch\)/.test(mainSrc), 'Settings.init 传入设备检测结果');
console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+105
View File
@@ -0,0 +1,105 @@
// 无头冒烟测试:构造全部关卡 + 模拟输入跑物理
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 down = c => key('keydown', c);
const up = c => key('keyup', c);
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const { Player } = await import('./player.js'.replace('./player.js', '../js/player.js'));
const { Input } = await import('../js/input.js');
let fail = 0;
const err = m => { console.log(' ✗ ' + m); fail++; };
console.log('=== 关卡构造 ===');
for (const d of LEVELS) {
try {
const L = new Level(d);
const P = new Player(L);
// 静止 60 帧
for (let i = 0; i < 60; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
const grounded = P.grounded, alive = P.alive;
console.log(` ${d.id.padEnd(3)} ${String(d.name).padEnd(16)} 实体=${String(L.entities.length).padStart(3)} boss=${L.bosses.length} 平台=${L.movers.length} 站住=${grounded ? 'Y' : 'N'} 存活=${alive ? 'Y' : 'N'}`);
if (!grounded && d.type !== 'boss') err(`${d.id} 出生点悬空(60帧后未着地)`);
if (!alive) err(`${d.id} 出生即死`);
// 模拟:向右跑 + 反复跳跃 400 帧
down('ArrowRight');
for (let i = 0; i < 400; i++) {
if (i % 40 === 0) down('ShiftLeft');
if (i % 40 === 8) up('ShiftLeft');
if (i % 40 === 12) { down('ShiftLeft'); }
if (i % 40 === 16) up('ShiftLeft');
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
}
up('ArrowRight'); up('ShiftLeft');
const moved = P.x;
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) err(`${d.id} 坐标 NaN`);
if (Math.abs(P.vx) > 20 || Math.abs(P.vy) > 40) err(`${d.id} 速度异常 vx=${P.vx} vy=${P.vy}`);
console.log(` → 400帧后 x=${moved.toFixed(0)} y=${P.y.toFixed(0)} 死亡=${G_DEATHS(P)}`);
} catch (e) {
err(`${d.id} 运行异常: ${e.message}\n${e.stack?.split('\n')[1] || ''}`);
}
}
function G_DEATHS() { return ''; }
console.log('\n=== 死亡与手动重生 ===');
{
const L = new Level(LEVELS.find(l => l.id === 'j1'));
const P = new Player(L);
let deaths = 0;
P.onDeath = () => deaths++;
for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
P.kill();
// 新语义(2026-10-24):死亡后不自动重生,定格等待玩家手动按重生键
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (P.alive) err('死亡后未等待手动重生(自动复活了)');
else console.log(` 死亡计数=${deaths} 80帧后仍定格等待手动重生 ✓(deadT=${P.deadT})`);
P.respawn();
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
if (!P.alive) err('手动重生失败');
else console.log(` 手动重生后 alive ✓ 位置=(${P.x.toFixed(0)},${P.y.toFixed(0)})`);
if (deaths !== 1) err('死亡回调未触发');
}
console.log('\n=== 射击与 BOSS ===');
{
const L = new Level(LEVELS.find(l => l.id === 'b1'));
const P = new Player(L);
const boss = L.bosses[0];
const hp0 = boss.hp;
P.hasGun = true;
for (let i = 0; i < 600; i++) {
if (i % 12 === 0) P.shootCool = 0, P.shoot();
L.update(1, P, null);
for (const b of L.bosses) b.update(1, P);
P.update(1);
Input.endFrame();
if (boss.hp <= 0) break;
}
console.log(` BOSS hp ${hp0} → ${boss.hp} 弹幕数=${L.ebullets.length} 阶段=${boss.phase}`);
if (boss.hp >= hp0) err('子弹没有对 BOSS 造成伤害');
}
console.log('\n=== 坑向事件 ===');
{
const L = new Level(LEVELS.find(l => l.id === 'r4'));
const P = new Player(L);
let died = 0; P.onDeath = () => died++;
// 走到传送门 (r4: col 44, row 17)
P.x = 44 * 32; P.y = 17 * 32;
for (let i = 0; i < 4; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
console.log(` 传送门:玩家被送到 x=${P.x.toFixed(0)} (起点 x=${L.spawn.x})`);
if (Math.abs(P.x - L.spawn.x) > 4) err('传送门未送回起点');
}
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 全部通过');
process.exit(fail ? 1 : 0);
+37
View File
@@ -0,0 +1,37 @@
// 可达性求解器:验证每关终点 / 存档点 / 苹果能否到达
// DJ=0,12,24 收紧二段跳时机;REL=0 禁用短跳 —— 检验是否只有"某一帧"能过
import { setupGlobals, reach, makeTargets } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
let fail = 0;
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
for (const d of LEVELS) {
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
let r;
try {
const { Level } = await import('../js/level.js');
const L = new Level(d);
const targets = makeTargets(L);
r = reach(L, { DJSET, RELSET, targets });
r.targets = targets;
} catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
const goal = r.targets.find(t => t.type === 'goal');
const saves = r.targets.filter(t => t.type === 'save');
const apples = r.targets.filter(t => t.type === 'apple');
const okSave = saves.filter(s => s.hit).length;
const okApple = apples.filter(a => a.hit).length;
const badSave = saves.filter(s => !s.hit).map(s => `#${s.no ?? s.idx}`);
const flag = goal && goal.hit ? ' ' : '✗';
if (!goal || !goal.hit) fail++;
if (badSave.length) fail++;
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states})`);
}
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
process.exit(fail ? 1 : 0);
+86
View File
@@ -0,0 +1,86 @@
// 单三角形刺:视觉与碰撞形状完全对应的单元测试
// 验证 spikeTri 顶点、triRectOverlap 的正确性,以及 deadlyRect 对刺的判定。
const noop = () => { };
const ctx2d = new Proxy({
canvas: { width: 800, height: 608 },
measureText: () => ({ width: 12 }),
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: () => ({ data: [] }),
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
globalThis.window = { addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia };
globalThis.document = { getElementById: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop, toggle: noop }, addEventListener: noop, appendChild: noop, querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [] }), querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [], createElement: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop } }), addEventListener: noop, body: {} };
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
globalThis.confirm = () => true;
globalThis.alert = noop;
const { Level, T, spikeTri, triRectOverlap } = await import('../js/level.js');
const { TILE } = await import('../js/util.js');
let fail = 0;
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
const near = (a, b, e = 1e-6) => Math.abs(a - b) < e;
console.log('=== spikeTri 顶点(与 drawSpike 一致)===');
const s = TILE;
{
const tr = spikeTri(T.SPIKE_U, 0, 0);
ok(near(tr.ax, s / 2) && near(tr.ay, 0) && near(tr.bx, 0) && near(tr.by, s) && near(tr.cx, s) && near(tr.cy, s), 'SPIKE_U:尖在顶中,底在底部');
const tr2 = spikeTri(T.SPIKE_D, 0, 0);
ok(near(tr2.ax, s / 2) && near(tr2.ay, s) && near(tr2.bx, 0) && near(tr2.by, 0), 'SPIKE_D:尖在底中,底在顶部');
const tr3 = spikeTri(T.SPIKE_L, 0, 0);
ok(near(tr3.ax, 0) && near(tr3.ay, s / 2) && near(tr3.bx, s) && near(tr3.by, 0) && near(tr3.cx, s) && near(tr3.cy, s), 'SPIKE_L:尖在左中,底在右侧');
const tr4 = spikeTri(T.SPIKE_R, 0, 0);
ok(near(tr4.ax, s) && near(tr4.ay, s / 2) && near(tr4.bx, 0) && near(tr4.by, 0), 'SPIKE_R:尖在右中,底在左侧');
}
console.log('=== triRectOverlap 基本几何 ===');
{
// 上刺三角形:A(16,0) B(0,32) C(32,32)(TILE=32)
const A = spikeTri(T.SPIKE_U, 0, 0);
// 完全在三角形内部的矩形
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 14, 20, 4, 4), '三角形内部矩形 → 相交');
// 在三角形“上方窄尖之外”的矩形(顶部左侧,AABB 内但三角外)
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 1, 4, 4), '尖顶外侧矩形(AABB 内、三角外)→ 不相交');
// 完全在三角形之外(远离)
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 100, 100, 4, 4), '远处矩形 → 不相交');
// AABB 都不重叠
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 50, 0, 4, 4), 'AABB 不重叠 → 不相交');
// 底边左角(上刺底部很宽)——应当相交
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 28, 4, 4), '上刺底左角矩形 → 相交(形状精确:底宽尖窄)');
}
console.log('=== deadlyRect 集成(上刺占一格)===');
{
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
const L = new Level(data);
ok(L.tiles[1][1] === T.SPIKE_U, '网格解析:中心格子为 SPIKE_U');
const cx = 1 * s, cy = 1 * s; // 刺格像素 [32,32]-[64,64]
// 顶部左侧(尖窄处,三角外)→ 不致死
ok(!L.deadlyRect(cx + 1, cy + 1, 4, 4), '上刺尖顶外侧的碰撞盒 → 不致死');
// 底部中央(基底宽处)→ 致死
ok(L.deadlyRect(cx + 13, cy + 26, 8, 4), '上刺底部中央的碰撞盒 → 致死');
// 底左角 → 致死
ok(L.deadlyRect(cx + 1, cy + 26, 6, 4), '上刺底左角的碰撞盒 → 致死(形状精确)');
// 相邻空格(格外)→ 不致死
ok(!L.deadlyRect(0, 0, 11, 21), '完全在空地上的碰撞盒 → 不致死');
}
console.log('=== 形状精确性对比:旧矩形近似会把底左角判为安全,现在判为致死 ===');
{
// 旧 spikeBox 对上刺 x+6..x+10, y+8.. :底左角 (cx+1,cy+26) 落在 x+6 之外,旧逻辑安全;
// 新三角在底部很宽,应致死。下面断言新逻辑与“旧近似矩形”在该点结论相反(证明形状更精确)。
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
const L = new Level(data);
const cx = s, cy = s;
const oldSafe = (cx + 1 < cx + 6); // 旧矩形左界 x+6,该点在其左 → 旧逻辑“安全”
const newDeadly = L.deadlyRect(cx + 1, cy + 26, 3, 3);
ok(oldSafe && newDeadly, '底左角:旧矩形近似判安全,新三角判致死(符合“底宽尖窄”的真实形状)');
}
console.log(fail === 0 ? '\n✅ 单三角形刺(视觉=碰撞)全部断言通过' : `\n❌ 失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);