diff --git a/app/Events/EffectBroadcast.php b/app/Events/EffectBroadcast.php index 0d61a48..4c7ea21 100644 --- a/app/Events/EffectBroadcast.php +++ b/app/Events/EffectBroadcast.php @@ -26,13 +26,13 @@ class EffectBroadcast implements ShouldBroadcastNow /** * 支持的特效类型列表(用于校验) */ - public const TYPES = ['fireworks', 'rain', 'lightning', 'snow']; + public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies']; /** * 构造函数 * * @param int $roomId 房间 ID - * @param string $type 特效类型:fireworks / rain / lightning / snow + * @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies * @param string $operator 触发特效的用户名(购买者) * @param string|null $targetUsername 接收者用户名(null = 全员) * @param string|null $giftMessage 附带赠言 diff --git a/app/Http/Controllers/Admin/VipController.php b/app/Http/Controllers/Admin/VipController.php index 1c7bc80..b99c369 100644 --- a/app/Http/Controllers/Admin/VipController.php +++ b/app/Http/Controllers/Admin/VipController.php @@ -17,6 +17,7 @@ use App\Models\User; use App\Models\VipLevel; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\View\View; /** @@ -36,6 +37,12 @@ class VipController extends Controller 'rain' => '下雨', 'lightning' => '闪电', 'snow' => '下雪', + 'sakura' => '樱花飘落', + 'meteors' => '流星', + 'gold-rain' => '金币雨', + 'hearts' => '爱心飘落', + 'confetti' => '彩带庆典', + 'fireflies' => '萤火虫', ]; /** @@ -205,8 +212,8 @@ class VipController extends Controller 'duration_days' => 'required|integer|min:0', 'join_templates' => 'nullable|string', 'leave_templates' => 'nullable|string', - 'join_effect' => 'required|in:none,fireworks,rain,lightning,snow', - 'leave_effect' => 'required|in:none,fireworks,rain,lightning,snow', + 'join_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)], + 'leave_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)], 'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell', 'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell', 'allow_custom_messages' => 'nullable|boolean', diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 1082e31..2f5d2cc 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -16,6 +16,7 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; +use App\Events\EffectBroadcast; use App\Events\MessageSent; use App\Jobs\SaveMessageJob; use App\Models\Message; @@ -28,6 +29,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redis; +use Illuminate\Validation\Rule; class AdminCommandController extends Controller { @@ -378,7 +380,7 @@ class AdminCommandController extends Controller } /** - * 管理员触发全屏特效(烟花/下雨/雷电) + * 管理员触发全屏特效。 * * 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。 * 仅 superlevel 等级管理员可触发。 @@ -390,7 +392,7 @@ class AdminCommandController extends Controller { $request->validate([ 'room_id' => 'required|integer', - 'type' => 'required|in:fireworks,rain,lightning,snow', + 'type' => ['required', 'string', Rule::in(EffectBroadcast::TYPES)], ]); $admin = Auth::user(); @@ -404,7 +406,7 @@ class AdminCommandController extends Controller } // 广播特效事件给房间内所有在线用户 - broadcast(new \App\Events\EffectBroadcast($roomId, $type, $admin->username)); + broadcast(new EffectBroadcast($roomId, $type, $admin->username)); return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]); } diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 061f177..5341032 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -124,6 +124,12 @@ class ShopController extends Controller 'rain' => '🌧', 'lightning' => '⚡', 'snow' => '❄️', + 'sakura' => '🌸', + 'meteors' => '🌠', + 'gold-rain' => '🪙', + 'hearts' => '💖', + 'confetti' => '🎊', + 'fireflies' => '✨', ]; // 赠礼消息文案(改成"为XX触发了一场特效") $icon = $icons[$result['play_effect']] ?? '✨'; diff --git a/app/Http/Controllers/VipCenterController.php b/app/Http/Controllers/VipCenterController.php index ac9b0d7..86e5daf 100644 --- a/app/Http/Controllers/VipCenterController.php +++ b/app/Http/Controllers/VipCenterController.php @@ -60,6 +60,12 @@ class VipCenterController extends Controller 'rain' => '下雨', 'lightning' => '闪电', 'snow' => '下雪', + 'sakura' => '樱花飘落', + 'meteors' => '流星', + 'gold-rain' => '金币雨', + 'hearts' => '爱心飘落', + 'confetti' => '彩带庆典', + 'fireflies' => '萤火虫', ], 'bannerStyleOptions' => [ 'aurora' => '鎏光星幕', diff --git a/app/Models/VipLevel.php b/app/Models/VipLevel.php index 4e601b4..ce20cc5 100644 --- a/app/Models/VipLevel.php +++ b/app/Models/VipLevel.php @@ -31,6 +31,12 @@ class VipLevel extends Model 'rain', 'lightning', 'snow', + 'sakura', + 'meteors', + 'gold-rain', + 'hearts', + 'confetti', + 'fireflies', ]; /** diff --git a/database/migrations/2026_04_12_164352_add_new_effect_items_to_shop_items_table.php b/database/migrations/2026_04_12_164352_add_new_effect_items_to_shop_items_table.php new file mode 100644 index 0000000..c55152b --- /dev/null +++ b/database/migrations/2026_04_12_164352_add_new_effect_items_to_shop_items_table.php @@ -0,0 +1,73 @@ + '樱花飘落单次卡', 'slug' => 'once_sakura', 'description' => '购买即刻在聊天室飘落浪漫樱花,适合欢迎、节日与轻氛围场景。', 'icon' => '🌸', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 5, 'is_active' => true], + ['name' => '流星单次卡', 'slug' => 'once_meteors', 'description' => '流星划过夜空,亮度更强、数量更多,适合 VIP 登场和晚间氛围。', 'icon' => '🌠', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 6, 'is_active' => true], + ['name' => '金币雨单次卡', 'slug' => 'once_gold-rain', 'description' => '金币从天而降,适合开奖、奖励和活动发放时刻。', 'icon' => '🪙', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 7, 'is_active' => true], + ['name' => '爱心飘落单次卡', 'slug' => 'once_hearts', 'description' => '柔和爱心缓缓飘落,适合婚礼、表白与祝福场景。', 'icon' => '💖', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 8, 'is_active' => true], + ['name' => '彩带庆典单次卡', 'slug' => 'once_confetti', 'description' => '连续彩带和纸屑庆典从天而降,适合公告、中奖和庆祝。', 'icon' => '🎊', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 9, 'is_active' => true], + ['name' => '萤火虫单次卡', 'slug' => 'once_fireflies', 'description' => '发光萤火虫在屏幕间穿梭,适合安静、夜色和浪漫房间。', 'icon' => '✨', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 10, 'is_active' => true], + ['name' => '樱花飘落周卡', 'slug' => 'week_sakura', 'description' => '连续7天,每次进入聊天室自动飘落樱花。同时只能激活一种周卡。', 'icon' => '🌸', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 15, 'is_active' => true], + ['name' => '流星周卡', 'slug' => 'week_meteors', 'description' => '连续7天,每次进入聊天室自动划过流星。同时只能激活一种周卡。', 'icon' => '🌠', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 16, 'is_active' => true], + ['name' => '金币雨周卡', 'slug' => 'week_gold-rain', 'description' => '连续7天,每次进入聊天室自动触发金币雨。同时只能激活一种周卡。', 'icon' => '🪙', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 17, 'is_active' => true], + ['name' => '爱心飘落周卡', 'slug' => 'week_hearts', 'description' => '连续7天,每次进入聊天室自动飘落爱心。同时只能激活一种周卡。', 'icon' => '💖', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 18, 'is_active' => true], + ['name' => '彩带庆典周卡', 'slug' => 'week_confetti', 'description' => '连续7天,每次进入聊天室自动触发彩带庆典。同时只能激活一种周卡。', 'icon' => '🎊', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 19, 'is_active' => true], + ['name' => '萤火虫周卡', 'slug' => 'week_fireflies', 'description' => '连续7天,每次进入聊天室自动飞出萤火虫。同时只能激活一种周卡。', 'icon' => '✨', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 20, 'is_active' => true], + ]; + + // 统一补齐创建/更新时间,便于 upsert 覆盖已有同 slug 商品。 + $payload = array_map(static function (array $item) use ($now): array { + return $item + [ + 'created_at' => $now, + 'updated_at' => $now, + ]; + }, $items); + + DB::table('shop_items')->upsert( + $payload, + ['slug'], + ['name', 'description', 'icon', 'price', 'type', 'duration_days', 'sort_order', 'is_active', 'updated_at'] + ); + } + + /** + * 回滚新增特效商品,删除本次补进的 12 个商店条目。 + */ + public function down(): void + { + DB::table('shop_items')->whereIn('slug', [ + 'once_sakura', + 'once_meteors', + 'once_gold-rain', + 'once_hearts', + 'once_confetti', + 'once_fireflies', + 'week_sakura', + 'week_meteors', + 'week_gold-rain', + 'week_hearts', + 'week_confetti', + 'week_fireflies', + ])->delete(); + } +}; diff --git a/database/seeders/ShopItemSeeder.php b/database/seeders/ShopItemSeeder.php index 5e668c9..e006739 100644 --- a/database/seeders/ShopItemSeeder.php +++ b/database/seeders/ShopItemSeeder.php @@ -2,7 +2,7 @@ /** * 文件功能:商店初始商品数据填充器 - * 初始化9种商品:4种单次特效卡 + 4种周卡 + 改名卡 + * 初始化商店基础商品:经典特效卡、新增特效卡、周卡与改名卡 */ namespace Database\Seeders; @@ -31,6 +31,24 @@ class ShopItemSeeder extends Seeder ['name' => '下雪单次卡', 'slug' => 'once_snow', 'icon' => '❄️', 'description' => '银装素裹,漫天飞雪!立即触发下雪特效(仅自己可见)。', 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 4], + ['name' => '樱花飘落单次卡', 'slug' => 'once_sakura', 'icon' => '🌸', + 'description' => '购买即刻在聊天室飘落浪漫樱花,适合欢迎、节日与轻氛围场景。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 5], + ['name' => '流星单次卡', 'slug' => 'once_meteors', 'icon' => '🌠', + 'description' => '流星划过夜空,亮度更强、数量更多,适合 VIP 登场和晚间氛围。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 6], + ['name' => '金币雨单次卡', 'slug' => 'once_gold-rain', 'icon' => '🪙', + 'description' => '金币从天而降,适合开奖、奖励和活动发放时刻。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 7], + ['name' => '爱心飘落单次卡', 'slug' => 'once_hearts', 'icon' => '💖', + 'description' => '柔和爱心缓缓飘落,适合婚礼、表白与祝福场景。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 8], + ['name' => '彩带庆典单次卡', 'slug' => 'once_confetti', 'icon' => '🎊', + 'description' => '连续彩带和纸屑庆典从天而降,适合公告、中奖和庆祝。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 9], + ['name' => '萤火虫单次卡', 'slug' => 'once_fireflies', 'icon' => '✨', + 'description' => '发光萤火虫在屏幕间穿梭,适合安静、夜色和浪漫房间。', + 'price' => 888, 'type' => 'instant', 'duration_days' => null, 'sort_order' => 10], // ── 周卡(登录自动播放7天,8888金币)────────────────── ['name' => '烟花周卡', 'slug' => 'week_fireworks', 'icon' => '🎆', @@ -45,11 +63,29 @@ class ShopItemSeeder extends Seeder ['name' => '下雪周卡', 'slug' => 'week_snow', 'icon' => '❄️', 'description' => '连续7天,每次进入聊天室自动下雪。同时只能激活一种周卡。', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 14], + ['name' => '樱花飘落周卡', 'slug' => 'week_sakura', 'icon' => '🌸', + 'description' => '连续7天,每次进入聊天室自动飘落樱花。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 15], + ['name' => '流星周卡', 'slug' => 'week_meteors', 'icon' => '🌠', + 'description' => '连续7天,每次进入聊天室自动划过流星。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 16], + ['name' => '金币雨周卡', 'slug' => 'week_gold-rain', 'icon' => '🪙', + 'description' => '连续7天,每次进入聊天室自动触发金币雨。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 17], + ['name' => '爱心飘落周卡', 'slug' => 'week_hearts', 'icon' => '💖', + 'description' => '连续7天,每次进入聊天室自动飘落爱心。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 18], + ['name' => '彩带庆典周卡', 'slug' => 'week_confetti', 'icon' => '🎊', + 'description' => '连续7天,每次进入聊天室自动触发彩带庆典。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 19], + ['name' => '萤火虫周卡', 'slug' => 'week_fireflies', 'icon' => '✨', + 'description' => '连续7天,每次进入聊天室自动飞出萤火虫。同时只能激活一种周卡。', + 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 20], // ── 改名卡(一次性,5000金币)──────────────────────── ['name' => '改名卡', 'slug' => 'rename_card', 'icon' => '🎭', 'description' => '使用后可修改一次昵称(旧名保留30天黑名单,不可被他人注册)。注意:历史聊天记录中的旧名不会更改。', - 'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 20], + 'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 30], ]; foreach ($items as $item) { diff --git a/public/js/effects/confetti.js b/public/js/effects/confetti.js new file mode 100644 index 0000000..ef2dc1a --- /dev/null +++ b/public/js/effects/confetti.js @@ -0,0 +1,127 @@ +/** + * 文件功能:聊天室彩带庆典特效 + * + * 通过大量彩纸碎片与飘带在空中散落、翻转,形成明显的庆典氛围, + * 适合活动开始、中奖提示和管理员公告等场景。 + */ + +const ConfettiEffect = (() => { + class Piece { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(); + } + + /** + * 初始化彩带碎片参数。 + */ + reset() { + this.x = this.w * (0.15 + Math.random() * 0.7); + this.y = -Math.random() * this.h * 0.2; + this.vx = Math.random() * 4.4 - 2.2; + this.vy = Math.random() * 1.6 + 0.8; + this.gravity = Math.random() * 0.03 + 0.025; + this.rot = Math.random() * Math.PI * 2; + this.rotSpeed = (Math.random() - 0.5) * 0.16; + this.width = Math.random() * 10 + 6; + this.height = Math.random() * 18 + 8; + this.wave = Math.random() * Math.PI * 2; + this.waveSpeed = Math.random() * 0.06 + 0.03; + this.alpha = Math.random() * 0.2 + 0.75; + this.color = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899"][ + Math.floor(Math.random() * 6) + ]; + this.isRibbon = Math.random() > 0.72; + } + + /** + * 更新碎片运动状态。 + */ + update() { + this.wave += this.waveSpeed; + this.vy += this.gravity; + this.vx *= 0.995; + this.x += this.vx + Math.sin(this.wave) * 0.65; + this.y += this.vy; + this.rot += this.rotSpeed; + } + + /** + * 判断彩带是否仍在画布内。 + */ + get alive() { + return this.y < this.h + 60; + } + + /** + * 绘制单个彩带碎片。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + const scaleX = Math.max(0.18, Math.abs(Math.cos(this.rot))); + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rot); + ctx.scale(scaleX, 1); + ctx.globalAlpha = this.alpha; + ctx.fillStyle = this.color; + ctx.shadowColor = `${this.color}66`; + ctx.shadowBlur = 6; + + if (this.isRibbon) { + ctx.fillRect(-this.width * 0.2, -this.height / 2, this.width * 0.4, this.height); + } else { + ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); + } + + ctx.restore(); + } + } + + /** + * 启动彩带庆典特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 7800; + let pieces = Array.from({ length: 90 }, () => new Piece(w, h)); + const startTime = performance.now(); + let lastSpawnAt = startTime; + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + pieces = pieces.filter((piece) => { + piece.update(); + piece.draw(ctx); + return piece.alive; + }); + + if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) { + pieces.push(...Array.from({ length: 10 }, () => new Piece(w, h))); + lastSpawnAt = now; + } + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/effect-manager.js b/public/js/effects/effect-manager.js index 0aa1523..7f47e2d 100644 --- a/public/js/effects/effect-manager.js +++ b/public/js/effects/effect-manager.js @@ -2,7 +2,7 @@ * 文件功能:聊天室特效管理器 * * 统一管理全屏 Canvas 特效的入口、防重入和资源清理。 - * 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning') + * 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies') */ const EffectManager = (() => { @@ -64,7 +64,7 @@ const EffectManager = (() => { /** * 播放指定特效 * - * @param {string} type 特效类型:fireworks / rain / lightning / snow + * @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies */ function play(type) { // 防重入:同时只允许一个特效 @@ -109,6 +109,36 @@ const EffectManager = (() => { SnowEffect.start(canvas, _cleanup); } break; + case "sakura": + if (typeof SakuraEffect !== "undefined") { + SakuraEffect.start(canvas, _cleanup); + } + break; + case "meteors": + if (typeof MeteorsEffect !== "undefined") { + MeteorsEffect.start(canvas, _cleanup); + } + break; + case "gold-rain": + if (typeof GoldRainEffect !== "undefined") { + GoldRainEffect.start(canvas, _cleanup); + } + break; + case "hearts": + if (typeof HeartsEffect !== "undefined") { + HeartsEffect.start(canvas, _cleanup); + } + break; + case "confetti": + if (typeof ConfettiEffect !== "undefined") { + ConfettiEffect.start(canvas, _cleanup); + } + break; + case "fireflies": + if (typeof FirefliesEffect !== "undefined") { + FirefliesEffect.start(canvas, _cleanup); + } + break; default: console.warn(`[EffectManager] 未知特效类型:${type}`); _cleanup(); diff --git a/public/js/effects/effect-sounds.js b/public/js/effects/effect-sounds.js index 35d2b44..8f9571c 100644 --- a/public/js/effects/effect-sounds.js +++ b/public/js/effects/effect-sounds.js @@ -13,6 +13,12 @@ * fireworks 烟花(发射滑音 + 高频爆炸噪声) * rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步) * snow 下雪(仅五声音阶铃音,无风声) + * sakura 樱花(轻柔风铃 + 微风扫过) + * meteors 流星(高速掠空呼啸) + * gold-rain 金币雨(金属叮当) + * hearts 爱心飘落(温暖双音) + * confetti 彩带庆典(礼炮碎响 + 清亮点缀) + * fireflies 萤火虫(稀疏微光铃音) */ const EffectSounds = (() => { @@ -56,6 +62,82 @@ const EffectSounds = (() => { return buf; } + /** + * 调度一个带包络的简单音符。 + * + * @param {AudioContext} ctx + * @param {GainNode} masterGain + * @param {{delay?:number, duration?:number, freq:number, endFreq?:number, volume?:number, type?:OscillatorType}} options + */ + function _scheduleTone(ctx, masterGain, options) { + const { + delay = 0, + duration = 0.8, + freq, + endFreq = freq, + volume = 0.18, + type = "sine", + } = options; + + const t0 = ctx.currentTime + delay; + const osc = ctx.createOscillator(); + const env = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t0); + osc.frequency.exponentialRampToValueAtTime( + Math.max(20, endFreq), + t0 + duration, + ); + env.gain.setValueAtTime(Math.max(0.0001, volume), t0); + env.gain.exponentialRampToValueAtTime(0.001, t0 + duration); + osc.connect(env); + env.connect(masterGain); + osc.start(t0); + osc.stop(t0 + duration + 0.03); + } + + /** + * 调度一段带滤波扫频的噪声音色,用于呼啸、爆裂等环境声。 + * + * @param {AudioContext} ctx + * @param {GainNode} masterGain + * @param {{delay?:number, duration?:number, startFreq?:number, endFreq?:number, volume?:number, q?:number, filterType?:BiquadFilterType}} options + */ + function _scheduleNoiseSweep(ctx, masterGain, options) { + const { + delay = 0, + duration = 0.6, + startFreq = 2200, + endFreq = 500, + volume = 0.16, + q = 0.8, + filterType = "bandpass", + } = options; + + const t0 = ctx.currentTime + delay; + const src = ctx.createBufferSource(); + src.buffer = _makeNoise(ctx, duration + 0.15); + const filter = ctx.createBiquadFilter(); + filter.type = filterType; + filter.Q.value = q; + filter.frequency.setValueAtTime(startFreq, t0); + filter.frequency.exponentialRampToValueAtTime( + Math.max(60, endFreq), + t0 + duration, + ); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, t0); + env.gain.linearRampToValueAtTime(volume, t0 + duration * 0.18); + env.gain.exponentialRampToValueAtTime(0.001, t0 + duration); + + src.connect(filter); + filter.connect(env); + env.connect(masterGain); + src.start(t0); + src.stop(t0 + duration + 0.08); + } + // ─── 雷电音效(三层合成,贴近真实雷声)──────────────────────── /** @@ -392,6 +474,294 @@ const EffectSounds = (() => { }; } + // ─── 樱花 / 流星 / 金币雨 / 爱心 / 彩带 / 萤火虫音效 ──────────── + + /** + * 启动樱花音效:低音量风铃加轻微风声。 + * + * @returns {Function} 停止函数 + */ + function _startSakura() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.42; + master.connect(ctx.destination); + + const notes = [523.25, 659.25, 783.99, 880]; + const plan = [0.35, 1.2, 2.1, 3.6, 5.1, 6.8, 8.2]; + plan.forEach((delay, index) => { + _scheduleTone(ctx, master, { + delay, + duration: 1.8, + freq: notes[index % notes.length], + endFreq: notes[index % notes.length] * 0.98, + volume: 0.11, + type: "sine", + }); + }); + + _scheduleNoiseSweep(ctx, master, { + delay: 0.2, + duration: 3.2, + startFreq: 1400, + endFreq: 500, + volume: 0.04, + q: 0.35, + }); + _scheduleNoiseSweep(ctx, master, { + delay: 4.8, + duration: 2.8, + startFreq: 1200, + endFreq: 420, + volume: 0.035, + q: 0.3, + }); + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 900); + }, 9800); + + return () => { + clearTimeout(endTimer); + try { + master.gain.setValueAtTime(0, ctx.currentTime); + master.disconnect(); + } catch (_) {} + }; + } + + /** + * 启动流星音效:多段高速呼啸掠空。 + * + * @returns {Function} 停止函数 + */ + function _startMeteors() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.55; + master.connect(ctx.destination); + + [0.4, 1.5, 2.8, 4.2, 5.7, 7.1].forEach((delay) => { + _scheduleNoiseSweep(ctx, master, { + delay, + duration: 0.85, + startFreq: 3600, + endFreq: 320, + volume: 0.14, + q: 1.1, + }); + _scheduleTone(ctx, master, { + delay: delay + 0.08, + duration: 0.55, + freq: 1100, + endFreq: 420, + volume: 0.06, + type: "triangle", + }); + }); + + const endTimer = setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 9300); + + return () => { + clearTimeout(endTimer); + try { + master.disconnect(); + } catch (_) {} + }; + } + + /** + * 启动金币雨音效:连续金属叮当声。 + * + * @returns {Function} 停止函数 + */ + function _startGoldRain() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.62; + master.connect(ctx.destination); + + const notes = [880, 987.77, 1174.66, 1318.51]; + for (let i = 0; i < 16; i++) { + const delay = 0.25 + i * 0.42; + const freq = notes[i % notes.length]; + _scheduleTone(ctx, master, { + delay, + duration: 0.5, + freq, + endFreq: freq * 0.92, + volume: 0.16, + type: "triangle", + }); + _scheduleTone(ctx, master, { + delay: delay + 0.02, + duration: 0.38, + freq: freq * 2.1, + endFreq: freq * 1.9, + volume: 0.06, + type: "sine", + }); + } + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.6); + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 700); + }, 7600); + + return () => { + clearTimeout(endTimer); + try { + master.gain.setValueAtTime(0, ctx.currentTime); + master.disconnect(); + } catch (_) {} + }; + } + + /** + * 启动爱心飘落音效:柔和双音和声。 + * + * @returns {Function} 停止函数 + */ + function _startHearts() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.5; + master.connect(ctx.destination); + + [0.35, 1.5, 2.9, 4.2, 5.8, 7.2].forEach((delay) => { + _scheduleTone(ctx, master, { + delay, + duration: 0.9, + freq: 523.25, + endFreq: 493.88, + volume: 0.12, + type: "triangle", + }); + _scheduleTone(ctx, master, { + delay: delay + 0.12, + duration: 1.05, + freq: 659.25, + endFreq: 622.25, + volume: 0.1, + type: "sine", + }); + }); + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 900); + }, 9000); + + return () => { + clearTimeout(endTimer); + try { + master.gain.setValueAtTime(0, ctx.currentTime); + master.disconnect(); + } catch (_) {} + }; + } + + /** + * 启动彩带庆典音效:礼炮碎响与亮点装饰音。 + * + * @returns {Function} 停止函数 + */ + function _startConfetti() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.58; + master.connect(ctx.destination); + + [0.2, 1.4, 2.8, 4.1, 5.6].forEach((delay) => { + _scheduleNoiseSweep(ctx, master, { + delay, + duration: 0.38, + startFreq: 1800, + endFreq: 700, + volume: 0.2, + q: 0.7, + }); + _scheduleTone(ctx, master, { + delay: delay + 0.04, + duration: 0.42, + freq: 1046.5, + endFreq: 880, + volume: 0.08, + type: "square", + }); + }); + + const endTimer = setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 7200); + + return () => { + clearTimeout(endTimer); + try { + master.disconnect(); + } catch (_) {} + }; + } + + /** + * 启动萤火虫音效:低音量稀疏闪烁音。 + * + * @returns {Function} 停止函数 + */ + function _startFireflies() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.28; + master.connect(ctx.destination); + + [0.6, 1.8, 2.7, 3.9, 5.2, 6.4, 7.6, 8.8].forEach((delay, index) => { + _scheduleTone(ctx, master, { + delay, + duration: 1.4, + freq: [659.25, 783.99, 987.77][index % 3], + endFreq: [659.25, 783.99, 987.77][index % 3] * 0.99, + volume: 0.06, + type: "sine", + }); + }); + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0); + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 1100); + }, 10200); + + return () => { + clearTimeout(endTimer); + try { + master.gain.setValueAtTime(0, ctx.currentTime); + master.disconnect(); + } catch (_) {} + }; + } + // ─── 公开 API ────────────────────────────────────────────────── /** @@ -400,7 +770,7 @@ const EffectSounds = (() => { * 当 AudioContext 处于 suspended 状态时,先 resume() 再播放, * 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。 * - * @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' + * @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' */ function play(type) { // 用户开启禁音则跳过 @@ -425,6 +795,24 @@ const EffectSounds = (() => { case "snow": _stopFn = _startSnow(); break; + case "sakura": + _stopFn = _startSakura(); + break; + case "meteors": + _stopFn = _startMeteors(); + break; + case "gold-rain": + _stopFn = _startGoldRain(); + break; + case "hearts": + _stopFn = _startHearts(); + break; + case "confetti": + _stopFn = _startConfetti(); + break; + case "fireflies": + _stopFn = _startFireflies(); + break; default: break; } diff --git a/public/js/effects/fireflies.js b/public/js/effects/fireflies.js new file mode 100644 index 0000000..d7ac6b6 --- /dev/null +++ b/public/js/effects/fireflies.js @@ -0,0 +1,151 @@ +/** + * 文件功能:聊天室萤火虫特效 + * + * 使用高亮柔光粒子模拟夜色中的萤火虫,让光点在屏幕中缓慢游走、 + * 呼吸闪烁,适合常驻氛围和安静主题房间。 + */ + +const FirefliesEffect = (() => { + class Firefly { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(); + } + + /** + * 重置萤火虫位置与呼吸参数。 + */ + reset() { + this.x = Math.random() * this.w; + this.y = Math.random() * this.h; + this.baseRadius = Math.random() * 3 + 2.4; + this.alpha = Math.random() * 0.24 + 0.36; + this.phase = Math.random() * Math.PI * 2; + this.phaseSpeed = Math.random() * 0.05 + 0.015; + this.angle = Math.random() * Math.PI * 2; + this.speed = Math.random() * 0.55 + 0.22; + this.flutter = Math.random() * Math.PI * 2; + this.flutterSpeed = Math.random() * 0.18 + 0.08; + this.trail = []; + } + + /** + * 更新萤火虫轨迹。 + */ + update() { + this.phase += this.phaseSpeed; + this.flutter += this.flutterSpeed; + this.trail.push({ x: this.x, y: this.y }); + if (this.trail.length > 7) { + this.trail.shift(); + } + + this.angle += (Math.random() - 0.5) * 0.08 + Math.sin(this.flutter) * 0.012; + this.x += Math.cos(this.angle) * this.speed; + this.y += Math.sin(this.angle) * this.speed; + + if (this.x < -20) this.x = this.w + 20; + if (this.x > this.w + 20) this.x = -20; + if (this.y < -20) this.y = this.h + 20; + if (this.y > this.h + 20) this.y = -20; + } + + /** + * 绘制发光萤火虫。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + const pulse = 0.38 + (Math.sin(this.phase) + 1) * 0.42; + const radius = this.baseRadius * pulse; + const wingSwing = Math.sin(this.flutter) * 1.9; + + this.trail.forEach((point, index) => { + const alpha = ((index + 1) / this.trail.length) * 0.08; + ctx.save(); + ctx.fillStyle = `rgba(250, 204, 21, ${alpha})`; + ctx.shadowColor = "rgba(250, 204, 21, 0.45)"; + ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.arc(point.x, point.y, Math.max(0.8, radius * 0.55), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }); + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.angle); + + // 先画半透明翅膀,再画虫身和发光尾部,让画面更像“萤火虫”而不是单纯光点。 + ctx.globalAlpha = 0.18 + pulse * 0.08; + ctx.fillStyle = "#f8fafc"; + ctx.beginPath(); + ctx.ellipse(-radius * 1.15, -radius * 0.45, radius * 1.05, radius * 0.55, wingSwing * 0.05, 0, Math.PI * 2); + ctx.ellipse(-radius * 0.1, radius * 0.45, radius * 1.05, radius * 0.55, -wingSwing * 0.05, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = this.alpha + pulse * 0.3; + ctx.fillStyle = "#fde047"; + ctx.shadowColor = "rgba(250, 204, 21, 0.85)"; + ctx.shadowBlur = 18 + pulse * 12; + ctx.beginPath(); + ctx.arc(radius * 0.75, 0, radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "rgba(51, 65, 85, 0.9)"; + ctx.shadowBlur = 0; + ctx.beginPath(); + ctx.ellipse(-radius * 0.25, 0, radius * 1.05, Math.max(1.4, radius * 0.5), 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = "rgba(248, 250, 252, 0.35)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(-radius * 1.1, -radius * 0.2); + ctx.lineTo(-radius * 1.65, -radius * 0.8); + ctx.moveTo(-radius * 1.1, radius * 0.2); + ctx.lineTo(-radius * 1.65, radius * 0.8); + ctx.stroke(); + ctx.restore(); + } + } + + /** + * 启动萤火虫特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 10500; + const COUNT = Math.min(52, Math.max(24, Math.floor(w / 34))); + const fireflies = Array.from({ length: COUNT }, () => new Firefly(w, h)); + const startTime = performance.now(); + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + fireflies.forEach((firefly) => { + firefly.update(); + firefly.draw(ctx); + }); + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/gold-rain.js b/public/js/effects/gold-rain.js new file mode 100644 index 0000000..ad9e5e8 --- /dev/null +++ b/public/js/effects/gold-rain.js @@ -0,0 +1,131 @@ +/** + * 文件功能:聊天室金币雨特效 + * + * 使用 Canvas 绘制翻转下落的金币,让金币带着高光与闪烁效果从天而降, + * 用于活动奖励、红包雨等庆祝场景。 + */ + +const GoldRainEffect = (() => { + class Coin { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(true); + } + + /** + * 重置金币位置与翻转参数。 + * + * @param {boolean} initial 是否首次初始化 + */ + reset(initial = false) { + this.x = Math.random() * this.w; + this.y = initial ? Math.random() * this.h : -30 - Math.random() * 160; + this.radius = Math.random() * 8 + 12; + this.speedY = Math.random() * 1.2 + 1.2; + this.speedX = Math.random() * 0.9 - 0.45; + this.gravity = Math.random() * 0.035 + 0.015; + this.spin = Math.random() * Math.PI * 2; + this.spinSpeed = Math.random() * 0.16 + 0.1; + this.alpha = Math.random() * 0.25 + 0.72; + this.sparkle = Math.random() * Math.PI * 2; + } + + /** + * 更新金币状态。 + */ + update() { + this.speedY = Math.min(this.speedY + this.gravity, 2.8); + this.y += this.speedY; + this.x += this.speedX; + this.spin += this.spinSpeed; + this.sparkle += 0.08; + + if (this.y > this.h + 40) { + this.reset(false); + } + } + + /** + * 绘制单枚金币。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + const scaleX = Math.max(0.22, Math.abs(Math.cos(this.spin))); + const glow = 0.28 + Math.max(0, Math.sin(this.sparkle)) * 0.18; + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.scale(scaleX, 1); + ctx.globalAlpha = this.alpha; + ctx.shadowColor = "rgba(250, 204, 21, 0.55)"; + ctx.shadowBlur = 10; + + const gradient = ctx.createLinearGradient(0, -this.radius, 0, this.radius); + gradient.addColorStop(0, "#fef08a"); + gradient.addColorStop(0.45, "#facc15"); + gradient.addColorStop(1, "#ca8a04"); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, this.radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.lineWidth = 2; + ctx.strokeStyle = "rgba(161, 98, 7, 0.7)"; + ctx.stroke(); + + ctx.fillStyle = `rgba(255,255,255,${glow})`; + ctx.beginPath(); + ctx.arc(-this.radius * 0.28, -this.radius * 0.32, this.radius * 0.26, 0, Math.PI * 2); + ctx.fill(); + + ctx.scale(1 / scaleX, 1); + ctx.fillStyle = "rgba(120, 53, 15, 0.8)"; + ctx.font = `${Math.max(10, this.radius)}px Arial`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("¥", 0, 1); + ctx.restore(); + } + } + + /** + * 启动金币雨特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 8600; + const COIN_COUNT = Math.min(58, Math.max(28, Math.floor(w / 28))); + const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h)); + const startTime = performance.now(); + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + coins.forEach((coin) => { + coin.update(); + coin.draw(ctx); + }); + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/hearts.js b/public/js/effects/hearts.js new file mode 100644 index 0000000..6ae362d --- /dev/null +++ b/public/js/effects/hearts.js @@ -0,0 +1,113 @@ +/** + * 文件功能:聊天室爱心飘落特效 + * + * 在透明 Canvas 上绘制成组心形,从上方向下飘落并轻微摇摆, + * 适用于表白、婚礼、节日祝福等氛围场景。 + */ + +const HeartsEffect = (() => { + class Heart { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(true); + } + + /** + * 重置爱心粒子。 + * + * @param {boolean} initial 是否首次初始化 + */ + reset(initial = false) { + this.x = Math.random() * this.w; + this.y = initial ? Math.random() * this.h : -24; + this.size = Math.random() * 10 + 10; + this.speedY = Math.random() * 1.5 + 1.1; + this.swing = Math.random() * Math.PI * 2; + this.swingSpeed = Math.random() * 0.04 + 0.015; + this.rotation = (Math.random() - 0.5) * 0.35; + this.rotationSpeed = (Math.random() - 0.5) * 0.01; + this.alpha = Math.random() * 0.28 + 0.62; + this.color = ["#fb7185", "#f43f5e", "#ec4899", "#fda4af"][ + Math.floor(Math.random() * 4) + ]; + } + + /** + * 更新爱心位置。 + */ + update() { + this.swing += this.swingSpeed; + this.rotation += this.rotationSpeed; + this.x += Math.sin(this.swing) * 1.1; + this.y += this.speedY; + + if (this.y > this.h + 28) { + this.reset(false); + } + } + + /** + * 绘制爱心形状。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + const s = this.size; + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + ctx.scale(s / 16, s / 16); + ctx.globalAlpha = this.alpha; + ctx.fillStyle = this.color; + ctx.shadowColor = "rgba(244, 63, 94, 0.45)"; + ctx.shadowBlur = 10; + + ctx.beginPath(); + ctx.moveTo(0, -4); + ctx.bezierCurveTo(12, -18, 26, -2, 0, 18); + ctx.bezierCurveTo(-26, -2, -12, -18, 0, -4); + ctx.fill(); + ctx.restore(); + } + } + + /** + * 启动爱心飘落特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 9000; + const HEART_COUNT = Math.min(48, Math.max(24, Math.floor(w / 34))); + const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(w, h)); + const startTime = performance.now(); + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + hearts.forEach((heart) => { + heart.update(); + heart.draw(ctx); + }); + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/lightning.js b/public/js/effects/lightning.js index a75b2cf..3642e8b 100644 --- a/public/js/effects/lightning.js +++ b/public/js/effects/lightning.js @@ -1,8 +1,8 @@ /** * 文件功能:聊天室雷电特效 * - * 使用递归分叉算法在 Canvas 上绘制真实感闪电路径, - * 配合全屏闪白效果模拟雷闪。总持续约 5 秒,结束后回调。 + * 使用递归分叉算法叠加云层闪光、主闪电、余辉残影, + * 在聊天室中模拟更有压迫感的雷暴闪电效果。 */ const LightningEffect = (() => { @@ -51,10 +51,41 @@ const LightningEffect = (() => { } /** - * 渲染一次闪电 + 闪屏效果 + * 绘制顶部乌云压光层,让闪电更有“雷暴”氛围。 * - * @param {HTMLCanvasElement} canvas - * @param {CanvasRenderingContext2D} ctx + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} ctx + */ + function _drawStormGlow(canvas, ctx) { + const w = canvas.width; + const h = canvas.height; + const sky = ctx.createLinearGradient(0, 0, 0, h * 0.8); + sky.addColorStop(0, "rgba(7, 18, 38, 0.34)"); + sky.addColorStop(0.45, "rgba(15, 23, 42, 0.18)"); + sky.addColorStop(1, "rgba(15, 23, 42, 0)"); + ctx.fillStyle = sky; + ctx.fillRect(0, 0, w, h); + + for (let i = 0; i < 3; i++) { + const cloudX = w * (0.12 + Math.random() * 0.76); + const cloudY = h * (0.05 + Math.random() * 0.22); + const cloudR = 120 + Math.random() * 160; + const cloud = ctx.createRadialGradient(cloudX, cloudY, 0, cloudX, cloudY, cloudR); + cloud.addColorStop(0, "rgba(210, 226, 255, 0.18)"); + cloud.addColorStop(0.38, "rgba(168, 196, 255, 0.1)"); + cloud.addColorStop(1, "rgba(168, 196, 255, 0)"); + ctx.fillStyle = cloud; + ctx.beginPath(); + ctx.arc(cloudX, cloudY, cloudR, 0, Math.PI * 2); + ctx.fill(); + } + } + + /** + * 渲染一次闪电 + 闪屏效果。 + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} ctx */ function _flash(canvas, ctx) { const w = canvas.width; @@ -63,24 +94,44 @@ const LightningEffect = (() => { // 清空画布 ctx.clearRect(0, 0, w, h); - // 闪屏:全屏短暂泛白 - ctx.fillStyle = "rgba(220, 235, 255, 0.55)"; + // 先铺一层压暗天空,再叠加闪白,让明暗反差更明显。 + _drawStormGlow(canvas, ctx); + ctx.fillStyle = "rgba(228, 239, 255, 0.46)"; ctx.fillRect(0, 0, w, h); - // 绘制 1-3 条主闪电(从随机顶部位置向下延伸) - const boltCount = Math.floor(Math.random() * 2) + 1; + // 绘制 1-3 条主闪电,并给出明显的白色核心线。 + const boltCount = Math.floor(Math.random() * 3) + 1; for (let i = 0; i < boltCount; i++) { - const x1 = w * (0.2 + Math.random() * 0.6); + const x1 = w * (0.12 + Math.random() * 0.76); const y1 = 0; - const x2 = x1 + (Math.random() - 0.5) * 300; - const y2 = h * (0.5 + Math.random() * 0.4); - _drawBolt(ctx, x1, y1, x2, y2, 4, 3); + const x2 = x1 + (Math.random() - 0.5) * 360; + const y2 = h * (0.55 + Math.random() * 0.35); + const width = 3.6 + Math.random() * 1.8; + _drawBolt(ctx, x1, y1, x2, y2, 5, width); + + ctx.save(); + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineWidth = Math.max(1.2, width * 0.34); + ctx.shadowColor = "rgba(255,255,255,0.95)"; + ctx.shadowBlur = 22; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); } - // 50ms 后让画布逐渐消退(模拟闪电短促感) + // 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。 setTimeout(() => { ctx.clearRect(0, 0, w, h); - }, 80); + _drawStormGlow(canvas, ctx); + ctx.fillStyle = "rgba(185, 205, 255, 0.12)"; + ctx.fillRect(0, 0, w, h); + }, 90); + + setTimeout(() => { + ctx.clearRect(0, 0, w, h); + }, 190); } /** @@ -91,24 +142,40 @@ const LightningEffect = (() => { */ function start(canvas, onEnd) { const ctx = canvas.getContext("2d"); - const FLASHES = 10; // 总闪电次数(增加密度) - const DURATION = 7000; // 总时长(ms,相应延长) + const FLASHES = 9; + const DURATION = 7600; let count = 0; + let finished = false; + + /** + * 统一结束特效,避免多次触发 onEnd。 + */ + function finish() { + if (finished) { + return; + } + + finished = true; + ctx.clearRect(0, 0, canvas.width, canvas.height); + onEnd(); + } // 间隔不规则触发多次闪电(模拟真实雷电节奏) function nextFlash() { if (count >= FLASHES) { - // 全部闪完,结束特效 setTimeout(() => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - onEnd(); - }, 400); + finish(); + }, 520); return; } + _flash(canvas, ctx); count++; - // 下次闪电间隔:400ms ~ 800ms 之间随机(更频繁) - const delay = 400 + Math.random() * 400; + + // 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。 + const delay = Math.random() > 0.65 + ? 140 + Math.random() * 140 + : 420 + Math.random() * 520; setTimeout(nextFlash, delay); } @@ -117,8 +184,7 @@ const LightningEffect = (() => { // 安全兜底:超时强制结束 setTimeout(() => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - onEnd(); + finish(); }, DURATION + 500); } diff --git a/public/js/effects/meteors.js b/public/js/effects/meteors.js new file mode 100644 index 0000000..0de56ba --- /dev/null +++ b/public/js/effects/meteors.js @@ -0,0 +1,176 @@ +/** + * 文件功能:聊天室流星特效 + * + * 在透明 Canvas 上绘制夜空光点和多枚斜向掠过的流星, + * 通过长尾渐变与随机节奏制造快速划空的视觉效果。 + */ + +const MeteorsEffect = (() => { + class Meteor { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(true); + } + + /** + * 重置单颗流星的出发状态。 + * + * @param {boolean} initial 是否首次初始化 + */ + reset(initial = false) { + this.x = initial ? Math.random() * this.w : this.w + Math.random() * 160; + this.y = initial ? Math.random() * this.h * 0.45 : Math.random() * this.h * 0.52; + this.vx = -(12 + Math.random() * 7); + this.vy = 4.4 + Math.random() * 2.6; + this.length = 170 + Math.random() * 170; + this.alpha = 0; + this.maxAlpha = Math.random() * 0.28 + 0.72; + this.delay = Math.random() * 1500; + this.birth = performance.now(); + this.life = 1800 + Math.random() * 1000; + this.width = Math.random() * 2.4 + 1.8; + this.tint = [ + [255, 255, 255], + [191, 219, 254], + [125, 211, 252], + [253, 224, 71], + ][Math.floor(Math.random() * 4)]; + this.active = false; + } + + /** + * 更新流星位置。 + * + * @param {number} now + */ + update(now) { + if (!this.active) { + if (now - this.birth >= this.delay) { + this.active = true; + } else { + return; + } + } + + this.x += this.vx; + this.y += this.vy; + + const progress = (now - this.birth - this.delay) / this.life; + if (progress < 0.2) { + this.alpha = this.maxAlpha * (progress / 0.2); + } else if (progress > 0.78) { + this.alpha = this.maxAlpha * Math.max(0, (1 - progress) / 0.22); + } else { + this.alpha = this.maxAlpha; + } + + if (progress >= 1 || this.x < -this.length || this.y > this.h + this.length) { + this.reset(false); + } + } + + /** + * 绘制流星主体和尾迹。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + if (!this.active || this.alpha <= 0.01) { + return; + } + + const tailX = this.x - this.vx * 9; + const tailY = this.y - this.vy * 9; + const [r, g, b] = this.tint; + const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY); + gradient.addColorStop(0, `rgba(255,255,255,${Math.min(1, this.alpha + 0.12)})`); + gradient.addColorStop(0.18, `rgba(${r},${g},${b},${this.alpha})`); + gradient.addColorStop(0.62, `rgba(${r},${g},${b},${this.alpha * 0.42})`); + gradient.addColorStop(1, `rgba(${r},${g},${b},0)`); + + ctx.save(); + ctx.strokeStyle = gradient; + ctx.lineWidth = this.width; + ctx.lineCap = "round"; + ctx.shadowColor = `rgba(${r},${g},${b},0.95)`; + ctx.shadowBlur = 18; + ctx.beginPath(); + ctx.moveTo(this.x, this.y); + ctx.lineTo(tailX, tailY); + ctx.stroke(); + + ctx.strokeStyle = `rgba(255,255,255,${this.alpha * 0.65})`; + ctx.lineWidth = this.width * 0.42; + ctx.beginPath(); + ctx.moveTo(this.x, this.y); + ctx.lineTo(this.x - this.vx * 2.2, this.y - this.vy * 2.2); + ctx.stroke(); + + ctx.fillStyle = `rgba(255,255,255,${Math.min(1, this.alpha + 0.18)})`; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.width * 1.55, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = `rgba(${r},${g},${b},${this.alpha * 0.55})`; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.width * 3.2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + /** + * 启动流星特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 9000; + const stars = Array.from({ length: 48 }, () => ({ + x: Math.random() * w, + y: Math.random() * h * 0.62, + r: Math.random() * 1.9 + 0.6, + alpha: Math.random() * 0.42 + 0.2, + })); + const meteors = Array.from({ length: 14 }, () => new Meteor(w, h)); + const startTime = performance.now(); + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + stars.forEach((star) => { + ctx.save(); + ctx.fillStyle = `rgba(248,250,252,${star.alpha})`; + ctx.shadowColor = "rgba(255,255,255,0.6)"; + ctx.shadowBlur = 7; + ctx.beginPath(); + ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }); + + meteors.forEach((meteor) => { + meteor.update(now); + meteor.draw(ctx); + }); + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/sakura.js b/public/js/effects/sakura.js new file mode 100644 index 0000000..8608153 --- /dev/null +++ b/public/js/effects/sakura.js @@ -0,0 +1,118 @@ +/** + * 文件功能:聊天室樱花飘落特效 + * + * 使用 Canvas 绘制多层粉色花瓣,让花瓣在屏幕上方生成后缓慢下落、 + * 左右摆动并带轻微旋转,营造柔和的春日氛围。 + */ + +const SakuraEffect = (() => { + class Petal { + constructor(w, h) { + this.w = w; + this.h = h; + this.reset(true); + } + + /** + * 重置花瓣位置与运动参数。 + * + * @param {boolean} initial 是否首次初始化 + */ + reset(initial = false) { + this.x = Math.random() * this.w; + this.y = initial ? Math.random() * this.h : -20; + this.size = Math.random() * 7 + 8; + this.speedY = Math.random() * 1.1 + 0.7; + this.speedX = Math.random() * 0.6 - 0.3; + this.swing = Math.random() * Math.PI * 2; + this.swingSpeed = Math.random() * 0.03 + 0.01; + this.rotation = Math.random() * Math.PI * 2; + this.rotationSpeed = (Math.random() - 0.5) * 0.03; + this.alpha = Math.random() * 0.25 + 0.55; + this.color = ["#fbcfe8", "#f9a8d4", "#fda4af", "#fce7f3"][ + Math.floor(Math.random() * 4) + ]; + } + + /** + * 更新花瓣运动状态。 + */ + update() { + this.swing += this.swingSpeed; + this.rotation += this.rotationSpeed; + this.x += this.speedX + Math.sin(this.swing) * 0.75; + this.y += this.speedY; + + if (this.y > this.h + 30 || this.x < -30 || this.x > this.w + 30) { + this.reset(false); + } + } + + /** + * 绘制单片樱花花瓣。 + * + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + ctx.globalAlpha = this.alpha; + ctx.fillStyle = this.color; + ctx.shadowColor = "rgba(244, 114, 182, 0.35)"; + ctx.shadowBlur = 8; + + ctx.beginPath(); + ctx.moveTo(0, -this.size * 0.9); + ctx.quadraticCurveTo(this.size * 0.9, -this.size * 0.25, 0, this.size); + ctx.quadraticCurveTo(-this.size * 0.9, -this.size * 0.25, 0, -this.size * 0.9); + ctx.fill(); + + ctx.strokeStyle = "rgba(190, 24, 93, 0.25)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, -this.size * 0.55); + ctx.lineTo(0, this.size * 0.7); + ctx.stroke(); + ctx.restore(); + } + } + + /** + * 启动樱花特效。 + * + * @param {HTMLCanvasElement} canvas + * @param {Function} onEnd + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const DURATION = 10000; + const PETAL_COUNT = Math.min(58, Math.max(34, Math.floor(w / 28))); + const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(w, h)); + const startTime = performance.now(); + let animId = null; + + function animate(now) { + ctx.clearRect(0, 0, w, h); + + petals.forEach((petal) => { + petal.update(); + petal.draw(ctx); + }); + + if (now - startTime < DURATION) { + animId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animId); + ctx.clearRect(0, 0, w, h); + onEnd(); + } + } + + animId = requestAnimationFrame(animate); + } + + return { start }; +})(); diff --git a/public/js/effects/snow.js b/public/js/effects/snow.js index 0ba5b4f..9b34d03 100644 --- a/public/js/effects/snow.js +++ b/public/js/effects/snow.js @@ -1,9 +1,8 @@ /** * 文件功能:聊天室下雪特效 * - * 使用 Canvas 绘制真实六角雪花图案(6条主臂 + 左右分叉)。 - * 采用深色描边+白色主体双遍绘制,在任何背景颜色上都清晰可见。 - * 特效总时长约 10 秒,结束后自动清理并回调。 + * 使用 Canvas 同时绘制远景小雪与近景六角雪花, + * 通过层次、大小、速度差营造更饱满的飘雪效果。 */ const SnowEffect = (() => { @@ -76,30 +75,64 @@ const SnowEffect = (() => { ctx.restore(); } + /** + * 绘制远景小雪点,让画面更密实,不会只看到零散大雪花。 + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {number} radius + * @param {number} alpha + */ + function _drawSoftSnow(ctx, x, y, radius, alpha) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.shadowColor = "rgba(255,255,255,0.8)"; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + // 雪花粒子类 class Flake { - constructor(w, h) { + constructor(w, h, layer = "front") { this.w = w; this.h = h; + this.layer = layer; this.reset(true); } + /** + * 重置雪花,让前景和背景使用不同参数。 + * + * @param {boolean} initial + */ reset(initial = false) { this.x = Math.random() * this.w; this.y = initial ? Math.random() * this.h : -20; - this.r = Math.random() * 10 + 7; // 主臂长度 7-17px(略放大) - this.speed = Math.random() * 1.2 + 0.4; - this.drift = Math.random() * 0.6 - 0.3; - this.alpha = Math.random() * 0.3 + 0.7; // 透明度 0.7-1.0 + if (this.layer === "back") { + this.r = Math.random() * 2 + 1.1; + this.speed = Math.random() * 0.55 + 0.28; + this.drift = Math.random() * 0.28 - 0.14; + this.alpha = Math.random() * 0.25 + 0.28; + } else { + this.r = Math.random() * 9 + 6.5; + this.speed = Math.random() * 1.05 + 0.48; + this.drift = Math.random() * 0.7 - 0.35; + this.alpha = Math.random() * 0.25 + 0.68; + } this.rot = Math.random() * Math.PI * 2; - this.rotSpd = (Math.random() - 0.5) * 0.02; + this.rotSpd = (Math.random() - 0.5) * (this.layer === "back" ? 0.008 : 0.018); this.wobble = 0; - this.wobSpd = Math.random() * 0.03 + 0.01; + this.wobSpd = Math.random() * (this.layer === "back" ? 0.02 : 0.028) + 0.008; } update() { this.wobble += this.wobSpd; - this.x += Math.sin(this.wobble) * 0.5 + this.drift; + this.x += Math.sin(this.wobble) * (this.layer === "back" ? 0.28 : 0.58) + this.drift; this.y += this.speed; this.rot += this.rotSpd; if (this.y > this.h + 20) { @@ -108,6 +141,11 @@ const SnowEffect = (() => { } draw(ctx) { + if (this.layer === "back") { + _drawSoftSnow(ctx, this.x, this.y, this.r, this.alpha); + return; + } + _drawFlake(ctx, this.x, this.y, this.r, this.alpha, this.rot); } } @@ -123,12 +161,24 @@ const SnowEffect = (() => { const w = canvas.width; const h = canvas.height; const DURATION = 10000; - const FLAKE_COUNT = 80; + const flakes = [ + ...Array.from( + { length: Math.min(120, Math.max(70, Math.floor(w / 18))) }, + () => new Flake(w, h, "back"), + ), + ...Array.from( + { length: Math.min(64, Math.max(34, Math.floor(w / 42))) }, + () => new Flake(w, h, "front"), + ), + ]; - const flakes = Array.from( - { length: FLAKE_COUNT }, - () => new Flake(w, h), - ); + const breezeBands = Array.from({ length: 2 }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + radius: 180 + Math.random() * 140, + alpha: Math.random() * 0.05 + 0.025, + drift: Math.random() * 0.3 + 0.08, + })); let animId = null; const startTime = performance.now(); @@ -136,6 +186,37 @@ const SnowEffect = (() => { function animate(now) { ctx.clearRect(0, 0, w, h); + // 加一层极淡的冷白雾感,让雪景更有氛围但不遮挡聊天内容。 + const mist = ctx.createLinearGradient(0, 0, 0, h); + mist.addColorStop(0, "rgba(226,240,255,0.08)"); + mist.addColorStop(0.4, "rgba(226,240,255,0.03)"); + mist.addColorStop(1, "rgba(226,240,255,0)"); + ctx.fillStyle = mist; + ctx.fillRect(0, 0, w, h); + + breezeBands.forEach((band) => { + band.x += band.drift; + if (band.x - band.radius > w) { + band.x = -band.radius; + band.y = Math.random() * h; + } + + const breeze = ctx.createRadialGradient( + band.x, + band.y, + 0, + band.x, + band.y, + band.radius, + ); + breeze.addColorStop(0, `rgba(255,255,255,${band.alpha})`); + breeze.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = breeze; + ctx.beginPath(); + ctx.arc(band.x, band.y, band.radius, 0, Math.PI * 2); + ctx.fill(); + }); + flakes.forEach((f) => { f.update(); f.draw(ctx); diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index b74450c..5f35eb0 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -218,13 +218,19 @@ @include('chat.partials.games.gomoku-panel') @include('chat.partials.games.earn-panel') - {{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}} + {{-- 全屏特效系统:管理员和会员入场可触发的全屏动效 --}} + + + + + + @include('chat.partials.scripts') diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 66ebc5f..1d3f935 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -140,8 +140,9 @@ $welcomeMessages = [ ✨ 特效 @endif diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index c7f508c..cc45882 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -1152,7 +1152,13 @@ async function generateWechatBindCode() { fireworks: '🎆', rain: '🌧', lightning: '⚡', - snow: '❄️' + snow: '❄️', + sakura: '🌸', + meteors: '🌠', + 'gold-rain': '🪙', + hearts: '💖', + confetti: '🎊', + fireflies: '✨' }; badge.textContent = (icons[data.active_week_effect] ?? '') + ' 周卡生效中'; badge.style.display = 'inline-block'; diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index c6abb2a..f43100f 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -1277,7 +1277,7 @@ } document.addEventListener('DOMContentLoaded', setupGomokuInviteListener); - // ── 全屏特效事件监听(烟花/下雨/雷电/下雪)───────── + // ── 全屏特效事件监听(管理员菜单 / 会员进出场通用)───────── window.addEventListener('chat:effect', (e) => { const type = e.detail?.type; const target = e.detail?.target_username; // null = 全员,otherwise 指定昵称 @@ -1294,7 +1294,7 @@ /** * 管理员点击特效按钮,向后端 POST /command/effect * - * @param {string} type 特效类型:fireworks / rain / lightning + * @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies */ function triggerEffect(type) { const roomId = window.chatContext?.roomId; diff --git a/resources/views/chat/partials/shop-panel.blade.php b/resources/views/chat/partials/shop-panel.blade.php index 8af592b..767ac0f 100644 --- a/resources/views/chat/partials/shop-panel.blade.php +++ b/resources/views/chat/partials/shop-panel.blade.php @@ -312,7 +312,13 @@ fireworks: '🎆', rain: '🌧', lightning: '⚡', - snow: '❄️' + snow: '❄️', + sakura: '🌸', + meteors: '🌠', + 'gold-rain': '🪙', + hearts: '💖', + confetti: '🎊', + fireflies: '✨' }; badge.textContent = (icons[data.active_week_effect] ?? '') + ' 周卡生效中'; badge.style.display = 'inline-block'; diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php new file mode 100644 index 0000000..614930c --- /dev/null +++ b/tests/Feature/Feature/AdminCommandControllerTest.php @@ -0,0 +1,48 @@ +create([ + 'user_level' => 100, + ]); + $room = Room::create([ + 'room_name' => '特效房', + ]); + $types = ['sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies']; + + foreach ($types as $type) { + $response = $this->actingAs($admin)->postJson(route('command.effect'), [ + 'room_id' => $room->id, + 'type' => $type, + ]); + + $response->assertOk(); + $response->assertJson([ + 'status' => 'success', + ]); + } + } +} diff --git a/tests/Feature/ShopControllerTest.php b/tests/Feature/ShopControllerTest.php index 679d490..ccf21ff 100644 --- a/tests/Feature/ShopControllerTest.php +++ b/tests/Feature/ShopControllerTest.php @@ -1,5 +1,10 @@ create(); @@ -41,6 +53,33 @@ class ShopControllerTest extends TestCase $this->assertFalse($responseItems->contains('id', $inactiveItem->id)); } + /** + * 测试商店商品列表会包含新增的特效单次卡与周卡。 + */ + public function test_items_include_new_effect_shop_cards(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->getJson(route('shop.items')); + + $response->assertOk(); + $response->assertJsonFragment(['slug' => 'once_meteors']); + $response->assertJsonFragment(['slug' => 'once_gold-rain']); + $response->assertJsonFragment(['slug' => 'once_hearts']); + $response->assertJsonFragment(['slug' => 'once_confetti']); + $response->assertJsonFragment(['slug' => 'once_fireflies']); + $response->assertJsonFragment(['slug' => 'once_sakura']); + $response->assertJsonFragment(['slug' => 'week_sakura']); + $response->assertJsonFragment(['slug' => 'week_meteors']); + $response->assertJsonFragment(['slug' => 'week_gold-rain']); + $response->assertJsonFragment(['slug' => 'week_hearts']); + $response->assertJsonFragment(['slug' => 'week_confetti']); + $response->assertJsonFragment(['slug' => 'week_fireflies']); + } + + /** + * 测试一次性道具可以正常购买。 + */ public function test_can_buy_one_time_item() { $user = User::factory()->create(['jjb' => 500]); @@ -73,6 +112,9 @@ class ShopControllerTest extends TestCase ]); } + /** + * 测试余额不足时不能购买商品。 + */ public function test_cannot_buy_if_insufficient_funds() { $user = User::factory()->create(['jjb' => 50]); @@ -99,6 +141,9 @@ class ShopControllerTest extends TestCase ]); } + /** + * 测试已下架商品不能购买。 + */ public function test_cannot_buy_inactive_item() { $user = User::factory()->create(['jjb' => 500]); @@ -122,6 +167,9 @@ class ShopControllerTest extends TestCase ]); } + /** + * 测试改名卡可以被正常使用。 + */ public function test_can_use_rename_card() { $user = User::factory()->create(['username' => 'OldName']);