273 lines
13 KiB
JavaScript
273 lines
13 KiB
JavaScript
// 回归测试:死亡流程(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);
|