diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md index 592a465..cb9aff4 100644 --- a/.workbuddy/memory/2026-10-24.md +++ b/.workbuddy/memory/2026-10-24.md @@ -71,3 +71,36 @@ - 用户最初提的"下落惯性叠加"经三次实践证明给不出好手感,放弃;参数保留 DJ_INERTIA(现值 0)供回退。 - 回归:dj-test/smoke/ice/spike/save/key 全过;未跑 solve(用户禁令)。 - 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 段加"残余粒子"断言;全套回归过。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index aeea429..0fa1b4c 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -9,6 +9,14 @@ - 刺碰撞是**三角×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 画)。用户明确要求跳跃/落地都不要身体晃动。 +## 死亡与重生流程(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` 走惯性分支,撞墙/归零/交还控制时才清位。 - **空中与地面必须分开摩擦**:`SLIDE_AIR 0.02`(几乎无损,能滑出冰块边缘飞出去)vs `SLIDE_FRIC 0.12`(地面约 34 帧/70px 收住)。曾经两者都用 0.25,导致「滑出冰块掉下去 7 帧就没速度」——玩家最直观的「没惯性」就来自这里。 diff --git a/js/level.js b/js/level.js index 5ff490d..c7e0af2 100644 --- a/js/level.js +++ b/js/level.js @@ -87,7 +87,7 @@ export class Level { /** 完整重置(死亡 / 重开) */ reset(full = false) { // tiles 状态(vanish 等) - if (full) this.deathMarks = []; // 死亡血迹只在整关重开时清空,复活后保留 + if (full) { this.deathMarks = []; this.deathFX = []; } // 血迹/死亡特效只在整关重开时清空,复活后保留 this.vanish = new Map(); this.bullets = []; this.ebullets = []; @@ -215,14 +215,15 @@ export class Level { this.totalSaves = saveNo; } - /** 死亡后的软重置:保留存档点与已拾取道具,其余复位 */ + /** 死亡后的软重置:保留存档点与已拾取道具,其余复位。 + * 粒子与震屏是"演出层":不清场——无论主动按 R 还是被动死亡后秒按 R, + * 死亡特效都留在死亡位置自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减), + * 重生只重置关卡逻辑状态,不掐断演出。整关重开(reset(true))才会清空 */ softReset() { this.vanish.clear(); if (this.vanishT) this.vanishT.clear(); this.bullets.length = 0; this.ebullets.length = 0; - this.particles?.clear(); - this.shake = 0; for (const m of this.movers) { const a = m.path[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 }); } + // 死亡特效(扩散冲击环):记录到关卡层而非玩家——主动重生时玩家当帧就复活了, + // 环要留在死亡位置播完。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) { const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px) ctx.lineCap = 'round'; @@ -370,6 +396,11 @@ export class Level { this.time += dt; 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) { if (!m.path.length) continue; @@ -623,6 +654,9 @@ export class Level { // 死亡血迹(IWBTG 风格,常驻于死亡地点) 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); ctx.restore(); } diff --git a/js/main.js b/js/main.js index fe4e378..ec30d7d 100644 --- a/js/main.js +++ b/js/main.js @@ -136,10 +136,13 @@ function restart() { G.deaths = d; // 死亡数不重置 } -/** 自杀(IWBT 的 R 键):死后自动回到最近的存档点 */ +/** R 键 / 触屏重生键:直接重生回最近的存档点——死亡中按 = 立即复活;活着按 = 原地播死亡动画后当帧重生 */ function suicide() { 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() { const P = G.player; - // R = 自杀回存档点;Backspace = 整关重开 + // R = 重生回存档点;Backspace = 整关重开 if (Input.hit('suicide')) { suicide(); return; } if (Input.hit('restart')) { restart(); return; } if (Input.hit('pause')) { pause(); return; } @@ -240,6 +243,21 @@ function render() { ctx.restore(); L.drawOverlay(ctx, C, t); 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 { drawTitleBg(t); } diff --git a/js/player.js b/js/player.js index 0d23951..a64112b 100644 --- a/js/player.js +++ b/js/player.js @@ -50,6 +50,7 @@ export class Player { this.alive = true; this.dead = false; this.deadT = 0; + this.deathX = 0; this.deathY = 0; // 死亡动画圆心(掉坑死亡时 clamp 到关卡内) this.inWater = false; this.animT = 0; this.shootCool = 0; @@ -67,15 +68,36 @@ export class Player { get cx() { return this.x + this.w / 2; } get cy() { return this.y + this.h / 2; } - // 死亡:不播任何死亡动画(无等待、无镜头演出),只留音效、死亡计数, - // 以及在死亡地点落一处 IWBTG 风格的血迹(由 Level 常驻渲染,复活后仍在)。 - kill() { + // 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环(Level 层,见 addDeathFX)。 + // 主动按重生键时直接调它再当帧 respawn——特效留在死亡位置自然播完。 + // 圆心 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; this.dead = true; this.alive = false; this.deadT = 0; this.vx = 0; this.vy = 0; this.sliding = false; - sfx.death(); - this.level.addDeathMark(this.cx, this.cy); - if (this.onDeath) this.onDeath(); + const { dx, dy } = this.deathBurst(); + this.deathX = dx; this.deathY = dy; + if (opts.mark !== false) this.level.addDeathMark(dx, dy); + if (opts.count !== false && this.onDeath) this.onDeath(); } respawn() { @@ -90,7 +112,7 @@ export class Player { // 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死 let guard = 0; 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 把镜头直接吸附到重生点 } @@ -100,8 +122,9 @@ export class Player { this.animT++; if (this.shootCool > 0) this.shootCool--; - // 上一帧/外部(R 键自杀、假存档)触发的死亡:立刻复活,不留死亡动画 - if (this.dead) this.respawn(); + // 死亡:定格在原地等玩家手动重生(R 键 / 触屏重生键), + // 世界继续运转(敌人/子弹/粒子照走),但玩家不响应任何输入 + if (this.dead) { this.deadT++; return; } const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4); if (inWater && !this.inWater) sfx.splash(); @@ -252,11 +275,11 @@ export class Player { // ---- 边界 ---- 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 (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) { @@ -278,8 +301,6 @@ export class Player { } } - // 兜底:本帧内任何环节致死(如假存档)都立刻复活 - if (this.dead) this.respawn(); } shoot() { @@ -314,7 +335,7 @@ export class Player { L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18, { 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; case 'goal': @@ -368,6 +389,8 @@ export class Player { } draw(ctx) { + // 死亡中:kid 已爆开,不画本体(粒子/冲击环由 Level 层渲染,重生后仍能播完) + if (this.dead) return; S.drawKid(ctx, this, this.animT); } } diff --git a/tools/death-test.mjs b/tools/death-test.mjs index 387cd4e..2422754 100644 --- a/tools/death-test.mjs +++ b/tools/death-test.mjs @@ -1,19 +1,26 @@ -// 回归测试:kid 死亡后必须立刻复活,且不留下任何死亡动画(无粒子、无震屏、无延迟) +// 回归测试:死亡流程(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; -// row2 放一枚上刺(col 5),row3 是地板,spawn 在 col1 +// row1 有存档点 'G'(col1);row2 放两枚上刺(col5/col10);row3 实心地板 const grid = [ '#'.repeat(W), - '#' + 'S' + '.'.repeat(W - 3) + '#', + '#' + 'G' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#', '#'.repeat(W), '#'.repeat(W), @@ -25,111 +32,241 @@ const grid = [ 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++; - 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 }; } -// 1) 踩到刺:同一帧内就必须已经复活(不是等 34 帧) +// 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 (P.alive || !P.dead) err('踩刺后没有进入死亡定格状态'); + else ok('踩刺后当帧死亡并定格(等待手动重生)'); if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`); else ok('死亡计数 = 1(正常累加)'); - if (Math.abs(P.x - sx) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${sx}`); - else ok('复活位置 = 最近存档点/出生点'); - if (L.particles.list.length) err(`死亡仍产生粒子(${L.particles.list.length} 个)`); - else ok('无屏幕级死亡粒子(血迹走常驻层,非瞬态粒子)'); - if (L.shake > 0) err(`死亡仍触发震屏 shake=${L.shake}`); - else ok('无死亡震屏'); + 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)}) 期望约 (${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)'); } - if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去'); - else ok('justRespawned 已置位(镜头瞬移)'); + if (P.x !== 5 * TILE) err('死亡后位置被改动(应定格在原处)'); + else ok('死亡后定格在死亡位置'); } -// 2) R 键自杀路径:kill() 后下一帧必须复活 +// 2) 死亡后 60 帧:仍然 dead、不自动重生、输入完全无效 { const { L, P, deaths } = fresh(); - P.kill(); - if (deaths() !== 1) err('kill() 未触发死亡回调'); + 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('自杀(R 键)后下一帧即复活'); - // 立刻复活后应能马上操作:按住右键能走 const x0 = P.x; - key('keydown', 'ArrowRight'); - for (let i = 0; i < 6; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); } - if (P.x <= x0) err('复活后当帧无法操作'); - else ok(`复活后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`); - key('keyup', 'ArrowRight'); + 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) 连续死亡:不能卡死在死亡状态 +// 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.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()}`); - else ok('连续 5 次死亡均即时复活,计数 = 5'); + else ok('连续 5 次死亡均等待手动重生,计数 = 5'); } -// 4) 死亡后不应残留任何死亡视觉:跑 30 帧确认没有死亡粒子产生 -{ - 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) 整关重开:血迹清空 +// 5) 红叉常驻保留;整关重开清空 { const { L, P } = fresh(); P.x = 5 * TILE; P.y = 2 * TILE + 11; 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); if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`); 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); diff --git a/tools/key-test.mjs b/tools/key-test.mjs index 4dc5566..3d56270 100644 --- a/tools/key-test.mjs +++ b/tools/key-test.mjs @@ -1,4 +1,4 @@ -// 回归测试:R 键必须"自杀回存档点",不能把整关重开(否则存档点白碰) +// 回归测试:R 键必须"重生回存档点",不能把整关重开(否则存档点白碰) const noop = () => { }; const els = new Map(); 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)}`); if (!cp) err('存档点未激活'); -// ---- 按 R ---- +// ---- 按 R(活着)= 原地播死亡动画后当帧重生回存档点(不计死亡、不留红叉、不等动画)---- 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'); step(2); 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; -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 (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 键后被送回了关卡起点'); +if (api.G.player.dead || !api.G.player.alive) 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('重生后被送回了关卡起点'); +if (!(L0.particles.list.length > 0)) err('死亡中按 R 重生把特效清掉了(应留在死亡位置播完)'); // ---- 按 Backspace = 整关重来 ---- key('keydown', 'Backspace'); diff --git a/tools/save-test.mjs b/tools/save-test.mjs index f51a692..08b71d6 100644 --- a/tools/save-test.mjs +++ b/tools/save-test.mjs @@ -37,7 +37,8 @@ for (const d of LEVELS) { 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(); + P.kill(); // 死亡后不再自动重生(新语义:定格等待手动重生) + P.respawn(); // 模拟玩家按 R 手动重生,应回到最后一个存档点 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; diff --git a/tools/smoke.mjs b/tools/smoke.mjs index b8d577b..39aecd9 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -51,7 +51,7 @@ for (const d of LEVELS) { } function G_DEATHS() { return ''; } -console.log('\n=== 死亡与重生 ==='); +console.log('\n=== 死亡与手动重生 ==='); { const L = new Level(LEVELS.find(l => l.id === 'j1')); const P = new Player(L); @@ -59,9 +59,14 @@ console.log('\n=== 死亡与重生 ==='); P.onDeath = () => deaths++; for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); } P.kill(); + // 新语义(2026-10-24):死亡后不自动重生,定格等待玩家手动按重生键 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('死亡回调未触发'); }