Add new chat effects and shop items

This commit is contained in:
2026-04-12 16:48:58 +08:00
parent 33a3e5d118
commit 70cb170f2c
25 changed files with 1707 additions and 60 deletions

View File

@@ -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 附带赠言

View File

@@ -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',

View File

@@ -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}"]);
}

View File

@@ -124,6 +124,12 @@ class ShopController extends Controller
'rain' => '🌧',
'lightning' => '⚡',
'snow' => '❄️',
'sakura' => '🌸',
'meteors' => '🌠',
'gold-rain' => '🪙',
'hearts' => '💖',
'confetti' => '🎊',
'fireflies' => '✨',
];
// 赠礼消息文案(改成"为XX触发了一场特效"
$icon = $icons[$result['play_effect']] ?? '✨';

View File

@@ -60,6 +60,12 @@ class VipCenterController extends Controller
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
'sakura' => '樱花飘落',
'meteors' => '流星',
'gold-rain' => '金币雨',
'hearts' => '爱心飘落',
'confetti' => '彩带庆典',
'fireflies' => '萤火虫',
],
'bannerStyleOptions' => [
'aurora' => '鎏光星幕',

View File

@@ -31,6 +31,12 @@ class VipLevel extends Model
'rain',
'lightning',
'snow',
'sakura',
'meteors',
'gold-rain',
'hearts',
'confetti',
'fireflies',
];
/**

View File

@@ -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();
}
};

View File

@@ -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) {

View 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 };
})();

View File

@@ -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();

View File

@@ -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;
}

View 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 };
})();

View 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
View 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 };
})();

View File

@@ -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);
}

View 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
View 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 };
})();

View File

@@ -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);

View File

@@ -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')

View File

@@ -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

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View 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',
]);
}
}
}

View File

@@ -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']);