Add new chat effects and shop items
This commit is contained in:
@@ -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 附带赠言
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}"]);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,12 @@ class ShopController extends Controller
|
||||
'rain' => '🌧',
|
||||
'lightning' => '⚡',
|
||||
'snow' => '❄️',
|
||||
'sakura' => '🌸',
|
||||
'meteors' => '🌠',
|
||||
'gold-rain' => '🪙',
|
||||
'hearts' => '💖',
|
||||
'confetti' => '🎊',
|
||||
'fireflies' => '✨',
|
||||
];
|
||||
// 赠礼消息文案(改成"为XX触发了一场特效")
|
||||
$icon = $icons[$result['play_effect']] ?? '✨';
|
||||
|
||||
@@ -60,6 +60,12 @@ class VipCenterController extends Controller
|
||||
'rain' => '下雨',
|
||||
'lightning' => '闪电',
|
||||
'snow' => '下雪',
|
||||
'sakura' => '樱花飘落',
|
||||
'meteors' => '流星',
|
||||
'gold-rain' => '金币雨',
|
||||
'hearts' => '爱心飘落',
|
||||
'confetti' => '彩带庆典',
|
||||
'fireflies' => '萤火虫',
|
||||
],
|
||||
'bannerStyleOptions' => [
|
||||
'aurora' => '鎏光星幕',
|
||||
|
||||
@@ -31,6 +31,12 @@ class VipLevel extends Model
|
||||
'rain',
|
||||
'lightning',
|
||||
'snow',
|
||||
'sakura',
|
||||
'meteors',
|
||||
'gold-rain',
|
||||
'hearts',
|
||||
'confetti',
|
||||
'fireflies',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为商店补充新增特效商品数据
|
||||
* 将 6 种新特效的单次卡与周卡写入 shop_items,供现有站点直接使用。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 商店新增特效商品数据迁移
|
||||
* 负责把新特效商品同步到已有数据库。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 写入新增特效商品,已存在时按 slug 更新展示字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$now = now();
|
||||
$items = [
|
||||
['name' => '樱花飘落单次卡', '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();
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
127
public/js/effects/confetti.js
Normal file
127
public/js/effects/confetti.js
Normal file
@@ -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 };
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
151
public/js/effects/fireflies.js
Normal file
151
public/js/effects/fireflies.js
Normal file
@@ -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 };
|
||||
})();
|
||||
131
public/js/effects/gold-rain.js
Normal file
131
public/js/effects/gold-rain.js
Normal file
@@ -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 };
|
||||
})();
|
||||
113
public/js/effects/hearts.js
Normal file
113
public/js/effects/hearts.js
Normal file
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
176
public/js/effects/meteors.js
Normal file
176
public/js/effects/meteors.js
Normal file
@@ -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 };
|
||||
})();
|
||||
118
public/js/effects/sakura.js
Normal file
118
public/js/effects/sakura.js
Normal file
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -218,13 +218,19 @@
|
||||
@include('chat.partials.games.gomoku-panel')
|
||||
@include('chat.partials.games.earn-panel')
|
||||
|
||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||
{{-- 全屏特效系统:管理员和会员入场可触发的全屏动效 --}}
|
||||
<script src="/js/effects/effect-sounds.js"></script>
|
||||
<script src="/js/effects/effect-manager.js"></script>
|
||||
<script src="/js/effects/fireworks.js"></script>
|
||||
<script src="/js/effects/rain.js"></script>
|
||||
<script src="/js/effects/lightning.js"></script>
|
||||
<script src="/js/effects/snow.js"></script>
|
||||
<script src="/js/effects/sakura.js"></script>
|
||||
<script src="/js/effects/meteors.js"></script>
|
||||
<script src="/js/effects/gold-rain.js"></script>
|
||||
<script src="/js/effects/hearts.js"></script>
|
||||
<script src="/js/effects/confetti.js"></script>
|
||||
<script src="/js/effects/fireflies.js"></script>
|
||||
|
||||
@include('chat.partials.scripts')
|
||||
|
||||
|
||||
@@ -140,8 +140,9 @@ $welcomeMessages = [
|
||||
✨ 特效
|
||||
</button>
|
||||
<div id="effect-menu"
|
||||
style="display:none;position:absolute;bottom:calc(100% + 6px);left:0;z-index:10020;min-width:186px;padding:8px;background:#fff7ed;border:1px solid #fdba74;border-radius:8px;box-shadow:0 10px 24px rgba(15,23,42,.18);">
|
||||
<div style="font-size:11px;font-weight:bold;color:#9a3412;padding:0 2px 6px;">选择要播放的特效</div>
|
||||
style="display:none;position:absolute;bottom:calc(100% + 6px);left:0;z-index:10020;min-width:228px;max-width:min(76vw,260px);max-height:min(58vh,420px);overflow-y:auto;padding:10px;background:#fff7ed;border:1px solid #fdba74;border-radius:10px;box-shadow:0 10px 24px rgba(15,23,42,.18);">
|
||||
<div style="font-size:11px;font-weight:bold;color:#9a3412;padding:0 2px 8px;">选择要播放的特效</div>
|
||||
<div style="font-size:10px;color:#c2410c;padding:0 2px 6px;">经典特效</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
|
||||
<button type="button" onclick="selectEffect('fireworks')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#ea580c;border:1px solid #fdba74;border-radius:6px;cursor:pointer;">🎆 烟花</button>
|
||||
@@ -152,6 +153,21 @@ $welcomeMessages = [
|
||||
<button type="button" onclick="selectEffect('snow')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#0891b2;border:1px solid #bae6fd;border-radius:6px;cursor:pointer;">❄️ 下雪</button>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#c2410c;padding:10px 2px 6px;">新增氛围特效</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
|
||||
<button type="button" onclick="selectEffect('sakura')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#db2777;border:1px solid #f9a8d4;border-radius:6px;cursor:pointer;">🌸 樱花飘落</button>
|
||||
<button type="button" onclick="selectEffect('meteors')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#2563eb;border:1px solid #bfdbfe;border-radius:6px;cursor:pointer;">🌠 流星</button>
|
||||
<button type="button" onclick="selectEffect('gold-rain')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#ca8a04;border:1px solid #fde68a;border-radius:6px;cursor:pointer;">🪙 金币雨</button>
|
||||
<button type="button" onclick="selectEffect('hearts')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#e11d48;border:1px solid #fda4af;border-radius:6px;cursor:pointer;">💖 爱心飘落</button>
|
||||
<button type="button" onclick="selectEffect('confetti')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #ddd6fe;border-radius:6px;cursor:pointer;">🎊 彩带庆典</button>
|
||||
<button type="button" onclick="selectEffect('fireflies')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #bbf7d0;border-radius:6px;cursor:pointer;">✨ 萤火虫</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
48
tests/Feature/Feature/AdminCommandControllerTest.php
Normal file
48
tests/Feature/Feature/AdminCommandControllerTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:管理员聊天命令功能测试
|
||||
* 负责验证聊天室管理员命令接口对新特效类型的支持情况。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature\Feature;
|
||||
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 管理员聊天命令功能测试
|
||||
* 覆盖全屏特效命令的新增特效校验。
|
||||
*/
|
||||
class AdminCommandControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试站长可以触发全部新增全屏特效。
|
||||
*/
|
||||
public function test_super_admin_can_trigger_all_new_effect_types(): void
|
||||
{
|
||||
$admin = User::factory()->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:商店控制器功能测试
|
||||
* 覆盖商品列表、购买、改名卡与新增特效商品展示等商店流程。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
@@ -8,10 +13,17 @@ use App\Models\UserPurchase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 商店控制器功能测试
|
||||
* 验证商店接口对商品与购买逻辑的返回结果。
|
||||
*/
|
||||
class ShopControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试商品列表接口只返回上架商品。
|
||||
*/
|
||||
public function test_items_returns_active_shop_items()
|
||||
{
|
||||
$user = User::factory()->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']);
|
||||
|
||||
Reference in New Issue
Block a user