From c209221badec67bedc76fd0bf7190cbdd3fbeafc Mon Sep 17 00:00:00 2001 From: lkddi Date: Tue, 21 Apr 2026 17:13:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E7=83=9F=E8=8A=B1=E7=89=B9=E6=95=88=E8=A1=A8=E7=8E=B0=E4=B8=8E?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Events/EffectBroadcast.php | 3 +- public/js/effects/fireworks.js | 486 +++++++++++++++++++-------- tests/Feature/ShopControllerTest.php | 50 +++ 3 files changed, 397 insertions(+), 142 deletions(-) diff --git a/app/Events/EffectBroadcast.php b/app/Events/EffectBroadcast.php index 4c7ea21..fcc945c 100644 --- a/app/Events/EffectBroadcast.php +++ b/app/Events/EffectBroadcast.php @@ -4,7 +4,7 @@ * 文件功能:聊天室全屏特效广播事件 * * 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。 - * 支持指定接收者(target_username 为 null 则全员播放)。 + * 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见。 * * @author ChatRoom Laravel * @@ -59,6 +59,7 @@ class EffectBroadcast implements ShouldBroadcastNow /** * 广播数据:特效类型、操作者、目标用户、赠言 + * 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。 * * @return array */ diff --git a/public/js/effects/fireworks.js b/public/js/effects/fireworks.js index ac3c9f5..c84f8fa 100644 --- a/public/js/effects/fireworks.js +++ b/public/js/effects/fireworks.js @@ -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 }; diff --git a/tests/Feature/ShopControllerTest.php b/tests/Feature/ShopControllerTest.php index b740071..0fc5517 100644 --- a/tests/Feature/ShopControllerTest.php +++ b/tests/Feature/ShopControllerTest.php @@ -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; + }); + } }