优化聊天室烟花特效表现与卡顿问题

This commit is contained in:
2026-04-21 17:13:14 +08:00
parent 590b7d5b35
commit c209221bad
3 changed files with 397 additions and 142 deletions
+2 -1
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室全屏特效广播事件
*
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者target_username null 则全员播放)
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见
*
* @author ChatRoom Laravel
*
@@ -59,6 +59,7 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 广播数据:特效类型、操作者、目标用户、赠言
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
*
* @return array<string, mixed>
*/
+345 -141
View File
@@ -18,12 +18,13 @@ const FireworksEffect = (() => {
* @param {string} color 爆炸颜色
* @param {string} type 爆炸类型:sphere / willow / ring
*/
constructor(x, targetY, color, type, canvasHeight) {
constructor(x, targetY, color, type, canvasHeight, drift = 0) {
this.x = x;
this.y = canvasHeight; // 从画布底部出发
this.targetY = targetY;
this.color = color;
this.type = type;
this.vx = drift;
// 根据飞行距离动态计算初始速度,保证必然到达目标高度
// 等比级数求和:total = vy / (1 - 0.98) = vy × 50
@@ -39,8 +40,13 @@ const FireworksEffect = (() => {
/** 更新火箭位置,到达目标高度后标记为已爆炸 */
update() {
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > 10) this.trail.shift();
if (this.trail.length > 12) {
this.trail.shift();
}
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.992;
this.vy *= 0.98; // 轻微减速(仿真阻力)
if (this.y <= this.targetY) {
this.exploded = true;
@@ -84,44 +90,48 @@ const FireworksEffect = (() => {
* @param {string} type 爆炸类型
* @param {number} angle 发射角度(ring 类型用)
*/
constructor(x, y, color, type, angle) {
constructor(x, y, color, type, angle, options = {}) {
this.x = x;
this.y = y;
this.color = color;
this.trail = [];
this.innerColor = options.innerColor ?? "#ffffff";
this.trailLimit = options.trailLimit ?? 8;
this.drag = options.drag ?? 0.985;
this.radiusScale = options.radiusScale ?? 1;
let speed;
if (type === "ring") {
// 环形:均匀角度,固定速度
speed = 5 + Math.random() * 2;
speed = 5.8 + Math.random() * 2.2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.gravity = 0.06;
this.decay = 0.014;
this.radius = 2;
this.decay = 0.012;
this.radius = 2.2 * this.radiusScale;
} else if (type === "willow") {
// 柳叶:慢速,在空中下垂
speed = Math.random() * 3 + 1;
speed = Math.random() * 3.8 + 1.4;
const a = Math.random() * Math.PI * 2;
this.vx = Math.cos(a) * speed;
this.vy = Math.sin(a) * speed - 2; // 初速稍微向上
this.gravity = 0.07;
this.decay = 0.009; // 衰减慢,拖出长尾
this.radius = 1.5;
this.vy = Math.sin(a) * speed - 2.4; // 初速稍微向上
this.gravity = 0.072;
this.decay = 0.0075; // 衰减慢,拖出长尾
this.radius = 1.7 * this.radiusScale;
} else {
// sphere:标准球形爆炸
speed = Math.random() * 6 + 2;
speed = Math.random() * 6.8 + 2.4;
const a = Math.random() * Math.PI * 2;
this.vx = Math.cos(a) * speed;
this.vy = Math.sin(a) * speed;
this.gravity = 0.1;
this.decay = 0.016;
this.radius = Math.random() * 2 + 1.5;
this.gravity = 0.095;
this.decay = 0.0135;
this.radius = (Math.random() * 2.2 + 1.7) * this.radiusScale;
}
this.alpha = 1;
// 部分粒子有闪烁效果
this.sparkle = Math.random() > 0.6;
this.sparkle = Math.random() > 0.45;
this.frame = 0;
}
@@ -130,13 +140,15 @@ const FireworksEffect = (() => {
this.frame++;
// 保存轨迹历史(尾迹长度由透明度控制)
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > 6) this.trail.shift();
if (this.trail.length > this.trailLimit) {
this.trail.shift();
}
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.985;
this.vy *= 0.985;
this.vx *= this.drag;
this.vy *= this.drag;
// 闪烁:每隔几帧透明度轻微抖动
if (this.sparkle && this.frame % 4 === 0) {
@@ -177,6 +189,13 @@ const FireworksEffect = (() => {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
// 用白色内核强化“炸点”质感,避免颜色过闷。
ctx.fillStyle = this.innerColor;
ctx.globalAlpha = Math.max(0, this.alpha * 0.35);
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius * 0.4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
@@ -185,6 +204,49 @@ const FireworksEffect = (() => {
}
}
// ─── 爆炸光晕类 ──────────────────────────────────────
class Halo {
/**
* @param {number} x 爆炸中心 x
* @param {number} y 爆炸中心 y
* @param {string} color 爆炸主色
* @param {number} radius 最大光晕半径
*/
constructor(x, y, color, radius) {
this.x = x;
this.y = y;
this.color = color;
this.radius = radius * 0.32;
this.maxRadius = radius;
this.alpha = 0.34;
}
/** 更新光晕扩散与淡出 */
update() {
this.radius += (this.maxRadius - this.radius) * 0.16 + 1.8;
this.alpha *= 0.88;
}
/** 绘制爆炸余辉,让烟花更有层次和氛围 */
draw(ctx) {
// 使用发光圆替代每帧渐变重建,尽量保留余辉质感同时降低爆炸高峰的绘制成本。
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = this.alpha;
ctx.fillStyle = this.color;
ctx.shadowColor = this.color;
ctx.shadowBlur = this.radius * 0.45;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius * 0.62, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
get alive() {
return this.alpha > 0.02;
}
}
// ─── 预定义颜色 / 类型 ───────────────────────────────
const COLORS = [
"#ff2200",
@@ -198,6 +260,34 @@ const FireworksEffect = (() => {
"#ffaa00",
];
const TYPES = ["sphere", "willow", "ring"];
const FLASH_COLORS = ["#ffffff", "#ffe3a3", "#ffd4f0", "#cfe9ff"];
/**
* 随机取数组中的一个元素
*
* @param {Array} items 候选数组
* @returns {*}
*/
function _pick(items) {
return items[Math.floor(Math.random() * items.length)];
}
/**
* 为画布做渐隐,而不是硬清屏。
*
* 这里使用 destination-out 只擦除旧像素,不会给聊天室背景额外盖一层黑幕。
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} w
* @param {number} h
*/
function _fadeFrame(ctx, w, h) {
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, 0.24)";
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
/**
* 生成一批爆炸粒子
@@ -206,11 +296,14 @@ const FireworksEffect = (() => {
* @param {number} y 爆炸中心 y
* @param {string} color 颜色
* @param {string} type 爆炸类型
* @param {number} density 粒子密度倍率
* @returns {Particle[]}
*/
function _burst(x, y, color, type) {
function _burst(x, y, color, type, density = 1) {
const particles = [];
const count = type === "ring" ? 80 : type === "willow" ? 120 : 100;
const baseCount = type === "ring" ? 120 : type === "willow" ? 170 : 145;
const count = Math.round(baseCount * density);
const accentColor = _pick(FLASH_COLORS);
if (type === "ring") {
// 环形:均匀分布
@@ -222,72 +315,191 @@ const FireworksEffect = (() => {
for (let i = 0; i < count; i++) {
particles.push(new Particle(x, y, color, type, 0));
}
// willow/sphere 中心加一颗白色闪光核心粒子
for (let i = 0; i < 10; i++) {
const p = new Particle(x, y, "#ffffff", "sphere", 0);
p.decay *= 2;
p.radius = 1;
// 核心补一圈亮色星火,让爆点更饱满。
for (let i = 0; i < 18; i++) {
const p = new Particle(x, y, accentColor, "sphere", 0, {
trailLimit: 4,
drag: 0.978,
radiusScale: 0.7,
});
p.decay *= 1.6;
p.radius *= 0.7;
particles.push(p);
}
}
// 再补一层高亮碎火,提升烟花“炸开”的亮度与体积感。
const sparkleCount = Math.max(10, Math.round(count * 0.12));
for (let i = 0; i < sparkleCount; i++) {
const sparkle = new Particle(x, y, accentColor, "sphere", 0, {
trailLimit: 3,
drag: 0.972,
radiusScale: 0.58,
innerColor: "#ffffff",
});
sparkle.decay *= 1.9;
sparkle.gravity *= 0.8;
particles.push(sparkle);
}
return particles;
}
/**
* 启动烟花特效(普通版)
* 批量把新增粒子追加到现有数组,避免频繁 concat 产生新数组。
*
* @param {HTMLCanvasElement} canvas 全屏 Canvas
* @param {Function} onEnd 特效结束回调
* @param {Particle[]} target
* @param {Particle[]} incoming
*/
function start(canvas, onEnd) {
function _appendParticles(target, incoming) {
for (let i = 0; i < incoming.length; i++) {
target.push(incoming[i]);
}
}
/**
* 统一发射一枚火箭。
*
* @param {Rocket[]} rockets
* @param {number} w
* @param {number} h
* @param {string[]} colors
* @param {Function} getX
* @param {Function} getTargetY
*/
function _launchRocket(rockets, w, h, colors, getX, getTargetY) {
const x = getX(w);
const ty = getTargetY(h);
const color = _pick(colors);
const type = _pick(TYPES);
const drift = (Math.random() - 0.5) * 0.9;
rockets.push(new Rocket(x, ty, color, type, h, drift));
}
/**
* 通用烟花演出引擎。
*
* @param {HTMLCanvasElement} canvas
* @param {Function} onEnd
* @param {object} config
*/
function _runShow(canvas, onEnd, config) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const DURATION = 10000;
const duration = config.duration;
const hardStopAt = duration + 2600;
const peakParticleBudget = config.peakParticleBudget ?? 1650;
let rockets = [];
let particles = [];
let halos = [];
let scheduledBursts = [];
let animId = null;
let launchCnt = 0;
const MAX_LAUNCHES = 12;
const launchInterval = setInterval(() => {
if (launchCnt >= MAX_LAUNCHES) {
if (launchCnt >= config.maxLaunches) {
clearInterval(launchInterval);
return;
}
const x = w * (0.15 + Math.random() * 0.7);
const ty = h * (0.08 + Math.random() * 0.45);
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const type = TYPES[Math.floor(Math.random() * TYPES.length)];
rockets.push(new Rocket(x, ty, color, type, h));
launchCnt++;
}, 600);
const batchSize = config.getBatchSize(launchCnt);
for (let i = 0; i < batchSize && launchCnt < config.maxLaunches; i++) {
_launchRocket(
rockets,
w,
h,
config.colors,
config.getLaunchX,
config.getTargetY,
);
launchCnt++;
}
}, config.launchEvery);
// 开场礼炮先把气氛撑起来,避免一开始太空。
if (typeof config.openingVolley === "function") {
setTimeout(() => {
config.openingVolley(rockets, w, h);
}, 120);
}
const startTime = performance.now();
function animate(now) {
ctx.clearRect(0, 0, w, h);
_fadeFrame(ctx, w, h);
for (let i = rockets.length - 1; i >= 0; i--) {
const r = rockets[i];
if (r.done) {
const burst = _burst(r.x, r.y, r.color, r.type);
particles = particles.concat(burst);
rockets.splice(i, 1);
} else {
r.update();
r.draw(ctx);
halos = halos.filter((halo) => halo.alive);
halos.forEach((halo) => {
halo.update();
halo.draw(ctx);
});
for (let i = scheduledBursts.length - 1; i >= 0; i--) {
if (scheduledBursts[i].triggerAt <= now) {
const burst = scheduledBursts[i];
_appendParticles(
particles,
_burst(burst.x, burst.y, burst.color, burst.type, burst.density),
);
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
scheduledBursts.splice(i, 1);
}
}
particles = particles.filter((p) => p.alive);
particles.forEach((p) => {
p.update();
p.draw(ctx);
for (let i = rockets.length - 1; i >= 0; i--) {
const rocket = rockets[i];
if (rocket.done) {
_appendParticles(
particles,
_burst(
rocket.x,
rocket.y,
rocket.color,
rocket.type,
config.particleDensity,
),
);
halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius));
// 粒子高峰时先保主爆炸观感,压掉一部分二次爆裂来避免卡顿。
if (
particles.length < peakParticleBudget
&& Math.random() < config.secondaryBurstChance
) {
scheduledBursts.push({
triggerAt: now + 90 + Math.random() * 140,
x: rocket.x + (Math.random() - 0.5) * 34,
y: rocket.y + (Math.random() - 0.5) * 26,
color: _pick(config.colors),
type: Math.random() > 0.5 ? "sphere" : "ring",
density: config.secondaryDensity,
haloRadius: config.secondaryHaloRadius,
});
}
rockets.splice(i, 1);
} else {
rocket.update();
rocket.draw(ctx);
}
}
particles = particles.filter((particle) => particle.alive);
particles.forEach((particle) => {
particle.update();
particle.draw(ctx);
});
if (now - startTime < DURATION) {
const elapsed = now - startTime;
const shouldContinue = elapsed < duration
|| rockets.length > 0
|| particles.length > 0
|| scheduledBursts.length > 0;
if (shouldContinue && elapsed < hardStopAt) {
animId = requestAnimationFrame(animate);
} else {
clearInterval(launchInterval);
@@ -300,6 +512,48 @@ const FireworksEffect = (() => {
animId = requestAnimationFrame(animate);
}
/**
* 启动烟花特效(普通版)
*
* @param {HTMLCanvasElement} canvas 全屏 Canvas
* @param {Function} onEnd 特效结束回调
*/
function start(canvas, onEnd) {
_runShow(canvas, onEnd, {
duration: 10500,
launchEvery: 340,
maxLaunches: 24,
particleDensity: 1.08,
peakParticleBudget: 1500,
secondaryDensity: 0.42,
primaryHaloRadius: 150,
secondaryHaloRadius: 84,
secondaryBurstChance: 0.54,
colors: COLORS,
getBatchSize(launchCnt) {
return launchCnt % 5 === 0 ? 2 : 1;
},
getLaunchX(width) {
return width * (0.1 + Math.random() * 0.8);
},
getTargetY(height) {
return height * (0.08 + Math.random() * 0.42);
},
openingVolley(rockets, width, height) {
[0.18, 0.5, 0.82].forEach((ratio) => {
rockets.push(new Rocket(
width * ratio,
height * (0.12 + Math.random() * 0.12),
_pick(COLORS),
"sphere",
height,
(Math.random() - 0.5) * 0.6,
));
});
},
});
}
/**
* 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久)
*
@@ -307,17 +561,6 @@ const FireworksEffect = (() => {
* @param {Function} onEnd 特效结束回调
*/
function startDouble(canvas, onEnd) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const DURATION = 12000; // 比普通多 2 秒
let rockets = [];
let particles = [];
let animId = null;
let launchCnt = 0;
const MAX_LAUNCHES = 24; // 双倍火箭数
// 婚礼专属浪漫色组(增加金色/粉色)
const WEDDING_COLORS = [
"#ff2266",
@@ -334,81 +577,42 @@ const FireworksEffect = (() => {
"#00ddff", // 其他
];
// 定时从左右两侧交替发射
const launchInterval = setInterval(() => {
if (launchCnt >= MAX_LAUNCHES) {
clearInterval(launchInterval);
return;
}
// 左右交替:偶数从左侧1/3,奇数从右侧2/3
const isLeft = launchCnt % 2 === 0;
const x = isLeft
? w * (0.05 + Math.random() * 0.4)
: w * (0.55 + Math.random() * 0.4);
const ty = h * (0.05 + Math.random() * 0.4);
const color =
WEDDING_COLORS[
Math.floor(Math.random() * WEDDING_COLORS.length)
];
const type = TYPES[Math.floor(Math.random() * TYPES.length)];
rockets.push(new Rocket(x, ty, color, type, h));
launchCnt++;
}, 400); // 发射间隔缩短到 400ms,密度加倍
// 额外:开场同时发射3枚双侧礼炮
setTimeout(() => {
[0.15, 0.5, 0.85].forEach((xRatio) => {
const color =
WEDDING_COLORS[
Math.floor(Math.random() * WEDDING_COLORS.length)
];
rockets.push(
new Rocket(w * xRatio, h * 0.1, color, "sphere", h),
);
});
}, 100);
const startTime = performance.now();
function animate(now) {
ctx.clearRect(0, 0, w, h);
for (let i = rockets.length - 1; i >= 0; i--) {
const r = rockets[i];
if (r.done) {
// 婚礼爆炸:粒子数×1.5(在 _burst 基础上额外补充50粒)
const burst = _burst(r.x, r.y, r.color, r.type);
// 额外补充粒子(心形/大颗)
for (let j = 0; j < 50; j++) {
const p = new Particle(r.x, r.y, r.color, "sphere", 0);
p.radius = Math.random() * 3 + 1;
burst.push(p);
}
particles = particles.concat(burst);
rockets.splice(i, 1);
} else {
r.update();
r.draw(ctx);
}
}
particles = particles.filter((p) => p.alive);
particles.forEach((p) => {
p.update();
p.draw(ctx);
});
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
clearInterval(launchInterval);
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
}
}
animId = requestAnimationFrame(animate);
_runShow(canvas, onEnd, {
duration: 12400,
launchEvery: 280,
maxLaunches: 34,
particleDensity: 1.3,
peakParticleBudget: 1850,
secondaryDensity: 0.56,
primaryHaloRadius: 176,
secondaryHaloRadius: 96,
secondaryBurstChance: 0.72,
colors: WEDDING_COLORS,
getBatchSize(launchCnt) {
return launchCnt % 4 === 0 ? 2 : 1;
},
getLaunchX(width) {
const fromLeft = Math.random() > 0.5;
return fromLeft
? width * (0.04 + Math.random() * 0.38)
: width * (0.58 + Math.random() * 0.38);
},
getTargetY(height) {
return height * (0.05 + Math.random() * 0.38);
},
openingVolley(rockets, width, height) {
[0.12, 0.32, 0.5, 0.68, 0.88].forEach((ratio, index) => {
rockets.push(new Rocket(
width * ratio,
height * (index % 2 === 0 ? 0.1 : 0.16),
_pick(WEDDING_COLORS),
index % 2 === 0 ? "sphere" : "ring",
height,
(Math.random() - 0.5) * 0.7,
));
});
},
});
}
return { start, startDouble };
+50
View File
@@ -7,6 +7,7 @@
namespace Tests\Feature;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Models\ShopItem;
use App\Models\User;
@@ -250,4 +251,53 @@ class ShopControllerTest extends TestCase
&& str_contains((string) ($event->message['content'] ?? ''), $item->name);
});
}
/**
* 测试指定接收人购买单次特效时,购买者本端仍会拿到播放指令,且广播会带上接收人与操作者信息。
*/
public function test_buy_instant_effect_for_recipient_returns_local_play_and_broadcasts_targeted_event(): void
{
Event::fake([EffectBroadcast::class]);
$buyer = User::factory()->create([
'username' => 'buyer-user',
'jjb' => 5000,
]);
$recipient = User::factory()->create([
'username' => 'receiver-user',
]);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_targeted',
'type' => 'instant',
'price' => 888,
'icon' => '🎆',
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => 1,
'recipient' => $recipient->username,
'message' => '送你一场烟花',
]);
$response->assertOk();
$response->assertJson([
'status' => 'success',
'play_effect' => $item->effectKey(),
'target_username' => $recipient->username,
'gift_message' => '送你一场烟花',
]);
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item): bool {
return $event->roomId === 1
&& $event->type === $item->effectKey()
&& $event->operator === $buyer->username
&& $event->targetUsername === $recipient->username
&& $event->giftMessage === '送你一场烟花'
&& $event->broadcastWith()['operator'] === $buyer->username;
});
}
}