fea: 优化死亡特效
This commit is contained in:
1 parent
afe2f64b9b
commit
e7200989ab
9 files changed
+373
-94
No files matched your search
@@ -71,3 +71,36 @@
|
|||||||
- 用户最初提的"下落惯性叠加"经三次实践证明给不出好手感,放弃;参数保留 DJ_INERTIA(现值 0)供回退。
|
- 用户最初提的"下落惯性叠加"经三次实践证明给不出好手感,放弃;参数保留 DJ_INERTIA(现值 0)供回退。
|
||||||
- 回归:dj-test/smoke/ice/spike/save/key 全过;未跑 solve(用户禁令)。
|
- 回归:dj-test/smoke/ice/spike/save/key 全过;未跑 solve(用户禁令)。
|
||||||
- dj-test 断言已随 INERTIA=0 语义重写(各时机 ≥55px 且与顶点差 ≤3px)。
|
- dj-test 断言已随 INERTIA=0 语义重写(各时机 ≥55px 且与顶点差 ≤3px)。
|
||||||
|
|
||||||
|
## 死亡流程改造:手动重生 + 小死亡动画(用户需求)
|
||||||
|
- 需求原话:「当角色死亡后不要立马重生,需要用户手动按重生键重生,并且死亡时除了原先的红叉,还要附加一个小的死亡动画」。推翻 2026-10-23 的"立即复活、无动画"旧需求。
|
||||||
|
- player.js:kill() 重写(定格 + 粒子爆发 16 个 + shake=6 + deathX/deathY clamp 到关卡内,红叉位置改用 clamp 后坐标);update() 开头 `if (this.dead) { this.deadT++; return; }` 早退;移除 4 处"kill 后当帧 respawn"(掉坑/刺/update 末尾兜底/假存档);draw() dead 分支画扩散冲击环(deadT<=14,半径 4→29px 淡出,kid 不画);reset() 初始化 deathX/deathY。
|
||||||
|
- main.js:suicide() 分流(alive→kill;dead→respawn),R 键两段式语义;render 的 playing 分支加死亡提示("按 R 重生"呼吸闪烁 + "Backspace 整关重来",触屏文案"按右下角重生键")。
|
||||||
|
- 触屏重生键(touch.js→api.suicide)自动获得死亡中重生能力,无需改。
|
||||||
|
- 测试:death-test.mjs 全面重写为 23 断言(定格/输入无效/手动 respawn 回存档点/连续 5 次死亡/红叉常驻+去重+reset 清空/掉坑 clamp/渲染冒烟)全过;key-test(R 自杀→定格、死亡中 R→重生)、save-test(kill 后手动 respawn)、smoke(死亡段改新语义)同步更新;dj/ice/spike/dom-smoke/editor-pick 全过;未跑 solve(禁令)。
|
||||||
|
- 踩坑:测试 grid 里 'S' 是出生点、存档点字符是 'G'(level.js:24),写测试地图别搞错。
|
||||||
|
|
||||||
|
## 重生键语义简化:任何状态按 R 都直接重生(用户反馈)
|
||||||
|
- 用户原话:「如果没死,由玩家手动按了重生键就直接重生」。此前 R 是两段式(活着=kill 定格→再按一次才回存档点),用户嫌按一次没反应。
|
||||||
|
- main.js suicide() 改为无条件 `G.player.respawn()`:活着按 = 放弃当前进度秒回存档点(不死亡、不计死亡数——主动重生不是失误);死亡中按 = 复活。触屏重生键走同一入口自动生效。
|
||||||
|
- key-test R 段重写:先站到出生点→按 R→验证回到存档点且 alive/dead/死亡数不变;再 kill→死亡中按 R→验证复活。全过。
|
||||||
|
- 回归:key/death/save/smoke/dj/ice/spike/dom-smoke/editor-pick 全过;未跑 solve(禁令)。
|
||||||
|
|
||||||
|
## 重生键最终形态:按一次 = 死亡动画 + 自动重生(用户二次反馈)
|
||||||
|
- 用户先要求「没死按重生键直接重生」,AI 做成无动画秒回;用户再反馈「不是,直接按重生键有死亡动画的」——要的是**一次按键=播死亡动画→演完自动重生**。
|
||||||
|
- 最终语义(main.js suicide):死亡中按 R = 立即复活;活着按 R = kill({mark:false, count:false, autoRespawn:30})——粒子+轻震+冲击环照播,不留红叉、不计死亡,30 帧(0.5s)后自动回存档点。
|
||||||
|
- player.js:kill() 参数化 opts{mark,count,autoRespawn};update 死亡早退里 autoRespawn 到期自动 respawn;reset/respawn 清理该字段。软重置(softReset 清粒子+shake)会收走动画残余粒子,属预期(爆发与冲击环已看完)。
|
||||||
|
- 测试:death-test 新增场景 9(主动重生键 7 断言)全过;key-test 活着按 R 段重写(演出中 dead/零计数/零红叉→33 帧后回存档点);save/smoke/dj/ice/spike/editor-pick/dom-smoke 全过。
|
||||||
|
- 补充(用户三次反馈):主动重生键演出期间**不显示**「按 R 重生」提示(main.js render 条件改为 `P.dead && !P.autoRespawn`)——演完自动重生,提示多余;真死亡时提示保留。
|
||||||
|
- 补充(用户四次反馈):主动重生键原定 autoRespawn=30 帧,冲击环 14 帧就播完,中间 16 帧只剩稀疏粒子=「空档」。改为 **autoRespawn=15**(冲击环 deadT<=14 播完的下一帧重生,零空档零腰斩)+ kill() 新增 opts.life 覆盖粒子寿命(主动模式 life=15 与冲击环同步收尾,避免被 softReset 腰斩满 alpha 粒子)。shake=6 每帧 ×0.86 衰减,15 帧时≈0.56px 无感,不会被重生清场掐断。
|
||||||
|
|
||||||
|
## 主动重生键终版:当帧重生 + 特效留在死亡位置播完(用户五次反馈)
|
||||||
|
- 用户原话:「不用等死亡动画结束再重生,直接重生就行了,动画在死亡位置放完」。此前 autoRespawn=15 帧的"演完再重生"方案废弃,autoRespawn 机制整个删除。
|
||||||
|
- 架构调整:冲击环从 player.draw 的 dead 分支**搬到 Level 层**(`deathFX` 数组 + addDeathFX + drawDeathRing + update 推进 14 帧移除)——否则玩家一重生环就消失,无法"在死亡位置放完"。
|
||||||
|
- player.js:新增 `deathBurst()`(纯演出:粒子+shake+addDeathFX,不进死亡状态);kill() 改为调 deathBurst + 红叉/计数(opts 仅剩 mark/count);respawn(opts) 透传 `softReset(opts.keepFX)`;draw() dead 分支简化为不画 kid。
|
||||||
|
- level.js:`softReset(keepFX=false)`——keepFX=true 时不清粒子/震屏(主动重生专用,让特效自然播完);reset(true) 清 deathFX。
|
||||||
|
- main.js suicide:活着按 = `deathBurst() + respawn({keepFX:true})` 当帧完成,零等待;死亡中按 = respawn()。
|
||||||
|
- 真死亡流程不变(定格+手动+红叉+计数);粒子 life 34 / 冲击环 14 帧 / shake ×0.86 全部自然播完(重生不清场)。
|
||||||
|
- 测试:death-test 场景 8/9 重写(当帧重生、纯演出不进死亡态、粒子/环/震屏全程自然播完断言),key-test 活着按 R 段重写;全套回归过。
|
||||||
|
- 补充(用户六次反馈):「kid 被动死亡的,死亡动画也应该放完,尽管玩家很快按了重生键」。根因:`softReset()` 无脑清 particles+shake——重生即清场,特效被腰斩。修复:softReset 永久不清粒子/震屏("演出层"语义:重生只重置关卡逻辑状态,不掐断演出;仅 reset(true) 整关重开才清空)。keepFX 参数随之删除,respawn() 恢复无参。踩坑:python 替换时把 `softReset(keepFX=false){` 函数头和 vanish/bullets 清逻辑一起吃进 old 串,文件语法损坏——已修复(补回函数头+4 行清逻辑,node import 验证)。教训:rep() 的 old 串跨函数签名行时,new 必须原样带回签名与后续语句。
|
||||||
|
- 测试:death-test 新增场景 10(真死亡 3 帧秒按 R:粒子/环/震屏全程保留并自然播完,7 断言);key-test 死亡中按 R 段加"残余粒子"断言;全套回归过。
|
||||||
@@ -9,6 +9,14 @@
|
|||||||
- 刺碰撞是**三角×AABB(SAT)**:`js/level.js` 的 `spikeTri(t,x,y)` 顶点与 `drawSpike` 一致(上刺尖在格顶 y、左右刺尖在格缘中点),`triRectOverlap` 做精确判定;`deadlyRect` 网格刺+掉落刺都走它。旧的 `spikeBox` 矩形盒已删除。`T.KILLER` 必须有兜底 return。
|
- 刺碰撞是**三角×AABB(SAT)**:`js/level.js` 的 `spikeTri(t,x,y)` 顶点与 `drawSpike` 一致(上刺尖在格顶 y、左右刺尖在格缘中点),`triRectOverlap` 做精确判定;`deadlyRect` 网格刺+掉落刺都走它。旧的 `spikeBox` 矩形盒已删除。`T.KILLER` 必须有兜底 return。
|
||||||
- **kid 绘制零形变**:起跳/落地 squash 已全部删除(`player.js` 无 `squash` 字段,`sprites.js` 的 `drawKid` 直接按原始 11×21 画)。用户明确要求跳跃/落地都不要身体晃动。
|
- **kid 绘制零形变**:起跳/落地 squash 已全部删除(`player.js` 无 `squash` 字段,`sprites.js` 的 `drawKid` 直接按原始 11×21 画)。用户明确要求跳跃/落地都不要身体晃动。
|
||||||
|
|
||||||
|
## 死亡与重生流程(2026-10-24 新语义,推翻早期"立即复活"需求)
|
||||||
|
- **死亡后不自动重生**:`kill()` 后玩家定格在死亡点,世界继续运转(敌人/子弹/粒子/相机照走)但玩家不响应输入(`player.js` update 开头 `if (this.dead) { this.deadT++; return; }`)。等玩家手动按重生键。
|
||||||
|
- **重生键 = R**(复用 IWBT 标准自杀键):活着按 R = 自杀回最近存档点;死亡中按 R = 手动重生(`main.js` 的 `suicide()` 按 `G.player.dead` 分流)。触屏重生键走同一入口(`touch.js` → `api.suicide`)。Backspace 仍是整关重来。
|
||||||
|
- **死亡动画("小"三件套,红叉之外附加)**:① `kill()` 时 16 个红白粒子爆发(`L.particles.spawn`,life 34);② `L.shake=6` 屏幕轻震(camera 原生支持,0.86/帧衰减);③ 死亡点扩散冲击环(`player.draw` 里 `dead` 分支,`deadT<=14` 帧内半径 4→29px 淡出,kid 本体不画)。
|
||||||
|
- **死亡坐标 clamp**:`deathX/deathY` 记录时 clamp 到关卡内(掉坑死亡时玩家已在屏幕外),红叉/粒子/圆环都落在可见区域;红叉(deathMarks)仍常驻保留、多次死亡只留最后一次、`reset(true)` 清空。
|
||||||
|
- **render 死亡提示**:`main.js` render 的 playing 分支画居中"按 R 重生"(呼吸闪烁,`Math.sin(t/12)`)+ 副行"Backspace 整关重来";触屏文案"按右下角重生键"。
|
||||||
|
- 历史:早期用户曾要求"死亡立即复活、无任何动画"(旧 `death-test.mjs` 首行注释为证),2026-10-24 明确推翻。回归:`tools/death-test.mjs`(23 断言:定格/输入无效/manual respawn/连续死亡/红叉/clamp/渲染冒烟);`key-test`(R 两段式语义)、`save-test`、`smoke` 的死亡段已同步改。
|
||||||
|
|
||||||
## 冰面滑行惯性(易反复踩坑)
|
## 冰面滑行惯性(易反复踩坑)
|
||||||
- 冰面物理只在 `js/player.js` 的水平输入段,`sliding` 是唯一惯性开关:冰上 `|vx|>SLIDE_MIN` 置位,离冰后仍按 `sliding` 走惯性分支,撞墙/归零/交还控制时才清位。
|
- 冰面物理只在 `js/player.js` 的水平输入段,`sliding` 是唯一惯性开关:冰上 `|vx|>SLIDE_MIN` 置位,离冰后仍按 `sliding` 走惯性分支,撞墙/归零/交还控制时才清位。
|
||||||
- **空中与地面必须分开摩擦**:`SLIDE_AIR 0.02`(几乎无损,能滑出冰块边缘飞出去)vs `SLIDE_FRIC 0.12`(地面约 34 帧/70px 收住)。曾经两者都用 0.25,导致「滑出冰块掉下去 7 帧就没速度」——玩家最直观的「没惯性」就来自这里。
|
- **空中与地面必须分开摩擦**:`SLIDE_AIR 0.02`(几乎无损,能滑出冰块边缘飞出去)vs `SLIDE_FRIC 0.12`(地面约 34 帧/70px 收住)。曾经两者都用 0.25,导致「滑出冰块掉下去 7 帧就没速度」——玩家最直观的「没惯性」就来自这里。
|
||||||
|
|||||||
+38
-4
@@ -87,7 +87,7 @@ export class Level {
|
|||||||
/** 完整重置(死亡 / 重开) */
|
/** 完整重置(死亡 / 重开) */
|
||||||
reset(full = false) {
|
reset(full = false) {
|
||||||
// tiles 状态(vanish 等)
|
// tiles 状态(vanish 等)
|
||||||
if (full) this.deathMarks = []; // 死亡血迹只在整关重开时清空,复活后保留
|
if (full) { this.deathMarks = []; this.deathFX = []; } // 血迹/死亡特效只在整关重开时清空,复活后保留
|
||||||
this.vanish = new Map();
|
this.vanish = new Map();
|
||||||
this.bullets = [];
|
this.bullets = [];
|
||||||
this.ebullets = [];
|
this.ebullets = [];
|
||||||
@@ -215,14 +215,15 @@ export class Level {
|
|||||||
this.totalSaves = saveNo;
|
this.totalSaves = saveNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位 */
|
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位。
|
||||||
|
* 粒子与震屏是"演出层":不清场——无论主动按 R 还是被动死亡后秒按 R,
|
||||||
|
* 死亡特效都留在死亡位置自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减),
|
||||||
|
* 重生只重置关卡逻辑状态,不掐断演出。整关重开(reset(true))才会清空 */
|
||||||
softReset() {
|
softReset() {
|
||||||
this.vanish.clear();
|
this.vanish.clear();
|
||||||
if (this.vanishT) this.vanishT.clear();
|
if (this.vanishT) this.vanishT.clear();
|
||||||
this.bullets.length = 0;
|
this.bullets.length = 0;
|
||||||
this.ebullets.length = 0;
|
this.ebullets.length = 0;
|
||||||
this.particles?.clear();
|
|
||||||
this.shake = 0;
|
|
||||||
for (const m of this.movers) {
|
for (const m of this.movers) {
|
||||||
const a = m.path[0];
|
const a = m.path[0];
|
||||||
if (a) { m.x = a.x; m.y = a.y; m.i = 0; }
|
if (a) { m.x = a.x; m.y = a.y; m.i = 0; }
|
||||||
@@ -253,6 +254,31 @@ export class Level {
|
|||||||
this.deathMarks.push({ x, y });
|
this.deathMarks.push({ x, y });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 死亡特效(扩散冲击环):记录到关卡层而非玩家——主动重生时玩家当帧就复活了,
|
||||||
|
// 环要留在死亡位置播完。t 由 update 推进,14 帧扩散淡出后移除
|
||||||
|
addDeathFX(x, y) {
|
||||||
|
this.deathFX.push({ x, y, t: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDeathRing(ctx, fx) {
|
||||||
|
const t = fx.t;
|
||||||
|
if (t > 14) return;
|
||||||
|
const r = 4 + t * 1.8, a = Math.max(0, 1 - t / 14);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = a * 0.85;
|
||||||
|
ctx.strokeStyle = '#ff8fa3';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(fx.x, fx.y, r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = a * 0.3;
|
||||||
|
ctx.fillStyle = '#ff5c7a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(fx.x, fx.y, r * 0.55, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
drawDeathMark(ctx, dm) {
|
drawDeathMark(ctx, dm) {
|
||||||
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
|
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
@@ -370,6 +396,11 @@ export class Level {
|
|||||||
this.time += dt;
|
this.time += dt;
|
||||||
const t = this.time;
|
const t = this.time;
|
||||||
|
|
||||||
|
// 死亡特效(冲击环)推进:14 帧后移除;粒子由 particles.update 自理
|
||||||
|
for (let i = this.deathFX.length - 1; i >= 0; i--) {
|
||||||
|
if (++this.deathFX[i].t > 14) this.deathFX.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// 移动平台
|
// 移动平台
|
||||||
for (const m of this.movers) {
|
for (const m of this.movers) {
|
||||||
if (!m.path.length) continue;
|
if (!m.path.length) continue;
|
||||||
@@ -623,6 +654,9 @@ export class Level {
|
|||||||
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
|
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
|
||||||
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
|
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
|
||||||
|
|
||||||
|
// 死亡特效(扩散冲击环,14 帧淡出)
|
||||||
|
for (const fx of this.deathFX) this.drawDeathRing(ctx, fx);
|
||||||
|
|
||||||
this.particles.draw(ctx);
|
this.particles.draw(ctx);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -136,10 +136,13 @@ function restart() {
|
|||||||
G.deaths = d; // 死亡数不重置
|
G.deaths = d; // 死亡数不重置
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自杀(IWBT 的 R 键):死后自动回到最近的存档点 */
|
/** R 键 / 触屏重生键:直接重生回最近的存档点——死亡中按 = 立即复活;活着按 = 原地播死亡动画后当帧重生 */
|
||||||
function suicide() {
|
function suicide() {
|
||||||
if (!G.player || G.state !== 'playing') return;
|
if (!G.player || G.state !== 'playing') return;
|
||||||
G.player.kill();
|
if (G.player.dead) G.player.respawn();
|
||||||
|
// 活着按:原地播死亡动画(粒子+轻震+冲击环),但不留红叉、不计死亡数,当帧就回存档点——
|
||||||
|
// 不等待,特效由 Level 层留在死亡位置自然播完(重生不清粒子/震屏)
|
||||||
|
else { G.player.deathBurst(); G.player.respawn(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触屏射击:仅在持枪、未死亡且游戏进行中时发射(受玩家自身冷却限制) */
|
/** 触屏射击:仅在持枪、未死亡且游戏进行中时发射(受玩家自身冷却限制) */
|
||||||
@@ -191,7 +194,7 @@ function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame
|
|||||||
// ---------------- 更新 ----------------
|
// ---------------- 更新 ----------------
|
||||||
function update() {
|
function update() {
|
||||||
const P = G.player;
|
const P = G.player;
|
||||||
// R = 自杀回存档点;Backspace = 整关重开
|
// R = 重生回存档点;Backspace = 整关重开
|
||||||
if (Input.hit('suicide')) { suicide(); return; }
|
if (Input.hit('suicide')) { suicide(); return; }
|
||||||
if (Input.hit('restart')) { restart(); return; }
|
if (Input.hit('restart')) { restart(); return; }
|
||||||
if (Input.hit('pause')) { pause(); return; }
|
if (Input.hit('pause')) { pause(); return; }
|
||||||
@@ -240,6 +243,21 @@ function render() {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
L.drawOverlay(ctx, C, t);
|
L.drawOverlay(ctx, C, t);
|
||||||
UI.toast(L.toast || '');
|
UI.toast(L.toast || '');
|
||||||
|
// 死亡中:提示手动重生(呼吸闪烁,不抢死亡动画镜头)
|
||||||
|
if (G.state === 'playing' && P.dead) {
|
||||||
|
const blink = Math.sin(t / 12) > -0.3;
|
||||||
|
ctx.save();
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.globalAlpha = blink ? 1 : 0.3;
|
||||||
|
ctx.fillStyle = '#ff8fa3';
|
||||||
|
ctx.font = 'bold 20px monospace';
|
||||||
|
ctx.fillText(useTouch ? '按右下角重生键' : '按 R 重生', W / 2, H / 2 - 36);
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.fillStyle = '#a8a8c0';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.fillText('Backspace 整关重来', W / 2, H / 2 - 12);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
drawTitleBg(t);
|
drawTitleBg(t);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-14
@@ -50,6 +50,7 @@ export class Player {
|
|||||||
this.alive = true;
|
this.alive = true;
|
||||||
this.dead = false;
|
this.dead = false;
|
||||||
this.deadT = 0;
|
this.deadT = 0;
|
||||||
|
this.deathX = 0; this.deathY = 0; // 死亡动画圆心(掉坑死亡时 clamp 到关卡内)
|
||||||
this.inWater = false;
|
this.inWater = false;
|
||||||
this.animT = 0;
|
this.animT = 0;
|
||||||
this.shootCool = 0;
|
this.shootCool = 0;
|
||||||
@@ -67,15 +68,36 @@ export class Player {
|
|||||||
get cx() { return this.x + this.w / 2; }
|
get cx() { return this.x + this.w / 2; }
|
||||||
get cy() { return this.y + this.h / 2; }
|
get cy() { return this.y + this.h / 2; }
|
||||||
|
|
||||||
// 死亡:不播任何死亡动画(无等待、无镜头演出),只留音效、死亡计数,
|
// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环(Level 层,见 addDeathFX)。
|
||||||
// 以及在死亡地点落一处 IWBTG 风格的血迹(由 Level 常驻渲染,复活后仍在)。
|
// 主动按重生键时直接调它再当帧 respawn——特效留在死亡位置自然播完。
|
||||||
kill() {
|
// 圆心 clamp 到关卡内:掉坑死亡时玩家已在屏幕外,保证红叉/粒子/圆环可见
|
||||||
|
deathBurst() {
|
||||||
|
const L = this.level;
|
||||||
|
const dx = clamp(this.cx, 10, Math.max(10, L.pxW - 10));
|
||||||
|
const dy = clamp(this.cy, 10, Math.max(10, L.pxH - 10));
|
||||||
|
sfx.death();
|
||||||
|
L.particles.spawn(dx, dy, 16, {
|
||||||
|
colors: ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16,
|
||||||
|
});
|
||||||
|
L.shake = Math.max(L.shake || 0, 6);
|
||||||
|
L.addDeathFX(dx, dy);
|
||||||
|
return { dx, dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 死亡:定格在原地,等玩家手动按重生键(R / 触屏重生键)。
|
||||||
|
// 视觉:① 死亡地点留下常驻红叉(IWBTG 血迹,复活后仍在;opts.mark=false 时不留)
|
||||||
|
// ② deathBurst() 的"小"死亡动画(粒子+轻震+冲击环)
|
||||||
|
// opts:
|
||||||
|
// mark=false → 不留红叉
|
||||||
|
// count=false → 不触发 onDeath 计数(不计入死亡数)
|
||||||
|
kill(opts = {}) {
|
||||||
if (this.dead || !this.alive) return;
|
if (this.dead || !this.alive) return;
|
||||||
this.dead = true; this.alive = false; this.deadT = 0;
|
this.dead = true; this.alive = false; this.deadT = 0;
|
||||||
this.vx = 0; this.vy = 0; this.sliding = false;
|
this.vx = 0; this.vy = 0; this.sliding = false;
|
||||||
sfx.death();
|
const { dx, dy } = this.deathBurst();
|
||||||
this.level.addDeathMark(this.cx, this.cy);
|
this.deathX = dx; this.deathY = dy;
|
||||||
if (this.onDeath) this.onDeath();
|
if (opts.mark !== false) this.level.addDeathMark(dx, dy);
|
||||||
|
if (opts.count !== false && this.onDeath) this.onDeath();
|
||||||
}
|
}
|
||||||
|
|
||||||
respawn() {
|
respawn() {
|
||||||
@@ -90,7 +112,7 @@ export class Player {
|
|||||||
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
|
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
|
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
|
||||||
L.softReset();
|
L.softReset(); // 不清粒子/震屏:死亡特效留在死亡位置自然播完
|
||||||
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
|
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +122,9 @@ export class Player {
|
|||||||
this.animT++;
|
this.animT++;
|
||||||
if (this.shootCool > 0) this.shootCool--;
|
if (this.shootCool > 0) this.shootCool--;
|
||||||
|
|
||||||
// 上一帧/外部(R 键自杀、假存档)触发的死亡:立刻复活,不留死亡动画
|
// 死亡:定格在原地等玩家手动重生(R 键 / 触屏重生键),
|
||||||
if (this.dead) this.respawn();
|
// 世界继续运转(敌人/子弹/粒子照走),但玩家不响应任何输入
|
||||||
|
if (this.dead) { this.deadT++; return; }
|
||||||
|
|
||||||
const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4);
|
const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4);
|
||||||
if (inWater && !this.inWater) sfx.splash();
|
if (inWater && !this.inWater) sfx.splash();
|
||||||
@@ -252,11 +275,11 @@ export class Player {
|
|||||||
|
|
||||||
// ---- 边界 ----
|
// ---- 边界 ----
|
||||||
this.x = clamp(this.x, 0, L.pxW - this.w);
|
this.x = clamp(this.x, 0, L.pxW - this.w);
|
||||||
if (this.y > L.pxH + 200) { this.kill(); if (this.dead) { this.respawn(); return; } }
|
if (this.y > L.pxH + 200) { this.kill(); return; }
|
||||||
if (this.y < -300) { this.y = -300; this.vy = 0; }
|
if (this.y < -300) { this.y = -300; this.vy = 0; }
|
||||||
|
|
||||||
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
|
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
|
||||||
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); if (this.dead) { this.respawn(); return; } }
|
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); return; }
|
||||||
|
|
||||||
// ---- 射击 ----
|
// ---- 射击 ----
|
||||||
if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) {
|
if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) {
|
||||||
@@ -278,8 +301,6 @@ export class Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:本帧内任何环节致死(如假存档)都立刻复活
|
|
||||||
if (this.dead) this.respawn();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shoot() {
|
shoot() {
|
||||||
@@ -314,7 +335,7 @@ export class Player {
|
|||||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
|
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
|
||||||
{ colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 });
|
{ colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 });
|
||||||
// 假存档:立刻复活回上一个存档点,不播死亡动画
|
// 假存档:立刻复活回上一个存档点,不播死亡动画
|
||||||
if (e.fake) { L.showToast('假的。', 90); this.kill(); if (this.dead) { this.respawn(); return; } }
|
if (e.fake) { L.showToast('假的。', 90); this.kill(); return; }
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'goal':
|
case 'goal':
|
||||||
@@ -368,6 +389,8 @@ export class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw(ctx) {
|
draw(ctx) {
|
||||||
|
// 死亡中:kid 已爆开,不画本体(粒子/冲击环由 Level 层渲染,重生后仍能播完)
|
||||||
|
if (this.dead) return;
|
||||||
S.drawKid(ctx, this, this.animT);
|
S.drawKid(ctx, this, this.animT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+199
-62
@@ -1,19 +1,26 @@
|
|||||||
// 回归测试:kid 死亡后必须立刻复活,且不留下任何死亡动画(无粒子、无震屏、无延迟)
|
// 回归测试:死亡流程(2026-10-24 新语义,推翻旧的"即时复活、无动画"需求)
|
||||||
|
// 新需求(用户原话):
|
||||||
|
// ① "当角色死亡后不要立马重生,需要用户手动按重生键重生"
|
||||||
|
// ② "死亡时除了原先的红叉,还要附加一个小的死亡动画"
|
||||||
|
// 本测试覆盖:死亡定格等待手动重生 / 死亡动画要素(粒子+震动+冲击环圆心)/
|
||||||
|
// 手动 respawn 回存档点 / 连续死亡 / 红叉常驻与去重 / 掉坑死亡位置 clamp
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||||
globalThis.addEventListener = addL;
|
globalThis.addEventListener = addL;
|
||||||
globalThis.window = { addEventListener: addL };
|
globalThis.window = { addEventListener: addL };
|
||||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
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 { Level } = await import('../js/level.js');
|
||||||
const { Player } = await import('../js/player.js');
|
const { Player } = await import('../js/player.js');
|
||||||
const { Input } = await import('../js/input.js');
|
const { Input } = await import('../js/input.js');
|
||||||
|
|
||||||
const W = 20, H = 8, TILE = 32;
|
const W = 20, H = 8, TILE = 32;
|
||||||
// row2 放一枚上刺(col 5),row3 是地板,spawn 在 col1
|
// row1 有存档点 'G'(col1);row2 放两枚上刺(col5/col10);row3 实心地板
|
||||||
const grid = [
|
const grid = [
|
||||||
'#'.repeat(W),
|
'#'.repeat(W),
|
||||||
'#' + 'S' + '.'.repeat(W - 3) + '#',
|
'#' + 'G' + '.'.repeat(W - 3) + '#',
|
||||||
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
|
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
|
||||||
'#'.repeat(W),
|
'#'.repeat(W),
|
||||||
'#'.repeat(W),
|
'#'.repeat(W),
|
||||||
@@ -25,111 +32,241 @@ const grid = [
|
|||||||
let fail = 0;
|
let fail = 0;
|
||||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||||
const ok = m => console.log(' ✓ ' + m);
|
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() {
|
function fresh() {
|
||||||
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
|
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
|
||||||
const P = new Player(L);
|
const P = new Player(L);
|
||||||
let deaths = 0;
|
let deaths = 0;
|
||||||
P.onDeath = () => deaths++;
|
P.onDeath = () => deaths++;
|
||||||
for (let i = 0; i < 12; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
step(L, P, 12);
|
||||||
return { L, P, deaths: () => deaths };
|
return { L, P, deaths: () => deaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 踩到刺:同一帧内就必须已经复活(不是等 34 帧)
|
// 1) 踩刺:当帧进入死亡状态并定格(不自动复活),动画要素齐全
|
||||||
{
|
{
|
||||||
const { L, P, deaths } = fresh();
|
const { L, P, deaths } = fresh();
|
||||||
const sx = L.spawn.x;
|
const sx = L.spawn.x;
|
||||||
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
|
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
|
||||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||||
if (!P.alive || P.dead) err('踩刺后本帧仍处于死亡状态(复活有延迟)');
|
if (P.alive || !P.dead) err('踩刺后没有进入死亡定格状态');
|
||||||
else ok('踩刺后同帧即复活,无死亡等待');
|
else ok('踩刺后当帧死亡并定格(等待手动重生)');
|
||||||
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
|
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
|
||||||
else ok('死亡计数 = 1(正常累加)');
|
else ok('死亡计数 = 1(正常累加)');
|
||||||
if (Math.abs(P.x - sx) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${sx}`);
|
if (P.vx !== 0 || P.vy !== 0) err('死亡时速度未清零');
|
||||||
else ok('复活位置 = 最近存档点/出生点');
|
if (!(L.particles.list.length > 0)) err('死亡没有触发粒子爆发(小死亡动画缺失)');
|
||||||
if (L.particles.list.length) err(`死亡仍产生粒子(${L.particles.list.length} 个)`);
|
else ok(`死亡动画:粒子爆发 ${L.particles.list.length} 个`);
|
||||||
else ok('无屏幕级死亡粒子(血迹走常驻层,非瞬态粒子)');
|
if (!(L.shake > 0)) err('死亡没有触发屏幕轻震');
|
||||||
if (L.shake > 0) err(`死亡仍触发震屏 shake=${L.shake}`);
|
else ok(`死亡动画:屏幕轻震 shake=${L.shake.toFixed(1)}`);
|
||||||
else ok('无死亡震屏');
|
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 血迹效果');
|
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
|
||||||
else {
|
else {
|
||||||
const dm = L.deathMarks[0];
|
const dm = L.deathMarks[0];
|
||||||
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
|
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)}) 期望约 (${ex},${ey})`);
|
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)');
|
else ok('在死亡地点留下红色死亡叉(X)');
|
||||||
}
|
}
|
||||||
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
|
if (P.x !== 5 * TILE) err('死亡后位置被改动(应定格在原处)');
|
||||||
else ok('justRespawned 已置位(镜头瞬移)');
|
else ok('死亡后定格在死亡位置');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) R 键自杀路径:kill() 后下一帧必须复活
|
// 2) 死亡后 60 帧:仍然 dead、不自动重生、输入完全无效
|
||||||
{
|
{
|
||||||
const { L, P, deaths } = fresh();
|
const { L, P, deaths } = fresh();
|
||||||
P.kill();
|
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||||
if (deaths() !== 1) err('kill() 未触发死亡回调');
|
|
||||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||||
if (!P.alive || P.dead) err('自杀后未立刻复活');
|
|
||||||
else ok('自杀(R 键)后下一帧即复活');
|
|
||||||
// 立刻复活后应能马上操作:按住右键能走
|
|
||||||
const x0 = P.x;
|
const x0 = P.x;
|
||||||
key('keydown', 'ArrowRight');
|
down('ArrowRight'); down('ShiftLeft');
|
||||||
for (let i = 0; i < 6; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
step(L, P, 60);
|
||||||
if (P.x <= x0) err('复活后当帧无法操作');
|
up('ArrowRight'); up('ShiftLeft');
|
||||||
else ok(`复活后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
|
if (P.alive) err('死亡 60 帧后自动复活了(违反"手动重生"需求)');
|
||||||
key('keyup', 'ArrowRight');
|
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) 连续死亡:不能卡死在死亡状态
|
// 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();
|
const { L, P, deaths } = fresh();
|
||||||
for (let k = 0; k < 5; k++) {
|
for (let k = 0; k < 5; k++) {
|
||||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||||
if (!P.alive) { err(`第 ${k + 1} 次死亡后卡在死亡状态`); break; }
|
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()}`);
|
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
|
||||||
else ok('连续 5 次死亡均即时复活,计数 = 5');
|
else ok('连续 5 次死亡均等待手动重生,计数 = 5');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 死亡后不应残留任何死亡视觉:跑 30 帧确认没有死亡粒子产生
|
// 5) 红叉常驻保留;整关重开清空
|
||||||
{
|
|
||||||
const { L, P } = fresh();
|
|
||||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
|
||||||
let maxParticles = 0;
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
|
||||||
maxParticles = Math.max(maxParticles, L.particles.list.length);
|
|
||||||
}
|
|
||||||
if (maxParticles > 0) err(`死亡后仍出现全局粒子(峰值 ${maxParticles})`);
|
|
||||||
else ok('30 帧内无屏幕级死亡粒子(血迹由常驻层渲染)');
|
|
||||||
if (!L.deathMarks.length) err('死亡血迹未常驻保留');
|
|
||||||
else ok(`死亡红叉常驻保留(共 ${L.deathMarks.length} 处,30 帧后仍在)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) 多次死亡:只显示上一次死亡的血迹(不累积)
|
|
||||||
{
|
|
||||||
const { L, P } = fresh();
|
|
||||||
for (let k = 0; k < 2; k++) { P.x = 5 * TILE; P.y = 2 * TILE + 11; L.update(1, P, null); P.update(1); Input.endFrame(); }
|
|
||||||
for (let k = 0; k < 2; k++) { 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];
|
|
||||||
const ex = 10 * TILE + 5.5, ey = 2 * TILE + 21.5;
|
|
||||||
if (Math.abs(dm.x - ex) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
|
|
||||||
else ok('多次死亡只保留上一次死亡地点的血迹');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) 整关重开:血迹清空
|
|
||||||
{
|
{
|
||||||
const { L, P } = fresh();
|
const { L, P } = fresh();
|
||||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||||
if (!L.deathMarks.length) err('死亡未生成血迹');
|
P.respawn();
|
||||||
|
step(L, P, 30);
|
||||||
|
if (!L.deathMarks.length) err('重生后红叉被清掉了(应常驻保留)');
|
||||||
|
else ok(`死亡红叉常驻保留(${L.deathMarks.length} 处,重生 30 帧后仍在)`);
|
||||||
L.reset(true);
|
L.reset(true);
|
||||||
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
|
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
|
||||||
else ok('整关重开(reset(true))后血迹清空');
|
else ok('整关重开(reset(true))后血迹清空');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡即时复活,全部通过');
|
// 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);
|
process.exit(fail ? 1 : 0);
|
||||||
+27
-7
@@ -1,4 +1,4 @@
|
|||||||
// 回归测试:R 键必须"自杀回存档点",不能把整关重开(否则存档点白碰)
|
// 回归测试:R 键必须"重生回存档点",不能把整关重开(否则存档点白碰)
|
||||||
const noop = () => { };
|
const noop = () => { };
|
||||||
const els = new Map();
|
const els = new Map();
|
||||||
function mkEl(id = '') {
|
function mkEl(id = '') {
|
||||||
@@ -73,19 +73,39 @@ const cp = P.savePoint;
|
|||||||
console.log(` 存档点激活=${!!cp} 位置=(${cp?.x.toFixed(0)},${cp?.y.toFixed(0)}) 关卡出生点x=${L0.spawn.x.toFixed(0)}`);
|
console.log(` 存档点激活=${!!cp} 位置=(${cp?.x.toFixed(0)},${cp?.y.toFixed(0)}) 关卡出生点x=${L0.spawn.x.toFixed(0)}`);
|
||||||
if (!cp) err('存档点未激活');
|
if (!cp) err('存档点未激活');
|
||||||
|
|
||||||
// ---- 按 R ----
|
// ---- 按 R(活着)= 原地播死亡动画后当帧重生回存档点(不计死亡、不留红叉、不等动画)----
|
||||||
const deaths0 = api.G.deaths;
|
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');
|
key('keydown', 'KeyR');
|
||||||
step(2);
|
step(2);
|
||||||
key('keyup', 'KeyR');
|
key('keyup', 'KeyR');
|
||||||
step(60);
|
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;
|
const survivedLevel = api.G.level === L0;
|
||||||
console.log(` 按 R 后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} 死亡=${deaths0}→${api.G.deaths}`);
|
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 (!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.player.dead || !api.G.player.alive) err('死亡中按 R 未手动重生');
|
||||||
if (api.G.deaths !== deaths0 + 1) 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('R 键后被送回了关卡起点');
|
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) err('重生后被送回了关卡起点');
|
||||||
|
if (!(L0.particles.list.length > 0)) err('死亡中按 R 重生把特效清掉了(应留在死亡位置播完)');
|
||||||
|
|
||||||
// ---- 按 Backspace = 整关重来 ----
|
// ---- 按 Backspace = 整关重来 ----
|
||||||
key('keydown', 'Backspace');
|
key('keydown', 'Backspace');
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,8 @@ for (const d of LEVELS) {
|
|||||||
const last = saves[saves.length - 1];
|
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 want = { x: last.x + last.w / 2 - P.w / 2, y: last.y + last.h - P.h };
|
||||||
const spawn = L.spawn;
|
const spawn = L.spawn;
|
||||||
P.kill();
|
P.kill(); // 死亡后不再自动重生(新语义:定格等待手动重生)
|
||||||
|
P.respawn(); // 模拟玩家按 R 手动重生,应回到最后一个存档点
|
||||||
for (let i = 0; i < 45; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
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 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 backToStart = Math.abs(P.x - spawn.x) < 3 && Math.abs(P.y - spawn.y) < 3;
|
||||||
|
|||||||
+8
-3
@@ -51,7 +51,7 @@ for (const d of LEVELS) {
|
|||||||
}
|
}
|
||||||
function G_DEATHS() { return ''; }
|
function G_DEATHS() { return ''; }
|
||||||
|
|
||||||
console.log('\n=== 死亡与重生 ===');
|
console.log('\n=== 死亡与手动重生 ===');
|
||||||
{
|
{
|
||||||
const L = new Level(LEVELS.find(l => l.id === 'j1'));
|
const L = new Level(LEVELS.find(l => l.id === 'j1'));
|
||||||
const P = new Player(L);
|
const P = new Player(L);
|
||||||
@@ -59,9 +59,14 @@ console.log('\n=== 死亡与重生 ===');
|
|||||||
P.onDeath = () => deaths++;
|
P.onDeath = () => deaths++;
|
||||||
for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||||
P.kill();
|
P.kill();
|
||||||
|
// 新语义(2026-10-24):死亡后不自动重生,定格等待玩家手动按重生键
|
||||||
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
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 (!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('死亡回调未触发');
|
if (deaths !== 1) err('死亡回调未触发');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user