fea: 优化死亡特效

This commit is contained in:
cancerai committed 2026-10-24 02:35:03 +08:00
1 parent afe2f64b9b
commit e7200989ab
9 files changed
+373 -94

No files matched your search

+38 -4
View File
@@ -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();
}
+21 -3
View File
@@ -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);
}
+37 -14
View File
@@ -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);
}
}