This commit is contained in:
cancerai committed 2026-10-20 22:21:26 +08:00
commit 234e769f4d
34 files changed
+4667

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);
+89
View File
@@ -0,0 +1,89 @@
// 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.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800,
};
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);
+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 + ' 帧)'}`);
}
+95
View File
@@ -0,0 +1,95 @@
// 回归测试: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.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
};
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;
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
step(60);
const survivedLevel = api.G.level === L0;
console.log(` 按 R 后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} 死亡=${deaths0}→${api.G.deaths}`);
if (!survivedLevel) err('R 键把整关重开了,存档进度丢失');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`R 键后没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (api.G.deaths !== deaths0 + 1) err('R 键没有计入死亡数');
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) 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);
+110
View File
@@ -0,0 +1,110 @@
// 诊断:打印可达站立点的最大 x(按行),找出断连位置
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 TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
function solidAt(L, br, 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(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
function jump(L, br, fx, fy, vx0, djump, flip) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) { const nx = x + vx; if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0; }
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); const ny = y + dy * m;
if (!solidAt(L, br, 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 (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(L, br, fx, fy, dir) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem); const ny = y + m;
if (!solidAt(L, br, x, ny, PW, PH, true, y + PH)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
const id = process.argv[2] || 'j6';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const br = [];
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;
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]), ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w, y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) { if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; } if (sy + k > L.pxH) break; }
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]); const q = [start]; let n = 0;
const byRow = new Map();
const note = s => {
const r = Math.round(s.fy / TILE);
const cur = byRow.get(r) || { max: -1, min: 1e9 };
byRow.set(r, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
};
note(start);
while (q.length && n < 40000) {
const s = q.shift(); n++;
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) { const ns = { fx: nx, fy: s.fy }; const k = key(ns); if (!seen.has(k)) { seen.add(k); q.push(ns); note(ns); } }
}
for (const d2 of [-1, 1]) { const r = fall(L, br, s.fx, s.fy, d2); if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } } }
for (const vx of [-HSPD, 0, HSPD]) for (const dj of [0, 6, 12, 18, 24, 30]) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0);
if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } }
if (vx !== 0) { const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22); if (r2 && canStand(L, br, r2.fx, r2.fy)) { const k = key(r2); if (!seen.has(k)) { seen.add(k); q.push(r2); note(r2); } } }
}
}
console.log(`${id} ${d.name} ${L.w}x${L.h} 可达站立行数=${byRow.size} 状态=${seen.size}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(r => {
const v = byRow.get(r);
console.log(` row ${String(r).padStart(2)} (y=${r * 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(' | ')}`);
}
+58
View File
@@ -0,0 +1,58 @@
// 存档点专项测试:触碰 -> 激活 -> 死亡 -> 回到存档点
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();
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);
+100
View File
@@ -0,0 +1,100 @@
// 无头冒烟测试:构造全部关卡 + 模拟输入跑物理
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++; };
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();
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
console.log(` 死亡计数=${deaths} 重生后 alive=${P.alive} 位置=(${P.x.toFixed(0)},${P.y.toFixed(0)})`);
if (!P.alive) err('死亡后未复活');
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);
+207
View File
@@ -0,0 +1,207 @@
// 可达性求解器:用真实物理常量做 BFS,验证终点 / 存档点能否到达
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');
// 可用 DJ=0,12,24 收紧二段跳时机选项,检验关卡是不是只有"某一帧"能过
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
// ---- 碰撞(单向平台:只有下落且上一帧还在平台面之上才挡)----
function solidAt(L, br, 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;
}
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;
/** 站立位置 (fx,fy)=脚底中心左端;返回是否合法 */
function canStand(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
/** 模拟一次跳跃:返回 {fx,fy} 落点 或 null;途中记录是否碰到目标 */
function jump(L, br, fx, fy, vx0, djump, flip, targets) {
let x = fx, y = fy - PH;
let vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0;
}
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);
const ny = y + dy * m;
if (!solidAt(L, br, 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(L, br, fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem);
const ny = y + m;
if (!solidAt(L, br, 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;
}
function solve(d) {
const L = new Level(d);
const br = [];
// 水:视为可站立的表面(游泳近似)
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;
// 移动平台:整条路径当作桥(乐观)
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 });
}
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, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
// 起点:从出生点往下找落脚点
let sx = L.spawn.x, sy = L.spawn.y + PH;
let start = null;
for (let k = 0; k < 400; k++) {
if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方 400px 内找不到落脚点', targets };
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]);
const q = [start];
let n = 0, steps = 0;
// 起跳时就检查一次(原地也算到过)
while (q.length && n < 40000) {
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 && overlap(box, tg.rect)) tg.hit = true;
// 走路
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) {
const ns = { fx: nx, fy: s.fy };
const k = key(ns);
if (!seen.has(k)) { seen.add(k); q.push(ns); }
}
}
// 走下悬崖
for (const d2 of [-1, 1]) {
const r = fall(L, br, s.fx, s.fy, d2, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
}
// 跳跃(含二段跳时机 / 空中转向)
for (const vx of [-HSPD, 0, HSPD]) {
for (const dj of DJSET) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
if (vx !== 0) {
const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22, targets);
steps++;
if (r2 && canStand(L, br, r2.fx, r2.fy)) {
const k = key(r2);
if (!seen.has(k)) { seen.add(k); q.push(r2); }
}
}
}
}
}
return { states: seen.size, steps, targets };
}
// ---------------- 运行 ----------------
let fail = 0;
console.log('=== 可达性求解 ===');
for (const d of LEVELS) {
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
let r;
try { r = solve(d); }
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.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} 模拟${r.steps})`);
}
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
process.exit(fail ? 1 : 0);