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