Files
kidgo/tools/death-test.mjs
T
2026-10-24 02:35:03 +08:00

273 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 回归测试:死亡流程(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);