Compare commits
14 Commits
45ce8b2b2d
...
06864a9cec
| Author | SHA1 | Date | |
|---|---|---|---|
| 06864a9cec | |||
| 622bc94377 | |||
| 575e92e03f | |||
| 522eea72f6 | |||
| fc7930046d | |||
| 7ba7b34ca7 | |||
| 3eaf37a648 | |||
| 221f629ec2 | |||
| 18acd7d890 | |||
| 09a2b0d85f | |||
| b60f3615c1 | |||
| 363c45a140 | |||
| 181cc6a0b0 | |||
| 3c95478097 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "chatroom-local-marketplace",
|
||||
"interface": {
|
||||
"displayName": "Chatroom Local Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "chatroom-ride-development",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/chatroom-ride-development"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,9 @@ enum CurrencySource: string
|
||||
/** 商城购买消耗(扣除金币) */
|
||||
case SHOP_BUY = 'shop_buy';
|
||||
|
||||
/** 购买聊天室座驾消耗(扣除金币) */
|
||||
case RIDE_BUY = 'ride_buy';
|
||||
|
||||
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
||||
case ADMIN_ADJUST = 'admin_adjust';
|
||||
|
||||
@@ -174,6 +177,7 @@ enum CurrencySource: string
|
||||
self::RECV_GIFT => '收到礼物',
|
||||
self::NEWBIE_BONUS => '新人礼包',
|
||||
self::SHOP_BUY => '商城购买',
|
||||
self::RIDE_BUY => '座驾购买',
|
||||
self::ADMIN_ADJUST => '管理员调整',
|
||||
self::POSITION_REWARD => '职务奖励',
|
||||
self::SIGN_IN => '每日签到',
|
||||
|
||||
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
|
||||
*/
|
||||
class EffectBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
/**
|
||||
* 支持的特效类型列表(用于校验)
|
||||
*/
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||
* @param string $operator 触发特效的用户名(购买者)
|
||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||
* @param string|null $giftMessage 附带赠言
|
||||
* @param string|null $effectTitle 特效画面标题
|
||||
* @param string|null $rideName 座驾名称
|
||||
* @param string|null $effectUserInfo 特效画面用户身份信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
public readonly string $operator,
|
||||
public readonly ?string $targetUsername = null,
|
||||
public readonly ?string $giftMessage = null,
|
||||
public readonly ?string $effectTitle = null,
|
||||
public readonly ?string $rideName = null,
|
||||
public readonly ?string $effectUserInfo = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -70,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
'operator' => $this->operator,
|
||||
'target_username' => $this->targetUsername, // null = 全员
|
||||
'gift_message' => $this->giftMessage,
|
||||
'effect_title' => $this->effectTitle,
|
||||
'ride_name' => $this->rideName,
|
||||
'effect_user_info' => $this->effectUserInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台座驾独立管理控制器。
|
||||
*
|
||||
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreRideRequest;
|
||||
use App\Http\Requests\UpdateRideRequest;
|
||||
use App\Models\Ride;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 后台座驾管理控制器
|
||||
* 负责独立 rides 表的后台管理流程。
|
||||
*/
|
||||
class RideController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示座驾管理列表页。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rides = Ride::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return view('admin.rides.index', compact('rides'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增座驾(仅 id=1 超级站长)。
|
||||
*/
|
||||
public function store(StoreRideRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
Ride::create($data);
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新座驾信息。
|
||||
*/
|
||||
public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse
|
||||
{
|
||||
$ride->update($request->validated());
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换座驾上下架状态。
|
||||
*/
|
||||
public function toggle(Ride $ride): RedirectResponse
|
||||
{
|
||||
$ride->update(['is_active' => ! $ride->is_active]);
|
||||
$status = $ride->is_active ? '上架' : '下架';
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', "「{$ride->name}」已{$status}。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除座驾(仅 id=1 超级站长)。
|
||||
*/
|
||||
public function destroy(Ride $ride): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$name = $ride->name;
|
||||
$ride->delete();
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreShopItemRequest;
|
||||
use App\Http\Requests\UpdateShopItemRequest;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
|
||||
/**
|
||||
* 新增商品(仅 id=1 超级站长)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreShopItemRequest $request): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$data = $this->validateItem($request);
|
||||
$data = $request->validated();
|
||||
ShopItem::create($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, ShopItem $shopItem): RedirectResponse
|
||||
public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
$data = $this->validateItem($request, $shopItem);
|
||||
$data = $request->validated();
|
||||
$shopItem->update($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
||||
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一验证商品表单(新增/编辑共用)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateItem(Request $request, ?ShopItem $item = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'slug' => ['required', 'string', 'max:100',
|
||||
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
|
||||
],
|
||||
'icon' => 'required|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'price' => 'required|integer|min:0',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
|
||||
'duration_days' => 'nullable|integer|min:0',
|
||||
'duration_minutes' => 'nullable|integer|min:0',
|
||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
||||
'charm_bonus' => 'nullable|integer|min:0',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\MessageFilterService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\RideService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
@@ -65,6 +66,7 @@ class ChatController extends Controller
|
||||
private readonly AppointmentService $appointmentService,
|
||||
private readonly RoomBroadcastService $broadcast,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
private readonly RideService $rideService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,6 +118,8 @@ class ChatController extends Controller
|
||||
|
||||
// 3. 广播和初始化欢迎(仅限初次进入)
|
||||
$newbieEffect = null;
|
||||
$initialRideEffect = null;
|
||||
$initialRideEffectOptions = null;
|
||||
$initialPresenceTheme = null;
|
||||
$initialWelcomeMessage = null;
|
||||
$initialWelcomeMessages = [];
|
||||
@@ -192,40 +196,84 @@ class ChatController extends Controller
|
||||
}
|
||||
|
||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
||||
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
|
||||
if (! $ridePresencePayload) {
|
||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
||||
|
||||
$generalWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||
'is_secret' => false,
|
||||
'font_color' => $color,
|
||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'entry_broadcast',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$generalWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||
'is_secret' => false,
|
||||
'font_color' => $color,
|
||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'entry_broadcast',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||
if (! empty($vipPresencePayload)) {
|
||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||
$initialPresenceTheme = $vipPresencePayload;
|
||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||
if (! empty($vipPresencePayload)) {
|
||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||
$initialPresenceTheme = $vipPresencePayload;
|
||||
}
|
||||
|
||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
||||
|
||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||||
|
||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
||||
}
|
||||
}
|
||||
|
||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
||||
if ($ridePresencePayload) {
|
||||
$rideWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '座驾播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "<span style=\"color:#0f766e;font-weight:bold;\">{$ridePresencePayload['ride_icon']} {$ridePresencePayload['identity_text']} · {$ridePresencePayload['welcome_text']}</span>",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#0f766e',
|
||||
'action' => 'ride_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'ride_presence',
|
||||
'ride_key' => $ridePresencePayload['ride_key'],
|
||||
'ride_name' => $ridePresencePayload['ride_name'],
|
||||
'effect_title' => $ridePresencePayload['effect_title'],
|
||||
'effect_user_info' => $ridePresencePayload['effect_user_info'],
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||||
// 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
|
||||
$this->chatState->pushMessage($id, $rideWelcomeMsg);
|
||||
broadcast(new MessageSent($id, $rideWelcomeMsg));
|
||||
broadcast(new \App\Events\EffectBroadcast(
|
||||
$id,
|
||||
$ridePresencePayload['ride_key'],
|
||||
$user->username,
|
||||
effectTitle: $ridePresencePayload['effect_title'],
|
||||
rideName: $ridePresencePayload['ride_name'],
|
||||
effectUserInfo: $ridePresencePayload['effect_user_info'],
|
||||
))->toOthers();
|
||||
|
||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
||||
$initialRideEffect = $ridePresencePayload['ride_key'];
|
||||
$initialRideEffectOptions = [
|
||||
'effect_title' => $ridePresencePayload['effect_title'],
|
||||
'effect_user_info' => $ridePresencePayload['effect_user_info'],
|
||||
'ride_name' => $ridePresencePayload['ride_name'],
|
||||
'operator' => $user->username,
|
||||
];
|
||||
$initialWelcomeMessages[] = $rideWelcomeMsg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +362,8 @@ class ChatController extends Controller
|
||||
'user' => $user,
|
||||
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
||||
'newbieEffect' => $newbieEffect,
|
||||
'initialRideEffect' => $initialRideEffect,
|
||||
'initialRideEffectOptions' => $initialRideEffectOptions,
|
||||
'initialPresenceTheme' => $initialPresenceTheme,
|
||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾前台接口控制器。
|
||||
*
|
||||
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\BuyRideRequest;
|
||||
use App\Models\Ride;
|
||||
use App\Models\Room;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\RideService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 聊天室座驾控制器
|
||||
* 负责前台座驾页面的数据读取与购买操作。
|
||||
*/
|
||||
class RideController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造座驾控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RideService $rideService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取座驾页面需要的商品、当前座驾和购买记录。
|
||||
*/
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->rideService->activeItems()
|
||||
->map(fn (Ride $item) => $this->rideService->formatItem($item))
|
||||
->values(),
|
||||
'current_ride' => $this->rideService->formatCurrentRide($user),
|
||||
'purchases' => $this->rideService->purchaseRecords($user),
|
||||
'user_jjb' => $user->jjb ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买座驾并返回最新金币和当前座驾状态。
|
||||
*/
|
||||
public function buy(BuyRideRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->integer('room_id');
|
||||
$room = Room::query()->findOrFail($roomId);
|
||||
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403);
|
||||
}
|
||||
|
||||
$item = Ride::query()->findOrFail((int) $request->integer('item_id'));
|
||||
$result = $this->rideService->buy($user, $item, $roomId);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $result['message'],
|
||||
'current_ride' => $result['current_ride'] ?? null,
|
||||
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
|
||||
'jjb' => $user->fresh()->jjb,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,23 @@ class ShopController extends Controller
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$items = ShopItem::active()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
$items = ShopItem::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
|
||||
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台座驾购买请求验证。
|
||||
*
|
||||
* 校验用户购买座驾时传入的座驾与房间上下文,避免未进房直接购买聊天室座驾。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 座驾购买请求
|
||||
* 负责校验座驾 ID 与当前房间 ID。
|
||||
*/
|
||||
class BuyRideRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许购买座驾。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾购买请求验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'item_id' => ['required', 'integer', 'exists:rides,id'],
|
||||
'room_id' => ['required', 'integer', 'exists:rooms,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾购买请求中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'item_id.required' => '请选择要购买的座驾。',
|
||||
'item_id.exists' => '座驾不存在或已被删除。',
|
||||
'room_id.required' => '请先进入聊天室后再购买座驾。',
|
||||
'room_id.exists' => '当前房间不存在。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台新增座驾请求验证。
|
||||
*
|
||||
* 校验座驾独立模块的名称、特效 key、价格、使用天数、欢迎语和上下架状态。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台新增座驾请求
|
||||
* 负责新增座驾时的权限与字段校验。
|
||||
*/
|
||||
class StoreRideRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许新增座驾。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->id === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取新增座驾验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')],
|
||||
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'duration_days' => ['required', 'integer', 'min:1'],
|
||||
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取新增座驾中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
|
||||
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
|
||||
'duration_days.min' => '使用天数至少为 1 天。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台新增商店商品请求验证。
|
||||
*
|
||||
* 统一校验商店商品字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台新增商店商品请求
|
||||
* 负责新增商品时的权限与字段校验。
|
||||
*/
|
||||
class StoreShopItemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许新增商品。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->id === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取新增商品验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许后台配置的商品类型。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedTypes(): array
|
||||
{
|
||||
return [
|
||||
'instant',
|
||||
'duration',
|
||||
'one_time',
|
||||
'ring',
|
||||
'auto_fishing',
|
||||
ShopItem::TYPE_SIGN_REPAIR,
|
||||
'msg_bubble',
|
||||
'msg_name_color',
|
||||
'msg_text_color',
|
||||
'avatar_frame',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台更新座驾请求验证。
|
||||
*
|
||||
* 校验座驾编辑时的唯一标识、价格、使用天数和欢迎语配置。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台更新座驾请求
|
||||
* 负责编辑座驾时的权限与字段校验。
|
||||
*/
|
||||
class UpdateRideRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许编辑座驾。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取更新座驾验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$ride = $this->route('ride');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')->ignore($ride?->id)],
|
||||
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')->ignore($ride?->id)],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'duration_days' => ['required', 'integer', 'min:1'],
|
||||
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取更新座驾中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
|
||||
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
|
||||
'duration_days.min' => '使用天数至少为 1 天。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台更新商店商品请求验证。
|
||||
*
|
||||
* 统一校验商店商品编辑字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台更新商店商品请求
|
||||
* 负责编辑商品时的权限与字段校验。
|
||||
*/
|
||||
class UpdateShopItemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许编辑商品。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取更新商品验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$shopItem = $this->route('shopItem');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')->ignore($shopItem?->id)],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许后台配置的商品类型。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedTypes(): array
|
||||
{
|
||||
return [
|
||||
'instant',
|
||||
'duration',
|
||||
'one_time',
|
||||
'ring',
|
||||
'auto_fishing',
|
||||
ShopItem::TYPE_SIGN_REPAIR,
|
||||
'msg_bubble',
|
||||
'msg_name_color',
|
||||
'msg_text_color',
|
||||
'avatar_frame',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾模型。
|
||||
*
|
||||
* 对应 rides 表,保存座驾名称、特效 key、价格、使用天数、欢迎语与上下架状态。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 聊天室座驾模型
|
||||
* 负责提供座驾定义、全屏特效 key 和购买记录关系。
|
||||
*/
|
||||
class Ride extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'effect_key', 'icon', 'description', 'price',
|
||||
'duration_days', 'welcome_message', 'sort_order', 'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取座驾对应的所有购买记录。
|
||||
*/
|
||||
public function purchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserRidePurchase::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾全屏特效 key。
|
||||
*/
|
||||
public function rideKey(): string
|
||||
{
|
||||
return $this->effect_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有上架座驾。
|
||||
*
|
||||
* @return Collection<int, self>
|
||||
*/
|
||||
public static function active(): Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
class ShopItem extends Model
|
||||
{
|
||||
public const TYPE_SIGN_REPAIR = 'sign_repair';
|
||||
|
||||
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
|
||||
|
||||
protected $table = 'shop_items';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户座驾购买记录模型。
|
||||
*
|
||||
* 对应 user_ride_purchases 表,追踪用户座驾购买、续期、替换和过期状态。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 用户座驾购买记录模型
|
||||
* 负责连接用户与座驾,并判断当前记录是否仍有效。
|
||||
*/
|
||||
class UserRidePurchase extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'ride_id', 'status', 'price_paid', 'expires_at', 'used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取购买记录所属用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取购买记录对应座驾。
|
||||
*/
|
||||
public function ride(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ride::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断座驾购买记录是否仍然有效。
|
||||
*/
|
||||
public function isAlive(): bool
|
||||
{
|
||||
if ($this->status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾业务服务。
|
||||
*
|
||||
* 统一管理座驾商品列表、购买续期、当前激活座驾、购买记录和入场欢迎语载荷。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\Ride;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRidePurchase;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 聊天室座驾服务
|
||||
* 负责通过 rides 与 user_ride_purchases 完成座驾购买、续期、替换与进房展示。
|
||||
*/
|
||||
class RideService
|
||||
{
|
||||
/**
|
||||
* 构造座驾服务依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取全部上架座驾商品。
|
||||
*
|
||||
* @return Collection<int, Ride>
|
||||
*/
|
||||
public function activeItems(): Collection
|
||||
{
|
||||
return Ride::active();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化座驾商品,供前端页面直接渲染。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function formatItem(Ride $item): array
|
||||
{
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'ride_key' => $item->rideKey(),
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'duration_days' => (int) ($item->duration_days ?? 0),
|
||||
'welcome_message' => $item->welcome_message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前有效座驾,若已过期则自动标记为 expired。
|
||||
*/
|
||||
public function currentRide(User $user): ?UserRidePurchase
|
||||
{
|
||||
$purchase = UserRidePurchase::query()
|
||||
->with('ride')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
|
||||
if (! $purchase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($purchase->expires_at && $purchase->expires_at->isPast()) {
|
||||
// 过期座驾必须及时落库,避免后续进房继续播放旧特效。
|
||||
$purchase->update(['status' => 'expired']);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户当前座驾。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function formatCurrentRide(User $user): ?array
|
||||
{
|
||||
$purchase = $this->currentRide($user);
|
||||
if (! $purchase || ! $purchase->ride) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->formatPurchase($purchase);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近座驾购买记录。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function purchaseRecords(User $user, int $limit = 20): array
|
||||
{
|
||||
return UserRidePurchase::query()
|
||||
->with('ride')
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (UserRidePurchase $purchase) => $this->formatPurchase($purchase))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买座驾:同款续期,不同款替换旧座驾且不退款。
|
||||
*
|
||||
* @return array{ok:bool, message:string, current_ride?:array<string, mixed>}
|
||||
*/
|
||||
public function buy(User $user, Ride $item, ?int $roomId = null): array
|
||||
{
|
||||
if (! $item->is_active) {
|
||||
return ['ok' => false, 'message' => '该座驾暂未上架。'];
|
||||
}
|
||||
|
||||
$days = (int) ($item->duration_days ?? 0);
|
||||
if ($days <= 0) {
|
||||
return ['ok' => false, 'message' => '该座驾使用天数配置异常,请联系管理员。'];
|
||||
}
|
||||
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
$purchased = DB::transaction(function () use ($user, $item, $days, $roomId): bool {
|
||||
$now = Carbon::now();
|
||||
|
||||
// 先清理已过期的 active 座驾,避免旧状态影响替换判断。
|
||||
UserRidePurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', $now)
|
||||
->update(['status' => 'expired']);
|
||||
|
||||
$activeRide = UserRidePurchase::query()
|
||||
->with('ride')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
|
||||
$balanceAfter = $this->currencyService->deductGoldIfEnough(
|
||||
$user,
|
||||
(int) $item->price,
|
||||
CurrencySource::RIDE_BUY,
|
||||
"购买聊天室座驾:{$item->name}",
|
||||
$roomId,
|
||||
);
|
||||
|
||||
if ($balanceAfter === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($activeRide && (int) $activeRide->ride_id === (int) $item->id) {
|
||||
$baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now)
|
||||
? $activeRide->expires_at
|
||||
: $now;
|
||||
|
||||
// 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。
|
||||
$activeRide->update(['status' => 'cancelled']);
|
||||
UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $baseTime->copy()->addDays($days),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($activeRide) {
|
||||
// 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。
|
||||
$activeRide->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $now->copy()->addDays($days),
|
||||
]);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (! $purchased) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->fresh()->jjb} 金币。"];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。",
|
||||
'current_ride' => $this->formatCurrentRide($user->fresh()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建进房座驾欢迎语与特效载荷。
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function buildPresencePayload(User $user): ?array
|
||||
{
|
||||
$purchase = $this->currentRide($user);
|
||||
$item = $purchase?->ride;
|
||||
$rideKey = $item?->rideKey();
|
||||
|
||||
if (! $purchase || ! $item || ! $rideKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = trim((string) ($item->welcome_message ?: '【{name}】驾驶【{ride}】震撼入场,全场请注意!'));
|
||||
$rendered = strtr($template, [
|
||||
'{name}' => $user->username,
|
||||
'{ride}' => $item->name,
|
||||
]);
|
||||
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
|
||||
$effectUserInfo = "用户 {$user->username} · {$identitySummary['inline']}";
|
||||
|
||||
return [
|
||||
'ride_key' => $rideKey,
|
||||
'ride_name' => $item->name,
|
||||
'ride_icon' => (string) ($item->icon ?? '🚘'),
|
||||
'effect_title' => "乘坐【{$item->name}】闪亮登场",
|
||||
'effect_user_info' => $effectUserInfo,
|
||||
'identity_text' => ChatContentSanitizer::htmlText($identitySummary['inline']),
|
||||
'welcome_text' => ChatContentSanitizer::htmlText($rendered),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条座驾购买记录。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPurchase(UserRidePurchase $purchase): array
|
||||
{
|
||||
$item = $purchase->ride;
|
||||
|
||||
return [
|
||||
'id' => $purchase->id,
|
||||
'status' => $purchase->status,
|
||||
'price_paid' => (int) $purchase->price_paid,
|
||||
'expires_at' => $purchase->expires_at?->toDateTimeString(),
|
||||
'used_at' => $purchase->used_at?->toDateTimeString(),
|
||||
'created_at' => $purchase->created_at?->toDateTimeString(),
|
||||
'item' => $item ? $this->formatItem($item) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建聊天室座驾独立数据表。
|
||||
*
|
||||
* 座驾定义和用户座驾购买记录独立于商店模块,支持后台单独配置价格、使用天数和欢迎语。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:创建 rides 与 user_ride_purchases 并预置默认座驾。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('rides', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 100)->comment('座驾名称');
|
||||
$table->string('slug', 100)->unique()->comment('座驾唯一标识,格式 ride_key');
|
||||
$table->string('effect_key', 50)->unique()->comment('全屏特效 key');
|
||||
$table->string('icon', 20)->default('🚘')->comment('座驾图标');
|
||||
$table->text('description')->nullable()->comment('座驾说明');
|
||||
$table->unsignedInteger('price')->default(0)->comment('购买价格');
|
||||
$table->unsignedInteger('duration_days')->default(7)->comment('使用天数');
|
||||
$table->string('welcome_message', 255)->nullable()->comment('入场欢迎语模板');
|
||||
$table->unsignedInteger('sort_order')->default(0)->comment('排序权重');
|
||||
$table->boolean('is_active')->default(true)->comment('是否上架');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('user_ride_purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('ride_id')->constrained('rides')->cascadeOnDelete();
|
||||
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active')->comment('座驾状态');
|
||||
$table->unsignedInteger('price_paid')->default(0)->comment('实际支付金币');
|
||||
$table->timestamp('expires_at')->nullable()->comment('到期时间');
|
||||
$table->timestamp('used_at')->nullable()->comment('首次使用时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status', 'expires_at']);
|
||||
});
|
||||
|
||||
$this->seedDefaultRides();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除座驾独立数据表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_ride_purchases');
|
||||
Schema::dropIfExists('rides');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:写入当前默认四个全屏座驾。
|
||||
*/
|
||||
private function seedDefaultRides(): void
|
||||
{
|
||||
$now = now();
|
||||
$rides = [
|
||||
[
|
||||
'name' => '歼-35隐身战机',
|
||||
'slug' => 'ride_j35',
|
||||
'effect_key' => 'j35',
|
||||
'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。',
|
||||
'icon' => '🛩️',
|
||||
'price' => 18888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 80,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!',
|
||||
],
|
||||
[
|
||||
'name' => '99A主战坦克',
|
||||
'slug' => 'ride_99a',
|
||||
'effect_key' => '99a',
|
||||
'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。',
|
||||
'icon' => '🛡️',
|
||||
'price' => 18888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 81,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!',
|
||||
],
|
||||
[
|
||||
'name' => '东风-5C战略导弹',
|
||||
'slug' => 'ride_df5c',
|
||||
'effect_key' => 'df5c',
|
||||
'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。',
|
||||
'icon' => '🚀',
|
||||
'price' => 28888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 82,
|
||||
'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!',
|
||||
],
|
||||
[
|
||||
'name' => '福建舰航母',
|
||||
'slug' => 'ride_fujian',
|
||||
'effect_key' => 'fujian',
|
||||
'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。',
|
||||
'icon' => '⚓',
|
||||
'price' => 28888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 83,
|
||||
'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rides as $ride) {
|
||||
DB::table('rides')->insert($ride + [
|
||||
'is_active' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:清理旧版商店座驾数据。
|
||||
*
|
||||
* 如果环境曾跑过“座驾复用商店”的旧迁移,本迁移会移除商店里的座驾字段和残留记录。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:删除 shop_items 中旧座驾记录并移除欢迎语字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('shop_items')->where('slug', 'like', 'ride_%')->delete();
|
||||
|
||||
if (DB::getDriverName() === 'mysql' && $this->shopItemTypeContainsRide()) {
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('shop_items', 'welcome_message')) {
|
||||
Schema::table('shop_items', function (Blueprint $table) {
|
||||
$table->dropColumn('welcome_message');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:清理迁移不恢复旧商店座驾结构。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断当前 shop_items.type 枚举是否包含旧座驾类型。
|
||||
*/
|
||||
private function shopItemTypeContainsRide(): bool
|
||||
{
|
||||
$column = DB::selectOne("SHOW COLUMNS FROM `shop_items` LIKE 'type'");
|
||||
|
||||
return $column && str_contains((string) $column->Type, "'ride'");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "chatroom-ride-development",
|
||||
"version": "0.2.0",
|
||||
"description": "聊天室座驾开发插件,沉淀独立座驾模块、金币流水、特效注册、命名规则和测试清单。",
|
||||
"interface": {
|
||||
"displayName": "Chatroom Ride Development"
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"name": "chatroom-ride-development",
|
||||
"path": "skills/chatroom-ride-development/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: chatroom-ride-development
|
||||
description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_<key> 独立座驾、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。"
|
||||
---
|
||||
|
||||
# Chatroom Ride Development
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增或修改聊天室独立座驾。
|
||||
- 新增 `resources/js/effects/<key>.js` 全屏座驾特效。
|
||||
- 调整座驾购买、续期、替换、入场欢迎语或后台价格/天数配置。
|
||||
- 排查座驾进房后特效不播放、欢迎语不显示、购买记录不正确的问题。
|
||||
- 排查座驾购买没有金币流水、后台座驾列表为空、前台座驾弹窗为空的问题。
|
||||
|
||||
## 必须遵守
|
||||
|
||||
- 座驾是独立模块:必须使用 `rides` 和 `user_ride_purchases`,不要写入 `shop_items` 或 `user_purchases`。
|
||||
- 商店模块只管理普通商店商品;不要给 `ShopItem` 增加 `TYPE_RIDE`、`isRide()`、`rideKey()` 或座驾欢迎语字段。
|
||||
- 座驾 slug 固定为 `ride_<effect_key>`。
|
||||
- `<effect_key>` 必须同时出现在:
|
||||
- `rides.slug`
|
||||
- `rides.effect_key`
|
||||
- `Ride::rideKey()` 可解析结果
|
||||
- `EffectBroadcast::TYPES`
|
||||
- `resources/js/effects/effect-manager.js`
|
||||
- `resources/js/effects/effect-sounds.js`
|
||||
- `resources/js/effects/<effect_key>.js`
|
||||
- 新增座驾默认通过迁移或 Seeder 写入 `rides`,字段至少包含名称、slug、`effect_key`、图标、价格、`duration_days`、排序和 `welcome_message`。
|
||||
- `welcome_message` 支持 `{name}` 和 `{ride}`,输出前必须转义,不能直接信任后台输入。
|
||||
- 当前版本只允许用户同时拥有一个 active 座驾;同款续购叠加有效期,不同款替换旧座驾并把旧记录置为 `cancelled`。
|
||||
- 用户购买记录必须写入 `user_ride_purchases`,不要复用商店购买记录。
|
||||
- 座驾购买扣金币必须走 `UserCurrencyService::deductGoldIfEnough()`,来源使用 `CurrencySource::RIDE_BUY`,并写入 `user_currency_logs`。
|
||||
- 座驾购买接口必须携带房间 ID,并把 `room_id` 传给金币流水,方便后台按房间追溯。
|
||||
- 测试中禁止使用 `Redis::flushall()` 清理聊天室状态;只允许清理 `room:*` 相关 key,避免把本机登录 session 一起删掉。
|
||||
|
||||
## 模块边界
|
||||
|
||||
- 前台接口:`GET /rides/items`、`POST /rides/buy`。
|
||||
- 后台入口:`GET /admin/rides`,页面文件为 `resources/views/admin/rides/index.blade.php`。
|
||||
- 前台弹窗脚本:`resources/js/chat-room/ride-controls.js`。
|
||||
- 业务服务:`app/Services/RideService.php`。
|
||||
- 数据模型:`app/Models/Ride.php`、`app/Models/UserRidePurchase.php`。
|
||||
- 请求验证:`BuyRideRequest`、`StoreRideRequest`、`UpdateRideRequest`。
|
||||
|
||||
## 已验收的进房展示规则
|
||||
|
||||
- 用户有有效座驾时,座驾优先级最高:
|
||||
- 只发送一条 `座驾播报` 文字消息。
|
||||
- 不再发送普通 `进出播报`。
|
||||
- 不再播放会员进场横幅或会员全屏特效。
|
||||
- 座驾全屏特效播放范围是当前房间内所有在线用户:
|
||||
- 进房用户本人通过 `initialRideEffect` 本地播放。
|
||||
- 其他在线用户通过 `EffectBroadcast` 的 `room.{roomId}` PresenceChannel 播放。
|
||||
- 不设置 `target_username`,表示当前房间全员可见。
|
||||
- 座驾文字播报口径:
|
||||
- 显示 `部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`。
|
||||
- 不在身份行前面显示 `用户 <用户名>`,因为后面的欢迎语模板已经包含 `{name}`。
|
||||
- 示例:`🚀 部门 办公厅 · 职务 🎖️ 技术总监 · 会员 👑 至尊会员 · 【流星】乘【东风-5C战略导弹】点火升空...`
|
||||
- 座驾动画 HUD 标题口径:
|
||||
- 第一行用户身份信息显示 `用户 <用户名> · 部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`。
|
||||
- 第二行标题只显示 `乘坐【<座驾名称>】闪亮登场`,不要重复用户名。
|
||||
- `effect_user_info` 供动画 HUD 第一行使用,`effect_title` 供动画 HUD 第二行使用,`identity_text` 只供文字播报使用。
|
||||
- 前端改动后如果浏览器仍显示旧动画标题,必须运行 `npm run build` 或确认 Vite dev server 已刷新,避免加载旧的 `public/build` 资源。
|
||||
|
||||
## 新增座驾步骤
|
||||
|
||||
1. 新增全屏特效文件:`resources/js/effects/<effect_key>.js`。
|
||||
2. 在 `effect-manager.js` 注册模块加载和启动分支。
|
||||
3. 在 `effect-sounds.js` 注册音效启动分支。
|
||||
4. 在 `EffectBroadcast::TYPES` 加入 `<effect_key>`。
|
||||
5. 在迁移或 Seeder 中新增 `rides` 记录,slug 使用 `ride_<effect_key>`,`effect_key` 使用 `<effect_key>`。
|
||||
6. 确认后台 `座驾管理` 可以配置价格、使用天数、欢迎语、上下架状态和排序。
|
||||
7. 确认 `RideService::buy()` 购买时写入 `user_ride_purchases` 和 `user_currency_logs`。
|
||||
8. 更新座驾相关 PHPUnit 测试,至少覆盖列表、余额不足、购买成功、金币流水、续期、替换和进房触发。
|
||||
|
||||
## 金币流水要求
|
||||
|
||||
- 购买成功必须在 `user_currency_logs` 中写入:
|
||||
- `currency = gold`
|
||||
- `amount = -座驾价格`
|
||||
- `source = ride_buy`
|
||||
- `remark = 购买聊天室座驾:<座驾名称>`
|
||||
- `room_id = 当前房间 ID`
|
||||
- 余额不足时不能写 `user_ride_purchases`,也不能写 `user_currency_logs`。
|
||||
- 不要用 `$user->decrement('jjb', ...)` 直接扣金币。
|
||||
|
||||
## 旧方案清理要求
|
||||
|
||||
- 不要恢复 `add_ride_fields_to_shop_items` 这类把座驾挂到商店表的迁移。
|
||||
- 允许保留清理迁移,用于删除旧环境中 `shop_items.slug like ride_%`、`shop_items.type = ride` 或 `shop_items.welcome_message` 残留。
|
||||
- 修改座驾模块后,检查 `rg 'TYPE_RIDE|ShopItem::rideKey|RideItemController|复用商店购买记录'`,确认没有旧方案引用。
|
||||
|
||||
## 验证清单
|
||||
|
||||
- `node --check resources/js/effects/<effect_key>.js`
|
||||
- `node --check resources/js/effects/effect-manager.js`
|
||||
- `node --check resources/js/effects/effect-sounds.js`
|
||||
- `php artisan test --compact tests/Feature/RideControllerTest.php`
|
||||
- 有进房逻辑变更时运行相关 `ChatControllerTest` 过滤用例。
|
||||
- 涉及商店边界时运行 `php artisan test --compact tests/Feature/ShopControllerTest.php`。
|
||||
- 修改 PHP 后运行 `vendor/bin/pint --dirty --format=agent`。
|
||||
|
||||
## 特别注意
|
||||
|
||||
- 如果从 stash 恢复昨天的座驾特效,必须确认 untracked 父提交中的新特效文件也已恢复,不能只恢复已跟踪文件。
|
||||
- `99a` 这种以数字开头的 key 在 JS 对象字面量里必须加引号。
|
||||
- 新座驾的展示名可以是中文,但 effect key 必须保持小写短横线/数字/字母风格,避免前后端匹配失败。
|
||||
- 本项目 PHP 文件要求类和方法上方有中文 DocBlock;新增座驾相关 PHP 文件时必须补齐。
|
||||
@@ -139,6 +139,10 @@ const _shop = createLazyModule(
|
||||
() => import("./chat-room/shop-controls.js"),
|
||||
(mod) => mod.bindShopControls()
|
||||
);
|
||||
const _ride = createLazyModule(
|
||||
() => import("./chat-room/ride-controls.js"),
|
||||
(mod) => mod.bindRideControls()
|
||||
);
|
||||
const _compactShop = createLazyModule(
|
||||
() => import("./chat-room/compact-shop-panel.js"),
|
||||
(mod) => mod.bindCompactShopPanelControls()
|
||||
@@ -392,6 +396,13 @@ if (typeof window !== "undefined") {
|
||||
renderShop: (...args) => _shop.wrap('renderShop')(...args),
|
||||
showShopToast: (...args) => _shop.wrap('showShopToast')(...args),
|
||||
submitRename: (...args) => _shop.wrap('submitRename')(...args),
|
||||
bindRideControls: (...args) => _ride.wrap('bindRideControls')(...args),
|
||||
buyRide: (...args) => _ride.wrap('buyRide')(...args),
|
||||
closeRideModal: (...args) => _ride.wrap('closeRideModal')(...args),
|
||||
fetchRideData: (...args) => _ride.wrap('fetchRideData')(...args),
|
||||
loadRides: (...args) => _ride.wrap('loadRides')(...args),
|
||||
openRideModal: (...args) => _ride.wrap('openRideModal')(...args),
|
||||
renderRides: (...args) => _ride.wrap('renderRides')(...args),
|
||||
bindCompactShopPanelControls: (...args) => _compactShop.wrap('bindCompactShopPanelControls')(...args),
|
||||
buyCompactShopItem: (...args) => _compactShop.wrap('buyCompactShopItem')(...args),
|
||||
closeCompactRenameModal: (...args) => _compactShop.wrap('closeCompactRenameModal')(...args),
|
||||
@@ -628,6 +639,12 @@ if (typeof window !== "undefined") {
|
||||
window.renderShop = (...args) => _shop.wrap('renderShop')(...args);
|
||||
window.showShopToast = (...args) => _shop.wrap('showShopToast')(...args);
|
||||
window.submitRename = (...args) => _shop.wrap('submitRename')(...args);
|
||||
window.buyRide = (...args) => _ride.wrap('buyRide')(...args);
|
||||
window.closeRideModal = (...args) => _ride.wrap('closeRideModal')(...args);
|
||||
window.fetchRideData = (...args) => _ride.wrap('fetchRideData')(...args);
|
||||
window.loadRides = (...args) => _ride.wrap('loadRides')(...args);
|
||||
window.openRideModal = (...args) => _ride.wrap('openRideModal')(...args);
|
||||
window.renderRides = (...args) => _ride.wrap('renderRides')(...args);
|
||||
window.closeAvatarPicker = (...args) => _profile.wrap('closeAvatarPicker')(...args);
|
||||
window.closeSettingsModal = (...args) => _profile.wrap('closeSettingsModal')(...args);
|
||||
window.copyWechatBindCode = (...args) => _profile.wrap('copyWechatBindCode')(...args);
|
||||
|
||||
@@ -46,6 +46,19 @@ export function bindAdminMenuControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectPreviewButton = event.target.closest("[data-chat-admin-effect-preview]");
|
||||
if (effectPreviewButton) {
|
||||
event.preventDefault();
|
||||
const effect = effectPreviewButton.getAttribute("data-chat-admin-effect-preview") || "";
|
||||
const menu = document.getElementById("admin-menu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
// 预览按钮仅在当前浏览器播放,方便测试新特效时不打扰房间其他用户。
|
||||
window.EffectManager?.play?.(effect);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-chat-admin-menu]")) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@@ -482,10 +482,16 @@ export function bindChatEvents() {
|
||||
const target = e.detail?.target_username;
|
||||
const operator = e.detail?.operator;
|
||||
const myName = window.chatContext?.username;
|
||||
const effectOptions = {
|
||||
effect_title: e.detail?.effect_title,
|
||||
effect_user_info: e.detail?.effect_user_info,
|
||||
ride_name: e.detail?.ride_name,
|
||||
operator,
|
||||
};
|
||||
|
||||
if (type && typeof EffectManager !== "undefined") {
|
||||
if (!target || target === myName || operator === myName) {
|
||||
EffectManager.play(type);
|
||||
EffectManager.play(type, effectOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
|
||||
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
|
||||
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
|
||||
export const HEARTBEAT_INTERVAL = 60 * 1000;
|
||||
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
|
||||
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "座驾播报", "系统", "欢迎", "系统播报", "神秘箱子"];
|
||||
|
||||
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说")
|
||||
export const ACTION_TEXT_MAP = {
|
||||
|
||||
@@ -81,7 +81,7 @@ function playEntryEffect(initialState) {
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.EffectManager?.play?.(initialState.entryEffect);
|
||||
window.EffectManager?.play?.(initialState.entryEffect, initialState.entryEffectOptions || {});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// 聊天室座驾弹窗模块,负责座驾列表、购买、当前座驾和购买记录展示。
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
|
||||
const DEFAULT_RIDE_ITEMS_URL = "/rides/items";
|
||||
const DEFAULT_RIDE_BUY_URL = "/rides/buy";
|
||||
|
||||
let rideEventsBound = false;
|
||||
let rideLoaded = false;
|
||||
let rideState = {
|
||||
items: [],
|
||||
currentRide: null,
|
||||
purchases: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取座驾弹窗接口地址配置。
|
||||
*
|
||||
* @returns {{items:string,buy:string}}
|
||||
*/
|
||||
function rideUrls() {
|
||||
const modal = document.getElementById("ride-modal");
|
||||
|
||||
return {
|
||||
items: modal?.dataset.rideItemsUrl || DEFAULT_RIDE_ITEMS_URL,
|
||||
buy: modal?.dataset.rideBuyUrl || DEFAULT_RIDE_BUY_URL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 CSRF Token。
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾接口请求头。
|
||||
*
|
||||
* @param {boolean} withJson 是否携带 JSON Content-Type
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function rideHeaders(withJson = false) {
|
||||
const headers = {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Accept": "application/json",
|
||||
};
|
||||
|
||||
if (withJson) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开座驾弹窗并首次加载数据。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function openRideModal() {
|
||||
const modal = document.getElementById("ride-modal");
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
if (!rideLoaded) {
|
||||
rideLoaded = true;
|
||||
void loadRides();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭座驾弹窗。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function closeRideModal() {
|
||||
const modal = document.getElementById("ride-modal");
|
||||
if (modal) {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取座驾页面数据。
|
||||
*
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
export async function fetchRideData() {
|
||||
const response = await fetch(rideUrls().items, {
|
||||
headers: rideHeaders(),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("座驾数据加载失败");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并渲染座驾页面。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function loadRides() {
|
||||
const list = document.getElementById("ride-items-list");
|
||||
if (list) {
|
||||
list.innerHTML = '<div class="ride-empty">加载中...</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchRideData();
|
||||
rideState = {
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
currentRide: data.current_ride || null,
|
||||
purchases: Array.isArray(data.purchases) ? data.purchases : [],
|
||||
};
|
||||
renderRides(data);
|
||||
} catch (error) {
|
||||
if (list) {
|
||||
list.innerHTML = '<div class="ride-empty ride-error">加载失败,请稍后重试</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染座驾弹窗全部内容。
|
||||
*
|
||||
* @param {Record<string, any>} data 接口返回数据
|
||||
* @returns {void}
|
||||
*/
|
||||
export function renderRides(data) {
|
||||
const balance = document.getElementById("ride-jjb");
|
||||
if (balance) {
|
||||
balance.textContent = Number(data.user_jjb || data.jjb || 0).toLocaleString();
|
||||
}
|
||||
|
||||
renderCurrentRide(data.current_ride || null);
|
||||
renderRideItems(Array.isArray(data.items) ? data.items : rideState.items);
|
||||
renderRidePurchases(Array.isArray(data.purchases) ? data.purchases : rideState.purchases);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染当前激活座驾。
|
||||
*
|
||||
* @param {Record<string, any>|null} currentRide 当前座驾记录
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderCurrentRide(currentRide) {
|
||||
const box = document.getElementById("ride-current");
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = currentRide?.item;
|
||||
if (!item) {
|
||||
box.innerHTML = '<span class="ride-current-empty">当前未启用座驾</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = `
|
||||
<span class="ride-current-icon">${escapeHtml(item.icon || "🚘")}</span>
|
||||
<span><b>${escapeHtml(item.name)}</b> 生效中</span>
|
||||
<span class="ride-current-expire">到期:${escapeHtml(currentRide.expires_at || "-")}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染座驾商品卡片。
|
||||
*
|
||||
* @param {Array<Record<string, any>>} items 座驾商品列表
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderRideItems(items) {
|
||||
const list = document.getElementById("ride-items-list");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div class="ride-empty">暂无上架座驾</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const activeItemId = Number(rideState.currentRide?.item?.id || 0);
|
||||
list.innerHTML = items.map((item) => {
|
||||
const isActive = Number(item.id) === activeItemId;
|
||||
const duration = Number(item.duration_days || 0);
|
||||
|
||||
return `
|
||||
<div class="ride-card${isActive ? " active" : ""}">
|
||||
<div class="ride-card-head">
|
||||
<span class="ride-card-icon">${escapeHtml(item.icon || "🚘")}</span>
|
||||
<span class="ride-card-title">${escapeHtml(item.name || "")}</span>
|
||||
${isActive ? '<span class="ride-active-badge">当前</span>' : ""}
|
||||
</div>
|
||||
<div class="ride-card-desc">${escapeHtml(item.description || "")}</div>
|
||||
<div class="ride-card-meta">
|
||||
<span>💰 ${Number(item.price || 0).toLocaleString()} 金币</span>
|
||||
<span>⏱ ${duration > 0 ? `${duration} 天` : "未配置"}</span>
|
||||
</div>
|
||||
<button type="button" class="ride-buy-btn" data-ride-buy="${Number(item.id)}">
|
||||
${isActive ? "续费座驾" : "购买座驾"}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染座驾购买记录。
|
||||
*
|
||||
* @param {Array<Record<string, any>>} purchases 购买记录
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderRidePurchases(purchases) {
|
||||
const list = document.getElementById("ride-purchase-list");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!purchases.length) {
|
||||
list.innerHTML = '<div class="ride-empty">暂无座驾购买记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = purchases.map((purchase) => {
|
||||
const item = purchase.item || {};
|
||||
const statusMap = {
|
||||
active: "使用中",
|
||||
expired: "已过期",
|
||||
cancelled: "已替换",
|
||||
used: "已使用",
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="ride-record">
|
||||
<span>${escapeHtml(item.icon || "🚘")} ${escapeHtml(item.name || "未知座驾")}</span>
|
||||
<span>${escapeHtml(statusMap[purchase.status] || purchase.status || "-")}</span>
|
||||
<span>${Number(purchase.price_paid || 0).toLocaleString()} 金币</span>
|
||||
<span>${escapeHtml(purchase.expires_at || "-")}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买或续费座驾。
|
||||
*
|
||||
* @param {number|string} itemId 商品 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function buyRide(itemId) {
|
||||
const item = rideState.items.find((entry) => Number(entry.id) === Number(itemId));
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Number(item.duration_days || 0);
|
||||
const ok = await window.chatDialog?.confirm?.(
|
||||
`确认花费 ${Number(item.price || 0).toLocaleString()} 金币购买【${item.name}】吗?\n有效期:${duration} 天\n同款续购会自动叠加有效期。`,
|
||||
"确认购买座驾",
|
||||
);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(rideUrls().buy, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: rideHeaders(true),
|
||||
body: JSON.stringify({
|
||||
item_id: Number(itemId),
|
||||
room_id: window.chatContext?.roomId || 0,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.status !== "success") {
|
||||
window.chatDialog?.alert?.(data.message || "购买失败", "座驾购买", "#cc4444");
|
||||
return;
|
||||
}
|
||||
|
||||
rideState.currentRide = data.current_ride || null;
|
||||
rideState.purchases = Array.isArray(data.purchases) ? data.purchases : [];
|
||||
renderRides({
|
||||
items: rideState.items,
|
||||
current_ride: rideState.currentRide,
|
||||
purchases: rideState.purchases,
|
||||
jjb: data.jjb,
|
||||
});
|
||||
|
||||
const shopBalance = document.getElementById("shop-jjb");
|
||||
if (shopBalance) {
|
||||
shopBalance.textContent = Number(data.jjb || 0).toLocaleString();
|
||||
}
|
||||
|
||||
window.chatDialog?.alert?.(data.message || "座驾购买成功", "座驾购买", "#16a34a");
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert?.("网络异常,请稍后重试。", "座驾购买", "#cc4444");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定座驾弹窗事件。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindRideControls() {
|
||||
if (rideEventsBound || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
rideEventsBound = true;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = event.target.closest("[data-ride-modal-close]");
|
||||
const modal = document.getElementById("ride-modal");
|
||||
if (closeButton || (modal && event.target === modal)) {
|
||||
event.preventDefault();
|
||||
closeRideModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const buyButton = event.target.closest("[data-ride-buy]");
|
||||
if (buyButton) {
|
||||
event.preventDefault();
|
||||
void buyRide(buyButton.getAttribute("data-ride-buy") || "");
|
||||
}
|
||||
});
|
||||
|
||||
window.openRideModal = openRideModal;
|
||||
window.closeRideModal = closeRideModal;
|
||||
window.loadRides = loadRides;
|
||||
window.buyRide = buyRide;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function runToolbarAction(action) {
|
||||
// 工具条只做入口分发,具体业务仍由原有全局函数负责。
|
||||
const actions = {
|
||||
shop: () => window.openShopModal?.(),
|
||||
ride: () => window.openRideModal?.(),
|
||||
vip: () => window.openVipModal?.(),
|
||||
"save-exp": () => window.saveExp?.(),
|
||||
game: () => window.openGameHall?.(),
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 文件功能:聊天室 99A 主战坦克重装入场特效
|
||||
*
|
||||
* 使用全屏透明 Canvas 绘制中国 99A 主战坦克横穿屏幕、履带滚动、
|
||||
* 长炮管炮击、楔形复合装甲、侧裙、尘土冲击波与重装入场 HUD。
|
||||
*/
|
||||
|
||||
const Type99AEffect = (() => {
|
||||
const DURATION = 8200;
|
||||
const ARMOR = "#5f6f3a";
|
||||
const DARK_ARMOR = "#1f2a1d";
|
||||
const CAMO = "#7c6a36";
|
||||
const DUST = "#fde68a";
|
||||
const FIRE = "#f97316";
|
||||
|
||||
/**
|
||||
* 缓出曲线,让坦克进场有重量感。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓入缓出曲线,用于 HUD 和冲击波。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeInOutSine(t) {
|
||||
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建地面尘土粒子。
|
||||
*
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @returns {Array<Record<string, number>>}
|
||||
*/
|
||||
function createDust(w, h) {
|
||||
return Array.from({ length: 90 }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: h * (0.66 + Math.random() * 0.18),
|
||||
speed: 1.6 + Math.random() * 4.8,
|
||||
size: 2 + Math.random() * 8,
|
||||
alpha: 0.12 + Math.random() * 0.34,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制战场式地面背景。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawBackdrop(ctx, w, h, progress) {
|
||||
const fade = Math.min(1, progress / 0.16) * Math.min(1, (1 - progress) / 0.12);
|
||||
const gradient = ctx.createRadialGradient(w * 0.5, h * 0.62, 0, w * 0.5, h * 0.62, Math.max(w, h) * 0.76);
|
||||
gradient.addColorStop(0, `rgba(41,37,36,${0.42 * fade})`);
|
||||
gradient.addColorStop(0.55, `rgba(63,98,18,${0.18 * fade})`);
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const y = h * (0.7 + i * 0.028);
|
||||
ctx.strokeStyle = `rgba(253,230,138,${0.1 * fade})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + Math.sin(progress * 12 + i) * 4);
|
||||
ctx.lineTo(w, y + Math.cos(progress * 9 + i) * 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制履带带起的尘土。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {Array<Record<string, number>>} dust 尘土粒子
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawDust(ctx, dust, w, progress) {
|
||||
const fade = Math.min(1, progress / 0.18) * Math.min(1, (1 - progress) / 0.12);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
dust.forEach((particle, index) => {
|
||||
const travel = (progress * (620 + particle.speed * 80) + index * 47) % (w + 420);
|
||||
const x = w + 210 - travel;
|
||||
ctx.globalAlpha = particle.alpha * fade;
|
||||
ctx.fillStyle = DUST;
|
||||
ctx.shadowColor = DUST;
|
||||
ctx.shadowBlur = particle.size * 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, particle.y, particle.size * 1.8, particle.size * 0.7, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制炮击冲击波。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawShockwave(ctx, w, h, progress) {
|
||||
const shot = Math.max(0, Math.min(1, (progress - 0.5) / 0.2));
|
||||
if (shot <= 0 || shot >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alpha = Math.sin(shot * Math.PI);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.strokeStyle = `rgba(253,230,138,${0.38 * alpha})`;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.48, h * 0.68, w * (0.08 + shot * 0.38), h * (0.03 + shot * 0.08), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制 99A 主炮炮口火焰。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawMuzzleFlash(ctx, progress) {
|
||||
const flash = Math.max(0, 1 - Math.abs(progress - 0.5) / 0.045);
|
||||
if (flash <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.shadowColor = FIRE;
|
||||
ctx.shadowBlur = 28;
|
||||
ctx.fillStyle = `rgba(249,115,22,${0.78 * flash})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(408, -118);
|
||||
ctx.lineTo(528, -156);
|
||||
ctx.lineTo(468, -112);
|
||||
ctx.lineTo(532, -76);
|
||||
ctx.lineTo(408, -100);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.74 * flash})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(414, -114);
|
||||
ctx.lineTo(486, -132);
|
||||
ctx.lineTo(452, -108);
|
||||
ctx.lineTo(492, -92);
|
||||
ctx.lineTo(414, -102);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制 99A 主战坦克主体。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 坦克中心 x
|
||||
* @param {number} y 坦克中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawTank(ctx, x, y, scale, progress) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(0,0,0,0.72)";
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.45)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 54, 250, 22, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
drawMuzzleFlash(ctx, progress);
|
||||
|
||||
// 99A 履带底盘:右侧前导轮更大,模拟参考图的右前方视角。
|
||||
const track = ctx.createLinearGradient(-246, 24, 246, 94);
|
||||
track.addColorStop(0, "#0a0a0a");
|
||||
track.addColorStop(0.42, DARK_ARMOR);
|
||||
track.addColorStop(1, "#030712");
|
||||
ctx.fillStyle = track;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-250, 32);
|
||||
ctx.lineTo(-206, 10);
|
||||
ctx.lineTo(184, 12);
|
||||
ctx.lineTo(252, 38);
|
||||
ctx.lineTo(218, 94);
|
||||
ctx.lineTo(-226, 96);
|
||||
ctx.lineTo(-270, 70);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(15,23,42,0.74)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-236, 22);
|
||||
ctx.lineTo(180, 23);
|
||||
ctx.lineTo(236, 43);
|
||||
ctx.lineTo(204, 62);
|
||||
ctx.lineTo(-218, 58);
|
||||
ctx.lineTo(-256, 42);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const wheelX = -180 + i * 58;
|
||||
const wheelY = 62 + (i > 4 ? 2 : 0);
|
||||
drawRoadWheel(ctx, wheelX, wheelY, progress + i * 0.05);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(253,230,138,0.14)";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-218, 82);
|
||||
ctx.lineTo(212, 82);
|
||||
ctx.stroke();
|
||||
|
||||
// 99A 车体:右侧为前装甲,首上装甲呈明显楔形下压。
|
||||
const hull = ctx.createLinearGradient(-238, -50, 250, 36);
|
||||
hull.addColorStop(0, "#42512b");
|
||||
hull.addColorStop(0.38, ARMOR);
|
||||
hull.addColorStop(0.66, CAMO);
|
||||
hull.addColorStop(1, "#253018");
|
||||
ctx.fillStyle = hull;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-238, 24);
|
||||
ctx.lineTo(-212, -30);
|
||||
ctx.lineTo(86, -52);
|
||||
ctx.lineTo(226, -24);
|
||||
ctx.lineTo(252, 14);
|
||||
ctx.lineTo(202, 38);
|
||||
ctx.lineTo(-216, 36);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(15,23,42,0.34)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(68, -44);
|
||||
ctx.lineTo(226, -22);
|
||||
ctx.lineTo(248, 9);
|
||||
ctx.lineTo(138, 18);
|
||||
ctx.lineTo(86, -5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 侧裙装甲模块和数码迷彩块,增强 99A 识别度。
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const panelX = -206 + i * 50;
|
||||
ctx.fillStyle = i % 2 === 0 ? "rgba(54,83,20,0.86)" : "rgba(120,113,55,0.82)";
|
||||
ctx.fillRect(panelX, -2, 42, 24);
|
||||
ctx.strokeStyle = "rgba(15,23,42,0.34)";
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.strokeRect(panelX, -2, 42, 24);
|
||||
}
|
||||
|
||||
[
|
||||
[-186, -24, 34, 18, "#7f8f57"],
|
||||
[-118, -34, 42, 20, "#b9855a"],
|
||||
[-32, -39, 48, 22, "#344329"],
|
||||
[52, -46, 44, 20, "#8a9b61"],
|
||||
[118, -30, 38, 19, "#a36f52"],
|
||||
[182, -14, 46, 21, "#415329"],
|
||||
[-220, 2, 30, 20, "#27351f"],
|
||||
[-72, 4, 36, 18, "#718246"],
|
||||
[22, 2, 42, 20, "#ac7654"],
|
||||
].forEach(([px, py, pw, ph, color]) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
});
|
||||
|
||||
// 参考图里的大号车号、国旗和前车灯。
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.font = "900 31px serif";
|
||||
ctx.fillText("807", -216, -12);
|
||||
ctx.fillStyle = "rgba(220,38,38,0.95)";
|
||||
ctx.fillRect(-128, -31, 34, 22);
|
||||
ctx.fillStyle = "rgba(253,224,71,0.95)";
|
||||
ctx.font = "900 12px serif";
|
||||
ctx.fillText("★", -122, -16);
|
||||
ctx.fillStyle = "rgba(254,242,242,0.88)";
|
||||
roundRect(ctx, 178, -17, 18, 9, 5);
|
||||
ctx.fill();
|
||||
roundRect(ctx, 214, -8, 18, 9, 5);
|
||||
ctx.fill();
|
||||
|
||||
// 低矮楔形炮塔和 125mm 长炮管:炮管朝右并略微上扬。
|
||||
const turret = ctx.createLinearGradient(-128, -108, 156, -28);
|
||||
turret.addColorStop(0, "#66734a");
|
||||
turret.addColorStop(0.46, "#87905d");
|
||||
turret.addColorStop(1, "#24301d");
|
||||
ctx.fillStyle = turret;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-132, -36);
|
||||
ctx.lineTo(-82, -96);
|
||||
ctx.lineTo(86, -108);
|
||||
ctx.lineTo(158, -72);
|
||||
ctx.lineTo(112, -32);
|
||||
ctx.lineTo(-118, -20);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
[
|
||||
[-98, -76, 42, 22, "#314222"],
|
||||
[-28, -90, 50, 22, "#a36f52"],
|
||||
[46, -92, 48, 20, "#73844d"],
|
||||
[98, -66, 34, 20, "#2f3f24"],
|
||||
].forEach(([px, py, pw, ph, color]) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
});
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(17,24,39,0.96)";
|
||||
ctx.lineWidth = 18;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(106, -70);
|
||||
ctx.lineTo(410, -120);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "#64748b";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(114, -75);
|
||||
ctx.lineTo(404, -123);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#1f2937";
|
||||
roundRect(ctx, 392, -132, 30, 24, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#40513a";
|
||||
roundRect(ctx, 250, -107, 24, 20, 5);
|
||||
ctx.fill();
|
||||
roundRect(ctx, 322, -120, 24, 20, 5);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = "rgba(17,24,39,0.95)";
|
||||
roundRect(ctx, 86, -80, 62, 24, 8);
|
||||
ctx.fill();
|
||||
|
||||
// 炮塔前装甲、烟幕弹发射器和车长机枪。
|
||||
ctx.fillStyle = "rgba(15,23,42,0.5)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(42, -48);
|
||||
ctx.lineTo(96, -78);
|
||||
ctx.lineTo(154, -66);
|
||||
ctx.lineTo(112, -36);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.fillStyle = "rgba(15,23,42,0.9)";
|
||||
roundRect(ctx, -124 + i * 13, -58 + (i % 2) * 10, 10, 18, 4);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(15,23,42,0.9)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-24, -102);
|
||||
ctx.lineTo(-18, -132);
|
||||
ctx.lineTo(40, -138);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "rgba(15,23,42,0.84)";
|
||||
roundRect(ctx, -32, -108, 58, 14, 7);
|
||||
ctx.fill();
|
||||
|
||||
// 装甲高光。
|
||||
ctx.strokeStyle = "rgba(226,232,240,0.28)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-184, -22);
|
||||
ctx.lineTo(92, -40);
|
||||
ctx.moveTo(-142, 8);
|
||||
ctx.lineTo(172, -2);
|
||||
ctx.moveTo(92, -82);
|
||||
ctx.lineTo(144, -66);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制坦克负重轮。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 中心 x
|
||||
* @param {number} y 中心 y
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawRoadWheel(ctx, x, y, progress) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(progress * 30);
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 21, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#475569";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "#94a3b8";
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ctx.rotate(Math.PI / 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(16, 0);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "#cbd5e1";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制 99A 重装入场 HUD 字幕。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
* @param {string} title 入场标题
|
||||
* @param {string} userInfo 用户身份信息
|
||||
*/
|
||||
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||
const enter = Math.min(1, Math.max(0, (progress - 0.14) / 0.2));
|
||||
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16));
|
||||
const alpha = easeInOutSine(enter) * leave;
|
||||
const y = h * 0.17 - (1 - enter) * 24;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.textAlign = "center";
|
||||
ctx.shadowColor = "rgba(253,230,138,0.95)";
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillStyle = "rgba(28,25,23,0.66)";
|
||||
ctx.strokeStyle = "rgba(253,230,138,0.72)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#fef3c7";
|
||||
ctx.font = "700 16px serif";
|
||||
ctx.fillText("ZTZ-99A ARMORED FORCE", w * 0.5, y - 24);
|
||||
ctx.fillStyle = "#fde68a";
|
||||
ctx.font = "700 18px serif";
|
||||
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "900 34px serif";
|
||||
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角矩形路径。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 左上角 x
|
||||
* @param {number} y 左上角 y
|
||||
* @param {number} w 宽度
|
||||
* @param {number} h 高度
|
||||
* @param {number} r 圆角半径
|
||||
*/
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 99A 主战坦克重装入场特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} onEnd 结束回调
|
||||
* @param {object} options 特效附加参数
|
||||
* @returns {{cancel: Function}}
|
||||
*/
|
||||
function start(canvas, onEnd, options = {}) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const dust = createDust(w, h);
|
||||
const title = String(options.effect_title || "99A主战坦克 重装入场").trim() || "99A主战坦克 重装入场";
|
||||
const userInfo = String(options.effect_user_info || "").trim();
|
||||
const startTime = performance.now();
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
|
||||
/**
|
||||
* 统一结束动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐帧绘制坦克入场动画。
|
||||
*
|
||||
* @param {number} now 当前高精度时间
|
||||
*/
|
||||
function animate(now) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(1, elapsed / DURATION);
|
||||
const entry = easeOutCubic(Math.min(1, progress / 0.64));
|
||||
const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24));
|
||||
const tankX = -w * 0.24 + entry * w * 0.92 + exit * w * 0.62;
|
||||
const tankY = h * 0.66 + Math.sin(progress * 24) * 2.5;
|
||||
const scale = Math.min(1.12, Math.max(0.68, w / 1180));
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawBackdrop(ctx, w, h, progress);
|
||||
drawDust(ctx, dust, w, progress);
|
||||
drawShockwave(ctx, w, h, progress);
|
||||
drawTank(ctx, tankX, tankY, scale, progress);
|
||||
drawHud(ctx, w, h, progress, title, userInfo);
|
||||
|
||||
if (progress < 1) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.Type99AEffect = Type99AEffect;
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 文件功能:聊天室东风-5C洲际导弹发射预览特效
|
||||
*
|
||||
* 使用全屏透明 Canvas 绘制风格化洲际导弹升空、尾焰、烟尘冲击波、
|
||||
* 雷达扫描网格和测试 HUD。该效果只用于聊天室视觉预览,不表达真实装备参数。
|
||||
*/
|
||||
|
||||
const Df5cEffect = (() => {
|
||||
const DURATION = 8200;
|
||||
const FIRE = "#fb923c";
|
||||
const HOT = "#fef3c7";
|
||||
const RED = "#dc2626";
|
||||
const BODY = "#e5e7eb";
|
||||
const BODY_DARK = "#64748b";
|
||||
|
||||
/**
|
||||
* 缓入缓出曲线,用于导弹升空和 HUD 动画。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓出曲线,用于烟尘扩散。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建尾焰和烟尘粒子。
|
||||
*
|
||||
* @param {number} count 粒子数量
|
||||
* @returns {Array<Record<string, number>>}
|
||||
*/
|
||||
function createParticles(count) {
|
||||
return Array.from({ length: count }, () => ({
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
spread: 0.3 + Math.random() * 1.3,
|
||||
speed: 0.4 + Math.random() * 2.4,
|
||||
size: 4 + Math.random() * 18,
|
||||
alpha: 0.12 + Math.random() * 0.6,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制夜空、雷达网格和扫描线。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawBackdrop(ctx, w, h, progress) {
|
||||
const fade = Math.min(1, progress / 0.14) * Math.min(1, (1 - progress) / 0.12);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h);
|
||||
sky.addColorStop(0, `rgba(2,6,23,${0.86 * fade})`);
|
||||
sky.addColorStop(0.58, `rgba(15,23,42,${0.62 * fade})`);
|
||||
sky.addColorStop(1, `rgba(30,41,59,${0.26 * fade})`);
|
||||
ctx.fillStyle = sky;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fade;
|
||||
ctx.strokeStyle = "rgba(56,189,248,0.16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = -w; x < w * 2; x += 72) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + progress * 120, 0);
|
||||
ctx.lineTo(x - h * 0.55 + progress * 120, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = h * 0.2; y < h; y += 46) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + Math.sin(progress * 18 + y) * 2);
|
||||
ctx.lineTo(w, y + Math.cos(progress * 14 + y) * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(248,113,113,0.34)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w * 0.16, h * 0.76, w * (0.18 + progress * 0.22), -Math.PI * 0.95, -Math.PI * 0.12);
|
||||
ctx.stroke();
|
||||
|
||||
const beamAngle = -Math.PI * 0.85 + progress * Math.PI * 1.15;
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.34)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.16, h * 0.76);
|
||||
ctx.lineTo(w * 0.16 + Math.cos(beamAngle) * w * 0.42, h * 0.76 + Math.sin(beamAngle) * w * 0.42);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制发射井底座、光柱和冲击波。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawLaunchPad(ctx, w, h, progress) {
|
||||
const ignition = Math.min(1, progress / 0.24);
|
||||
const pulse = Math.sin(progress * Math.PI * 12) * 0.5 + 0.5;
|
||||
const cx = w * 0.18;
|
||||
const cy = h * 0.78;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
const beam = ctx.createRadialGradient(cx, cy, 0, cx, cy, h * 0.42);
|
||||
beam.addColorStop(0, `rgba(251,146,60,${0.54 * ignition})`);
|
||||
beam.addColorStop(0.3, `rgba(254,243,199,${0.18 * ignition})`);
|
||||
beam.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = beam;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = `rgba(251,146,60,${(0.32 + pulse * 0.2) * ignition})`;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, w * (0.06 + progress * 0.28), h * (0.025 + progress * 0.08), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(15,23,42,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy + 18, w * 0.12, h * 0.035, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(148,163,184,0.7)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制尾焰和烟尘。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {Array<Record<string, number>>} particles 粒子数组
|
||||
* @param {number} tailX 尾部 x
|
||||
* @param {number} tailY 尾部 y
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawExhaust(ctx, particles, tailX, tailY, progress) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
particles.forEach((particle, index) => {
|
||||
const t = (progress * 3.2 + index * 0.013) % 1;
|
||||
const spread = easeOutCubic(t) * 118 * particle.spread;
|
||||
const x = tailX - spread * 0.62 + Math.cos(particle.angle) * spread * 0.36;
|
||||
const y = tailY + spread * 0.86 + Math.sin(particle.angle + particle.phase) * spread * 0.24;
|
||||
const alpha = particle.alpha * (1 - t);
|
||||
const radius = particle.size * (0.7 + t * 2.4);
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = t < 0.34 ? HOT : t < 0.62 ? FIRE : "rgba(148,163,184,0.9)";
|
||||
ctx.shadowColor = t < 0.55 ? FIRE : "rgba(148,163,184,0.8)";
|
||||
ctx.shadowBlur = radius * 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, radius * 0.9, radius * 1.35, -0.38, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制东风-5C风格化导弹。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 导弹中心 x
|
||||
* @param {number} y 导弹中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawMissile(ctx, x, y, scale, progress) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
// 导弹沿左下到右上的轨迹飞行,箭体头部必须朝右上,尾焰才会落在后方。
|
||||
ctx.rotate(0.62 + Math.sin(progress * 7) * 0.012);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(251,146,60,0.9)";
|
||||
ctx.shadowBlur = 26;
|
||||
ctx.fillStyle = "rgba(251,146,60,0.86)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-28, 170);
|
||||
ctx.lineTo(0, 260);
|
||||
ctx.lineTo(28, 170);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(254,243,199,0.86)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-12, 176);
|
||||
ctx.lineTo(0, 236);
|
||||
ctx.lineTo(12, 176);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
const body = ctx.createLinearGradient(-44, -178, 44, 168);
|
||||
body.addColorStop(0, "#f8fafc");
|
||||
body.addColorStop(0.45, BODY);
|
||||
body.addColorStop(1, BODY_DARK);
|
||||
ctx.fillStyle = body;
|
||||
roundRect(ctx, -42, -156, 84, 326, 40);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = RED;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-42, -126);
|
||||
ctx.quadraticCurveTo(0, -214, 42, -126);
|
||||
ctx.lineTo(42, -92);
|
||||
ctx.lineTo(-42, -92);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.fillRect(-42, -74, 84, 10);
|
||||
ctx.fillRect(-42, 74, 84, 10);
|
||||
ctx.fillStyle = "rgba(239,68,68,0.92)";
|
||||
ctx.fillRect(-42, -22, 84, 30);
|
||||
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.font = "900 30px serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("DF-5C", 0, 50);
|
||||
ctx.fillStyle = "#fef3c7";
|
||||
ctx.font = "900 20px serif";
|
||||
ctx.fillText("★", 0, -1);
|
||||
|
||||
ctx.fillStyle = "#334155";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-42, 102);
|
||||
ctx.lineTo(-104, 166);
|
||||
ctx.lineTo(-42, 152);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(42, 102);
|
||||
ctx.lineTo(104, 166);
|
||||
ctx.lineTo(42, 152);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.42)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-20, -112);
|
||||
ctx.lineTo(-20, 130);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制测试 HUD 文案。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
* @param {string} title 入场标题
|
||||
* @param {string} userInfo 用户身份信息
|
||||
*/
|
||||
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||
const enter = Math.min(1, Math.max(0, (progress - 0.1) / 0.18));
|
||||
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
|
||||
const alpha = easeInOutCubic(enter) * leave;
|
||||
const y = h * 0.16 - (1 - enter) * 20;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "rgba(15,23,42,0.68)";
|
||||
ctx.strokeStyle = "rgba(248,113,113,0.72)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(ctx, w * 0.5 - 350, y - 56, 700, 120, 18);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowColor = "rgba(248,113,113,0.95)";
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillStyle = "#fee2e2";
|
||||
ctx.font = "700 16px serif";
|
||||
ctx.fillText("DF-5C STRATEGIC LAUNCH PREVIEW", w * 0.5, y - 24);
|
||||
ctx.fillStyle = "#fecaca";
|
||||
ctx.font = "700 18px serif";
|
||||
ctx.fillText(userInfo, w * 0.5, y + 8, 640);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "900 34px serif";
|
||||
ctx.fillText(title, w * 0.5, y + 45, 640);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角矩形路径。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 左上角 x
|
||||
* @param {number} y 左上角 y
|
||||
* @param {number} w 宽度
|
||||
* @param {number} h 高度
|
||||
* @param {number} r 圆角半径
|
||||
*/
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动东风-5C洲际导弹发射预览特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} onEnd 结束回调
|
||||
* @param {object} options 特效附加参数
|
||||
* @returns {{cancel: Function}}
|
||||
*/
|
||||
function start(canvas, onEnd, options = {}) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const particles = createParticles(120);
|
||||
const title = String(options.effect_title || "东风-5C 洲际导弹 升空").trim() || "东风-5C 洲际导弹 升空";
|
||||
const userInfo = String(options.effect_user_info || "").trim();
|
||||
const startTime = performance.now();
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
|
||||
/**
|
||||
* 统一结束动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐帧绘制发射动画。
|
||||
*
|
||||
* @param {number} now 当前高精度时间
|
||||
*/
|
||||
function animate(now) {
|
||||
const progress = Math.min(1, (now - startTime) / DURATION);
|
||||
const launch = easeInOutCubic(Math.min(1, progress / 0.78));
|
||||
const launchX = w * (0.18 + launch * 0.66);
|
||||
const launchY = h * (0.78 - launch * 0.95);
|
||||
const scale = Math.min(1.08, Math.max(0.7, w / 1240));
|
||||
const tailX = launchX - Math.sin(0.62) * 168 * scale;
|
||||
const tailY = launchY + Math.cos(0.62) * 168 * scale;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawBackdrop(ctx, w, h, progress);
|
||||
drawLaunchPad(ctx, w, h, progress);
|
||||
drawExhaust(ctx, particles, tailX, tailY, progress);
|
||||
drawMissile(ctx, launchX, launchY, scale, progress);
|
||||
drawHud(ctx, w, h, progress, title, userInfo);
|
||||
|
||||
if (progress < 1) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.Df5cEffect = Df5cEffect;
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
||||
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
|
||||
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies')
|
||||
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian')
|
||||
*/
|
||||
|
||||
const EffectManager = (() => {
|
||||
@@ -22,6 +22,10 @@ const EffectManager = (() => {
|
||||
hearts: { key: "hearts", load: () => import("./hearts.js") },
|
||||
confetti: { key: "confetti", load: () => import("./confetti.js") },
|
||||
fireflies: { key: "fireflies", load: () => import("./fireflies.js") },
|
||||
j35: { key: "j35", load: () => import("./j35.js") },
|
||||
"99a": { key: "99a", load: () => import("./99a.js") },
|
||||
df5c: { key: "df5c", load: () => import("./df5c.js") },
|
||||
fujian: { key: "fujian", load: () => import("./fujian.js") },
|
||||
};
|
||||
// 特效模块 Promise 缓存,同类型重复触发时复用同一次加载
|
||||
const _effectModulePromises = new Map();
|
||||
@@ -217,9 +221,9 @@ const EffectManager = (() => {
|
||||
}
|
||||
|
||||
if (playNext) {
|
||||
const nextType = _dequeueNextType();
|
||||
if (nextType) {
|
||||
play(nextType);
|
||||
const nextEffect = _dequeueNextType();
|
||||
if (nextEffect) {
|
||||
play(nextEffect.type, nextEffect.options || {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,8 +232,9 @@ const EffectManager = (() => {
|
||||
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
|
||||
*
|
||||
* @param {string} type 待播放特效类型
|
||||
* @param {object} options 特效附加参数
|
||||
*/
|
||||
function _enqueue(type) {
|
||||
function _enqueue(type, options = {}) {
|
||||
const existingIndex = _queue.findIndex((item) => item.type === type);
|
||||
if (existingIndex !== -1) {
|
||||
_queue.splice(existingIndex, 1);
|
||||
@@ -237,6 +242,7 @@ const EffectManager = (() => {
|
||||
|
||||
_queue.push({
|
||||
type,
|
||||
options,
|
||||
queuedAt: Date.now(),
|
||||
keepUntilPlayed: type === "wedding-fireworks",
|
||||
});
|
||||
@@ -248,7 +254,7 @@ const EffectManager = (() => {
|
||||
/**
|
||||
* 取出下一个仍然有效的排队特效。
|
||||
*
|
||||
* @returns {string|null}
|
||||
* @returns {{type: string, options: object}|null}
|
||||
*/
|
||||
function _dequeueNextType() {
|
||||
const now = Date.now();
|
||||
@@ -256,7 +262,7 @@ const EffectManager = (() => {
|
||||
while (_queue.length > 0) {
|
||||
const next = _queue.shift();
|
||||
if (next.keepUntilPlayed || now - next.queuedAt <= QUEUED_EFFECT_TTL) {
|
||||
return next.type;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,23 +332,25 @@ const EffectManager = (() => {
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} finishCurrent 当前特效结束回调
|
||||
* @param {string} startMethod 启动方法名称
|
||||
* @param {object} options 特效附加参数
|
||||
* @returns {boolean} 是否成功找到并启动特效
|
||||
*/
|
||||
function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start") {
|
||||
function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start", options = {}) {
|
||||
if (!effectObject || typeof effectObject[startMethod] !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
_bindEffectController(effectObject[startMethod](canvas, finishCurrent));
|
||||
_bindEffectController(effectObject[startMethod](canvas, finishCurrent, options));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定特效
|
||||
*
|
||||
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
||||
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||
* @param {object} options 特效附加参数
|
||||
*/
|
||||
function play(type) {
|
||||
function play(type, options = {}) {
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
@@ -354,19 +362,20 @@ const EffectManager = (() => {
|
||||
|
||||
// 防重入:同时只允许一个特效
|
||||
if (_current) {
|
||||
_enqueue(type);
|
||||
_enqueue(type, options);
|
||||
return;
|
||||
}
|
||||
|
||||
_play(type);
|
||||
_play(type, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模块后播放指定特效。
|
||||
*
|
||||
* @param {string} type 特效类型
|
||||
* @param {object} options 特效附加参数
|
||||
*/
|
||||
async function _play(type) {
|
||||
async function _play(type, options = {}) {
|
||||
_current = type;
|
||||
const token = _playToken;
|
||||
|
||||
@@ -434,6 +443,18 @@ const EffectManager = (() => {
|
||||
case "fireflies":
|
||||
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "j35":
|
||||
started = _startEffect(window.J35Effect, canvas, finishCurrent, "start", options);
|
||||
break;
|
||||
case "99a":
|
||||
started = _startEffect(window.Type99AEffect, canvas, finishCurrent, "start", options);
|
||||
break;
|
||||
case "df5c":
|
||||
started = _startEffect(window.Df5cEffect, canvas, finishCurrent, "start", options);
|
||||
break;
|
||||
case "fujian":
|
||||
started = _startEffect(window.FujianEffect, canvas, finishCurrent, "start", options);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
* hearts 爱心飘落(温暖双音)
|
||||
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
|
||||
* fireflies 萤火虫(稀疏微光铃音)
|
||||
* j35 歼-35 战机(喷气低频 + 高速呼啸 + 音爆扫频)
|
||||
* 99a 99A 主战坦克(履带低频 + 炮击冲击 + 金属震动)
|
||||
* df5c 东风-5C(发射低频 + 尾焰轰鸣 + 高空呼啸)
|
||||
* fujian 福建舰(海浪低频 + 舰载机掠过 + 甲板提示音)
|
||||
*/
|
||||
|
||||
const EffectSounds = (() => {
|
||||
@@ -762,6 +766,385 @@ const EffectSounds = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动歼-35 战机音效:喷气低频、空中掠过和音爆扫频。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startJ35() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.58;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const turbine = ctx.createOscillator();
|
||||
const subRumble = ctx.createOscillator();
|
||||
const turbineFilter = ctx.createBiquadFilter();
|
||||
const turbineGain = ctx.createGain();
|
||||
turbine.type = "sawtooth";
|
||||
subRumble.type = "triangle";
|
||||
turbine.frequency.setValueAtTime(72, ctx.currentTime);
|
||||
turbine.frequency.exponentialRampToValueAtTime(210, ctx.currentTime + 1.8);
|
||||
turbine.frequency.exponentialRampToValueAtTime(96, ctx.currentTime + 7.6);
|
||||
subRumble.frequency.setValueAtTime(34, ctx.currentTime);
|
||||
subRumble.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.8);
|
||||
subRumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.6);
|
||||
turbineFilter.type = "lowpass";
|
||||
turbineFilter.frequency.setValueAtTime(420, ctx.currentTime);
|
||||
turbineFilter.frequency.exponentialRampToValueAtTime(2600, ctx.currentTime + 1.6);
|
||||
turbineFilter.frequency.exponentialRampToValueAtTime(520, ctx.currentTime + 7.8);
|
||||
turbineGain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||
turbineGain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
|
||||
turbineGain.gain.linearRampToValueAtTime(0.42, ctx.currentTime + 1.6);
|
||||
turbineGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
|
||||
turbine.connect(turbineFilter);
|
||||
subRumble.connect(turbineFilter);
|
||||
turbineFilter.connect(turbineGain);
|
||||
turbineGain.connect(master);
|
||||
turbine.start(ctx.currentTime);
|
||||
subRumble.start(ctx.currentTime);
|
||||
turbine.stop(ctx.currentTime + 8.2);
|
||||
subRumble.stop(ctx.currentTime + 8.2);
|
||||
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 0.12,
|
||||
duration: 2.6,
|
||||
startFreq: 180,
|
||||
endFreq: 6200,
|
||||
volume: 0.24,
|
||||
q: 0.9,
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 2.05,
|
||||
duration: 0.72,
|
||||
startFreq: 9000,
|
||||
endFreq: 1200,
|
||||
volume: 0.34,
|
||||
q: 1.4,
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 4.2,
|
||||
duration: 1.05,
|
||||
startFreq: 7200,
|
||||
endFreq: 280,
|
||||
volume: 0.2,
|
||||
q: 1.8,
|
||||
filterType: "highpass",
|
||||
});
|
||||
|
||||
[0.42, 0.9, 1.32, 5.1].forEach((delay, index) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.11,
|
||||
freq: [1046.5, 1318.5, 1567.98, 2093][index % 4],
|
||||
endFreq: [1567.98, 1975.53, 2349.32, 3135.96][index % 4],
|
||||
volume: 0.045,
|
||||
type: "square",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 900);
|
||||
}, 8800);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
turbine.stop();
|
||||
subRumble.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 99A 主战坦克音效:履带低频、炮击冲击与金属震动。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startType99A() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.52;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const engine = ctx.createOscillator();
|
||||
const track = ctx.createOscillator();
|
||||
const filter = ctx.createBiquadFilter();
|
||||
const gain = ctx.createGain();
|
||||
engine.type = "sawtooth";
|
||||
track.type = "square";
|
||||
engine.frequency.setValueAtTime(42, ctx.currentTime);
|
||||
engine.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.1);
|
||||
engine.frequency.exponentialRampToValueAtTime(46, ctx.currentTime + 7.5);
|
||||
track.frequency.setValueAtTime(18, ctx.currentTime);
|
||||
track.frequency.linearRampToValueAtTime(24, ctx.currentTime + 2.0);
|
||||
track.frequency.linearRampToValueAtTime(18, ctx.currentTime + 7.5);
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(220, ctx.currentTime);
|
||||
filter.frequency.linearRampToValueAtTime(520, ctx.currentTime + 1.4);
|
||||
filter.frequency.linearRampToValueAtTime(260, ctx.currentTime + 7.8);
|
||||
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||
gain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
|
||||
engine.connect(filter);
|
||||
track.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(master);
|
||||
engine.start(ctx.currentTime);
|
||||
track.start(ctx.currentTime);
|
||||
engine.stop(ctx.currentTime + 8.2);
|
||||
track.stop(ctx.currentTime + 8.2);
|
||||
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 0.2,
|
||||
duration: 2.8,
|
||||
startFreq: 80,
|
||||
endFreq: 420,
|
||||
volume: 0.18,
|
||||
q: 0.8,
|
||||
filterType: "lowpass",
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 4.02,
|
||||
duration: 0.5,
|
||||
startFreq: 180,
|
||||
endFreq: 65,
|
||||
volume: 0.42,
|
||||
q: 0.7,
|
||||
filterType: "lowpass",
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 4.05,
|
||||
duration: 0.18,
|
||||
startFreq: 4800,
|
||||
endFreq: 900,
|
||||
volume: 0.16,
|
||||
q: 1.2,
|
||||
});
|
||||
|
||||
[0.5, 1.1, 1.7, 2.25, 3.0, 3.55, 5.2, 5.85].forEach((delay) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.08,
|
||||
freq: 72,
|
||||
endFreq: 44,
|
||||
volume: 0.045,
|
||||
type: "square",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 900);
|
||||
}, 8400);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
engine.stop();
|
||||
track.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动东风-5C预览音效:发射低频、尾焰轰鸣与高空呼啸。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startDf5c() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.58;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const rumble = ctx.createOscillator();
|
||||
const flame = ctx.createOscillator();
|
||||
const filter = ctx.createBiquadFilter();
|
||||
const gain = ctx.createGain();
|
||||
rumble.type = "sawtooth";
|
||||
flame.type = "triangle";
|
||||
rumble.frequency.setValueAtTime(28, ctx.currentTime);
|
||||
rumble.frequency.exponentialRampToValueAtTime(76, ctx.currentTime + 2.2);
|
||||
rumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.4);
|
||||
flame.frequency.setValueAtTime(96, ctx.currentTime);
|
||||
flame.frequency.exponentialRampToValueAtTime(220, ctx.currentTime + 2.8);
|
||||
flame.frequency.exponentialRampToValueAtTime(112, ctx.currentTime + 7.2);
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(180, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(1800, ctx.currentTime + 2.8);
|
||||
filter.frequency.exponentialRampToValueAtTime(420, ctx.currentTime + 7.8);
|
||||
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||
gain.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 0.9);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.0);
|
||||
rumble.connect(filter);
|
||||
flame.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(master);
|
||||
rumble.start(ctx.currentTime);
|
||||
flame.start(ctx.currentTime);
|
||||
rumble.stop(ctx.currentTime + 8.1);
|
||||
flame.stop(ctx.currentTime + 8.1);
|
||||
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 0.08,
|
||||
duration: 3.4,
|
||||
startFreq: 90,
|
||||
endFreq: 2600,
|
||||
volume: 0.28,
|
||||
q: 0.8,
|
||||
filterType: "lowpass",
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 2.1,
|
||||
duration: 2.6,
|
||||
startFreq: 720,
|
||||
endFreq: 8200,
|
||||
volume: 0.22,
|
||||
q: 1.3,
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 4.8,
|
||||
duration: 1.1,
|
||||
startFreq: 9600,
|
||||
endFreq: 1600,
|
||||
volume: 0.18,
|
||||
q: 1.8,
|
||||
filterType: "highpass",
|
||||
});
|
||||
|
||||
[0.2, 0.46, 0.74, 1.04, 1.34].forEach((delay) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.12,
|
||||
freq: 58,
|
||||
endFreq: 32,
|
||||
volume: 0.07,
|
||||
type: "square",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 900);
|
||||
}, 8500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
rumble.stop();
|
||||
flame.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动福建舰预览音效:海浪低频、舰载机掠过与甲板提示音。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startFujian() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.42;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const engine = ctx.createOscillator();
|
||||
const wake = ctx.createOscillator();
|
||||
const filter = ctx.createBiquadFilter();
|
||||
const gain = ctx.createGain();
|
||||
engine.type = "sawtooth";
|
||||
wake.type = "triangle";
|
||||
engine.frequency.setValueAtTime(38, ctx.currentTime);
|
||||
engine.frequency.exponentialRampToValueAtTime(52, ctx.currentTime + 2.4);
|
||||
engine.frequency.exponentialRampToValueAtTime(34, ctx.currentTime + 8.2);
|
||||
wake.frequency.setValueAtTime(74, ctx.currentTime);
|
||||
wake.frequency.exponentialRampToValueAtTime(92, ctx.currentTime + 2.2);
|
||||
wake.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + 8.2);
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(240, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(720, ctx.currentTime + 2.0);
|
||||
filter.frequency.exponentialRampToValueAtTime(260, ctx.currentTime + 8.4);
|
||||
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||
gain.gain.linearRampToValueAtTime(0.26, ctx.currentTime + 0.55);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.6);
|
||||
engine.connect(filter);
|
||||
wake.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(master);
|
||||
engine.start(ctx.currentTime);
|
||||
wake.start(ctx.currentTime);
|
||||
engine.stop(ctx.currentTime + 8.7);
|
||||
wake.stop(ctx.currentTime + 8.7);
|
||||
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 0.1,
|
||||
duration: 7.8,
|
||||
startFreq: 120,
|
||||
endFreq: 520,
|
||||
volume: 0.18,
|
||||
q: 0.7,
|
||||
filterType: "lowpass",
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 3.15,
|
||||
duration: 1.35,
|
||||
startFreq: 420,
|
||||
endFreq: 7600,
|
||||
volume: 0.22,
|
||||
q: 1.5,
|
||||
});
|
||||
|
||||
[0.8, 1.35, 2.0, 3.0, 4.7, 5.45].forEach((delay, index) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.12,
|
||||
freq: [880, 1174.66, 1567.98][index % 3],
|
||||
endFreq: [987.77, 1318.51, 1760][index % 3],
|
||||
volume: 0.045,
|
||||
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);
|
||||
engine.stop();
|
||||
wake.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 公开 API ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -770,7 +1153,7 @@ const EffectSounds = (() => {
|
||||
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
|
||||
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
|
||||
*
|
||||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies'
|
||||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian'
|
||||
*/
|
||||
function play(type) {
|
||||
// 用户开启禁音则跳过
|
||||
@@ -813,6 +1196,18 @@ const EffectSounds = (() => {
|
||||
case "fireflies":
|
||||
_stopFn = _startFireflies();
|
||||
break;
|
||||
case "j35":
|
||||
_stopFn = _startJ35();
|
||||
break;
|
||||
case "99a":
|
||||
_stopFn = _startType99A();
|
||||
break;
|
||||
case "df5c":
|
||||
_stopFn = _startDf5c();
|
||||
break;
|
||||
case "fujian":
|
||||
_stopFn = _startFujian();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* 文件功能:聊天室福建舰航空母舰入场预览特效
|
||||
*
|
||||
* 使用全屏透明 Canvas 绘制福建舰航母破浪入场、甲板灯带、舰岛、编号 18、
|
||||
* 弹射轨道、舰载机剪影起飞和海面尾流。该效果只用于本地视觉预览。
|
||||
*/
|
||||
|
||||
const FujianEffect = (() => {
|
||||
const DURATION = 8800;
|
||||
const SEA = "#0f766e";
|
||||
const DECK = "#334155";
|
||||
const HULL = "#1f2937";
|
||||
const LIGHT = "#67e8f9";
|
||||
|
||||
/**
|
||||
* 缓入缓出曲线,让航母移动有重量感。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓出曲线,用于舰载机起飞和尾流扩散。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建海浪粒子。
|
||||
*
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @returns {Array<Record<string, number>>}
|
||||
*/
|
||||
function createWaves(w, h) {
|
||||
return Array.from({ length: 72 }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: h * (0.62 + Math.random() * 0.28),
|
||||
speed: 0.7 + Math.random() * 2.6,
|
||||
width: 24 + Math.random() * 96,
|
||||
alpha: 0.1 + Math.random() * 0.32,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制海面背景、雷达线和远处光带。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawBackdrop(ctx, w, h, progress) {
|
||||
const fade = Math.min(1, progress / 0.14) * Math.min(1, (1 - progress) / 0.12);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h);
|
||||
sky.addColorStop(0, `rgba(15,23,42,${0.68 * fade})`);
|
||||
sky.addColorStop(0.5, `rgba(30,64,175,${0.18 * fade})`);
|
||||
sky.addColorStop(1, `rgba(15,118,110,${0.42 * fade})`);
|
||||
ctx.fillStyle = sky;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fade;
|
||||
ctx.strokeStyle = "rgba(103,232,249,0.16)";
|
||||
ctx.lineWidth = 1.2;
|
||||
for (let y = h * 0.56; y < h; y += 38) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + Math.sin(progress * 16 + y) * 6);
|
||||
ctx.lineTo(w, y + Math.cos(progress * 12 + y) * 6);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.strokeStyle = "rgba(251,191,36,0.2)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.12, h * 0.22);
|
||||
ctx.lineTo(w * 0.88, h * 0.2 + Math.sin(progress * 10) * 5);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.18)";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(w * 0.8, h * 0.32, w * (0.08 + i * 0.07 + progress * 0.02), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制动态海浪和航母尾流。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {Array<Record<string, number>>} waves 海浪粒子
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} progress 播放进度
|
||||
* @param {number} carrierX 航母中心 x
|
||||
* @param {number} carrierY 航母中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
*/
|
||||
function drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale) {
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
waves.forEach((wave, index) => {
|
||||
const x = (wave.x - progress * 420 * wave.speed + index * 37) % (w + 220);
|
||||
ctx.globalAlpha = wave.alpha;
|
||||
ctx.strokeStyle = index % 3 === 0 ? "rgba(240,253,250,0.65)" : "rgba(125,211,252,0.44)";
|
||||
ctx.lineWidth = index % 3 === 0 ? 3 : 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 110, wave.y);
|
||||
ctx.lineTo(x - 110 + wave.width, wave.y + Math.sin(progress * 20 + index) * 5);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.72)";
|
||||
ctx.lineWidth = 8 * scale;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(carrierX - 290 * scale, carrierY + 88 * scale);
|
||||
ctx.bezierCurveTo(
|
||||
carrierX - 460 * scale,
|
||||
carrierY + 118 * scale,
|
||||
carrierX - 580 * scale,
|
||||
carrierY + 72 * scale,
|
||||
carrierX - 720 * scale,
|
||||
carrierY + 128 * scale,
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 0.36;
|
||||
ctx.lineWidth = 18 * scale;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制福建舰航母主体。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 航母中心 x
|
||||
* @param {number} y 航母中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawCarrier(ctx, x, y, scale, progress) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(0,0,0,0.72)";
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.34)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 112, 500, 32, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 舰体侧面和上翘舰艏,整体拉长压低,更接近参考图的侧视航母比例。
|
||||
const hull = ctx.createLinearGradient(-520, 22, 520, 126);
|
||||
hull.addColorStop(0, "#0f172a");
|
||||
hull.addColorStop(0.42, HULL);
|
||||
hull.addColorStop(1, "#475569");
|
||||
ctx.fillStyle = hull;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-540, 24);
|
||||
ctx.lineTo(444, 22);
|
||||
ctx.lineTo(522, 48);
|
||||
ctx.lineTo(458, 112);
|
||||
ctx.lineTo(-438, 122);
|
||||
ctx.lineTo(-520, 68);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(148,163,184,0.34)";
|
||||
ctx.lineWidth = 3;
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const px = -420 + i * 54;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, 42);
|
||||
ctx.lineTo(px + 22, 42);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 大型平直飞行甲板:长矩形甲板与左侧上翘舰艏是主要识别点。
|
||||
const deck = ctx.createLinearGradient(-520, -70, 512, 56);
|
||||
deck.addColorStop(0, "#475569");
|
||||
deck.addColorStop(0.48, DECK);
|
||||
deck.addColorStop(1, "#64748b");
|
||||
ctx.fillStyle = deck;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-520, -52);
|
||||
ctx.lineTo(352, -62);
|
||||
ctx.lineTo(512, -24);
|
||||
ctx.lineTo(468, 42);
|
||||
ctx.lineTo(-456, 58);
|
||||
ctx.lineTo(-548, 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(226,232,240,0.46)";
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-492, -38);
|
||||
ctx.lineTo(474, -28);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-500, 48);
|
||||
ctx.lineTo(442, 30);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = "rgba(226,232,240,0.5)";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-440, 4);
|
||||
ctx.lineTo(398, -18);
|
||||
ctx.stroke();
|
||||
|
||||
// 三条弹射轨道和甲板灯带,突出福建舰电磁弹射视觉。
|
||||
ctx.strokeStyle = "rgba(103,232,249,0.82)";
|
||||
ctx.lineWidth = 3;
|
||||
[-38, -15, 8].forEach((offset, index) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-348, offset);
|
||||
ctx.lineTo(368, offset - 22 - index * 4);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
ctx.strokeStyle = "rgba(251,191,36,0.72)";
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const lx = -436 + i * 38;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, 44 + Math.sin(progress * 20 + i) * 2);
|
||||
ctx.lineTo(lx + 14, 43 + Math.sin(progress * 20 + i) * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 舰岛、雷达桅杆和红旗标识,位置靠中右,贴近参考图。
|
||||
ctx.fillStyle = "#1e293b";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(106, -112);
|
||||
ctx.lineTo(196, -124);
|
||||
ctx.lineTo(226, -74);
|
||||
ctx.lineTo(178, -36);
|
||||
ctx.lineTo(92, -46);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#475569";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(126, -176);
|
||||
ctx.lineTo(182, -166);
|
||||
ctx.lineTo(172, -118);
|
||||
ctx.lineTo(106, -126);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#cbd5e1";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.fillRect(122 + i * 14, -154, 8, 8);
|
||||
ctx.fillRect(118 + i * 14, -138, 8, 8);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(226,232,240,0.5)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(154, -174);
|
||||
ctx.lineTo(154, -220);
|
||||
ctx.moveTo(134, -204);
|
||||
ctx.lineTo(188, -216);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "rgba(239,68,68,0.95)";
|
||||
ctx.fillRect(184, -104, 34, 22);
|
||||
ctx.fillStyle = "#fde68a";
|
||||
ctx.font = "900 13px serif";
|
||||
ctx.fillText("★", 192, -88);
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.font = "900 46px serif";
|
||||
ctx.fillText("18", -456, 24);
|
||||
ctx.fillStyle = "rgba(203,213,225,0.92)";
|
||||
ctx.font = "900 28px serif";
|
||||
ctx.fillText("福建舰", -72, -34);
|
||||
|
||||
drawDeckAircraft(ctx, progress);
|
||||
drawBowspray(ctx, progress);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制甲板飞机剪影和一架起飞中的舰载机。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawDeckAircraft(ctx, progress) {
|
||||
[
|
||||
[-356, -30, -0.03, 0.34],
|
||||
[-296, -7, -0.02, 0.36],
|
||||
[-244, 24, 0.02, 0.34],
|
||||
[-188, -34, -0.04, 0.36],
|
||||
[-132, -8, -0.02, 0.38],
|
||||
[-76, 22, 0.02, 0.36],
|
||||
[-22, -35, -0.05, 0.38],
|
||||
[34, -12, -0.02, 0.36],
|
||||
[92, 16, 0.02, 0.34],
|
||||
[156, -26, -0.04, 0.34],
|
||||
[214, 0, -0.02, 0.32],
|
||||
[276, -30, -0.04, 0.3],
|
||||
].forEach(([x, y, rotation, scale]) => {
|
||||
drawJet(ctx, x, y, scale, rotation, "rgba(15,23,42,0.86)");
|
||||
});
|
||||
|
||||
const takeoff = Math.max(0, Math.min(1, (progress - 0.34) / 0.42));
|
||||
if (takeoff <= 0 || takeoff >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.globalAlpha = Math.sin(takeoff * Math.PI);
|
||||
ctx.strokeStyle = "rgba(103,232,249,0.7)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(248, -36);
|
||||
ctx.lineTo(248 + takeoff * 270, -36 - takeoff * 132);
|
||||
ctx.stroke();
|
||||
drawJet(ctx, 248 + takeoff * 270, -36 - takeoff * 132, 0.56, -0.34, "rgba(226,232,240,0.95)");
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制简化舰载机剪影。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 中心 x
|
||||
* @param {number} y 中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
* @param {number} rotation 旋转角度
|
||||
* @param {string} fill 填充颜色
|
||||
*/
|
||||
function drawJet(ctx, x, y, scale, rotation, fill) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(rotation);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(68, 0);
|
||||
ctx.lineTo(-46, -18);
|
||||
ctx.lineTo(-22, -3);
|
||||
ctx.lineTo(-66, 0);
|
||||
ctx.lineTo(-22, 3);
|
||||
ctx.lineTo(-46, 18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制舰艏破浪飞沫。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawBowspray(ctx, progress) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.strokeStyle = "rgba(240,253,250,0.72)";
|
||||
ctx.lineWidth = 4;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spread = 20 + i * 16 + Math.sin(progress * 18 + i) * 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(470, 48 + i * 5);
|
||||
ctx.quadraticCurveTo(520 + spread, 60 + i * 14, 570 + spread, 38 + i * 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制福建舰测试 HUD。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
* @param {string} title 入场标题
|
||||
* @param {string} userInfo 用户身份信息
|
||||
*/
|
||||
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||
const enter = Math.min(1, Math.max(0, (progress - 0.12) / 0.2));
|
||||
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
|
||||
const alpha = easeInOutCubic(enter) * leave;
|
||||
const y = h * 0.17 - (1 - enter) * 22;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "rgba(15,23,42,0.68)";
|
||||
ctx.strokeStyle = "rgba(103,232,249,0.72)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowColor = "rgba(103,232,249,0.95)";
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.fillStyle = "#cffafe";
|
||||
ctx.font = "700 16px serif";
|
||||
ctx.fillText("FUJIAN AIRCRAFT CARRIER PREVIEW", w * 0.5, y - 24);
|
||||
ctx.fillStyle = "#a5f3fc";
|
||||
ctx.font = "700 18px serif";
|
||||
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "900 34px serif";
|
||||
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角矩形路径。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 左上角 x
|
||||
* @param {number} y 左上角 y
|
||||
* @param {number} w 宽度
|
||||
* @param {number} h 高度
|
||||
* @param {number} r 圆角半径
|
||||
*/
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动福建舰航母入场预览特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} onEnd 结束回调
|
||||
* @param {object} options 特效附加参数
|
||||
* @returns {{cancel: Function}}
|
||||
*/
|
||||
function start(canvas, onEnd, options = {}) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const waves = createWaves(w, h);
|
||||
const title = String(options.effect_title || "福建舰 航母入场").trim() || "福建舰 航母入场";
|
||||
const userInfo = String(options.effect_user_info || "").trim();
|
||||
const startTime = performance.now();
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
|
||||
/**
|
||||
* 统一结束动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐帧绘制福建舰入场动画。
|
||||
*
|
||||
* @param {number} now 当前高精度时间
|
||||
*/
|
||||
function animate(now) {
|
||||
const progress = Math.min(1, (now - startTime) / DURATION);
|
||||
const enter = easeInOutCubic(Math.min(1, progress / 0.72));
|
||||
const exit = easeInOutCubic(Math.max(0, (progress - 0.78) / 0.22));
|
||||
const scale = Math.min(1.02, Math.max(0.64, w / 1280));
|
||||
const carrierX = -w * 0.24 + enter * w * 0.76 + exit * w * 0.54;
|
||||
const carrierY = h * 0.66 + Math.sin(progress * 18) * 3;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawBackdrop(ctx, w, h, progress);
|
||||
drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale);
|
||||
drawCarrier(ctx, carrierX, carrierY, scale, progress);
|
||||
drawHud(ctx, w, h, progress, title, userInfo);
|
||||
|
||||
if (progress < 1) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.FujianEffect = FujianEffect;
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 文件功能:聊天室歼-35 战机入场特效
|
||||
*
|
||||
* 使用全屏透明 Canvas 绘制隐身战机高速掠过、喷口尾焰、音爆环、
|
||||
* 流光航迹与战术 HUD 字幕,作为座驾/载具入场的战机版本原型。
|
||||
*/
|
||||
|
||||
const J35Effect = (() => {
|
||||
const DURATION = 8200;
|
||||
const STEEL = "#9ca3af";
|
||||
const DARK_STEEL = "#1f2937";
|
||||
const COCKPIT = "#0f172a";
|
||||
const AFTERBURNER = "#38bdf8";
|
||||
const WARNING_RED = "#ef4444";
|
||||
|
||||
/**
|
||||
* 缓出曲线,用于战机高速进场后略微减速展示。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓入缓出曲线,用于 HUD 与音爆环淡入淡出。
|
||||
*
|
||||
* @param {number} t 0 到 1 的进度
|
||||
* @returns {number}
|
||||
*/
|
||||
function easeInOutSine(t) {
|
||||
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建高速流光粒子。
|
||||
*
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @returns {Array<Record<string, number|string>>}
|
||||
*/
|
||||
function createSpeedLines(w, h) {
|
||||
return Array.from({ length: 110 }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: h * (0.18 + Math.random() * 0.68),
|
||||
speed: 2 + Math.random() * 5,
|
||||
length: 90 + Math.random() * 190,
|
||||
alpha: 0.18 + Math.random() * 0.42,
|
||||
color: [AFTERBURNER, "#ffffff", "#fde68a", "#60a5fa"][Math.floor(Math.random() * 4)],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制夜航背景与扫描网格。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawBackdrop(ctx, w, h, progress) {
|
||||
const fade = Math.min(1, progress / 0.16) * Math.min(1, (1 - progress) / 0.12);
|
||||
const gradient = ctx.createRadialGradient(w * 0.5, h * 0.48, 0, w * 0.5, h * 0.5, Math.max(w, h) * 0.82);
|
||||
gradient.addColorStop(0, `rgba(15,23,42,${0.52 * fade})`);
|
||||
gradient.addColorStop(0.55, `rgba(8,47,73,${0.26 * fade})`);
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const y = h * (0.18 + i * 0.07);
|
||||
ctx.strokeStyle = `rgba(56,189,248,${0.08 * fade})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + Math.sin(progress * 18 + i) * 6);
|
||||
ctx.lineTo(w, y + Math.cos(progress * 15 + i) * 6);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制高速运动线,方向与战机飞行一致。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {Array<Record<string, number|string>>} lines 流光线集合
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawSpeedLines(ctx, lines, w, progress) {
|
||||
const fade = Math.min(1, progress / 0.12) * Math.min(1, (1 - progress) / 0.1);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
lines.forEach((line, index) => {
|
||||
const travel = (progress * (900 + line.speed * 120) + index * 71) % (w + 520);
|
||||
const x = w + 260 - travel;
|
||||
ctx.globalAlpha = line.alpha * fade;
|
||||
ctx.strokeStyle = line.color;
|
||||
ctx.lineWidth = 1.4 + line.speed * 0.18;
|
||||
ctx.shadowColor = line.color;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, line.y);
|
||||
ctx.lineTo(x + line.length, line.y + Math.sin(progress * 22 + index) * 4);
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制战机尾部双发喷口尾焰。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawAfterburners(ctx, progress) {
|
||||
const pulse = 0.78 + Math.sin(progress * 70) * 0.16;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
[[168, -12], [168, 12]].forEach(([x, y]) => {
|
||||
const flame = ctx.createLinearGradient(x, y, x + 170, y);
|
||||
flame.addColorStop(0, `rgba(255,255,255,${0.9 * pulse})`);
|
||||
flame.addColorStop(0.18, `rgba(56,189,248,${0.78 * pulse})`);
|
||||
flame.addColorStop(0.58, `rgba(59,130,246,${0.3 * pulse})`);
|
||||
flame.addColorStop(1, "rgba(59,130,246,0)");
|
||||
ctx.fillStyle = flame;
|
||||
ctx.shadowColor = AFTERBURNER;
|
||||
ctx.shadowBlur = 26;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - 9);
|
||||
ctx.bezierCurveTo(x + 58, y - 18, x + 115, y - 18, x + 178, y);
|
||||
ctx.bezierCurveTo(x + 115, y + 18, x + 58, y + 18, x, y + 9);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制歼-35 风格隐身战机主体。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 战机中心 x
|
||||
* @param {number} y 战机中心 y
|
||||
* @param {number} scale 缩放比例
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawJet(ctx, x, y, scale, progress) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.rotate(Math.sin(progress * 10) * 0.018);
|
||||
|
||||
drawAfterburners(ctx, progress);
|
||||
|
||||
const bodyGradient = ctx.createLinearGradient(-210, -62, 190, 62);
|
||||
bodyGradient.addColorStop(0, "#d1d5db");
|
||||
bodyGradient.addColorStop(0.34, "#6b7280");
|
||||
bodyGradient.addColorStop(0.64, "#374151");
|
||||
bodyGradient.addColorStop(1, "#111827");
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(148,163,184,0.9)";
|
||||
ctx.shadowBlur = 18;
|
||||
|
||||
// 主翼与机体采用多边形硬折线,体现隐身战机边缘。
|
||||
ctx.fillStyle = bodyGradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-230, 0);
|
||||
ctx.lineTo(-96, -44);
|
||||
ctx.lineTo(12, -120);
|
||||
ctx.lineTo(58, -42);
|
||||
ctx.lineTo(170, -70);
|
||||
ctx.lineTo(142, -18);
|
||||
ctx.lineTo(202, 0);
|
||||
ctx.lineTo(142, 18);
|
||||
ctx.lineTo(170, 70);
|
||||
ctx.lineTo(58, 42);
|
||||
ctx.lineTo(12, 120);
|
||||
ctx.lineTo(-96, 44);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const spineGradient = ctx.createLinearGradient(-200, -16, 178, 16);
|
||||
spineGradient.addColorStop(0, "#e5e7eb");
|
||||
spineGradient.addColorStop(0.45, "#6b7280");
|
||||
spineGradient.addColorStop(1, "#1f2937");
|
||||
ctx.fillStyle = spineGradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-222, 0);
|
||||
ctx.lineTo(-80, -24);
|
||||
ctx.lineTo(92, -18);
|
||||
ctx.lineTo(184, 0);
|
||||
ctx.lineTo(92, 18);
|
||||
ctx.lineTo(-80, 24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 机头下方深色进气道。
|
||||
ctx.fillStyle = "rgba(3,7,18,0.72)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-92, -33);
|
||||
ctx.lineTo(-38, -74);
|
||||
ctx.lineTo(-8, -55);
|
||||
ctx.lineTo(-60, -24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-92, 33);
|
||||
ctx.lineTo(-38, 74);
|
||||
ctx.lineTo(-8, 55);
|
||||
ctx.lineTo(-60, 24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 座舱盖。
|
||||
const canopy = ctx.createLinearGradient(-148, -18, -58, 18);
|
||||
canopy.addColorStop(0, "#020617");
|
||||
canopy.addColorStop(0.55, "#1e3a8a");
|
||||
canopy.addColorStop(1, "#111827");
|
||||
ctx.fillStyle = canopy;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-160, 0);
|
||||
ctx.bezierCurveTo(-130, -28, -78, -28, -48, 0);
|
||||
ctx.bezierCurveTo(-78, 26, -130, 26, -160, 0);
|
||||
ctx.fill();
|
||||
|
||||
// 双垂尾。
|
||||
ctx.fillStyle = DARK_STEEL;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(78, -44);
|
||||
ctx.lineTo(154, -120);
|
||||
ctx.lineTo(132, -38);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(78, 44);
|
||||
ctx.lineTo(154, 120);
|
||||
ctx.lineTo(132, 38);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 中国战机识别元素:低调红星,不做过大以免影响隐身外形。
|
||||
drawRedStar(ctx, 18, -47, 15);
|
||||
drawRedStar(ctx, 18, 47, 15);
|
||||
|
||||
// 面板高光线。
|
||||
ctx.strokeStyle = "rgba(229,231,235,0.34)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-205, 0);
|
||||
ctx.lineTo(168, 0);
|
||||
ctx.moveTo(-70, -22);
|
||||
ctx.lineTo(86, -16);
|
||||
ctx.moveTo(-70, 22);
|
||||
ctx.lineTo(86, 16);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制低调红星机徽。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 中心 x
|
||||
* @param {number} y 中心 y
|
||||
* @param {number} radius 半径
|
||||
*/
|
||||
function drawRedStar(ctx, x, y, radius) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.fillStyle = "rgba(239,68,68,0.9)";
|
||||
ctx.strokeStyle = "rgba(254,202,202,0.68)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||
const r = i % 2 === 0 ? radius : radius * 0.42;
|
||||
const px = Math.cos(angle) * r;
|
||||
const py = Math.sin(angle) * r;
|
||||
if (i === 0) {
|
||||
ctx.moveTo(px, py);
|
||||
} else {
|
||||
ctx.lineTo(px, py);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制高速掠过时的音爆环。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
*/
|
||||
function drawSonicRing(ctx, w, h, progress) {
|
||||
const ringProgress = Math.max(0, Math.min(1, (progress - 0.38) / 0.22));
|
||||
if (ringProgress <= 0 || ringProgress >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alpha = Math.sin(ringProgress * Math.PI);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.strokeStyle = `rgba(224,242,254,${0.42 * alpha})`;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.5, h * 0.54, w * (0.08 + ringProgress * 0.32), h * (0.03 + ringProgress * 0.11), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(56,189,248,${0.2 * alpha})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.5, h * 0.54, w * (0.12 + ringProgress * 0.44), h * (0.05 + ringProgress * 0.16), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制战术 HUD 字幕。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} w 画布宽度
|
||||
* @param {number} h 画布高度
|
||||
* @param {number} progress 播放进度
|
||||
* @param {string} title 入场标题
|
||||
* @param {string} userInfo 用户身份信息
|
||||
*/
|
||||
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||
const enter = Math.min(1, Math.max(0, (progress - 0.13) / 0.18));
|
||||
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16));
|
||||
const alpha = easeInOutSine(enter) * leave;
|
||||
const y = h * 0.18 - (1 - enter) * 26;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.textAlign = "center";
|
||||
ctx.shadowColor = "rgba(56,189,248,0.95)";
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillStyle = "rgba(2,6,23,0.62)";
|
||||
ctx.strokeStyle = "rgba(56,189,248,0.72)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#bae6fd";
|
||||
ctx.font = "700 16px serif";
|
||||
ctx.fillText("STEALTH FIGHTER ARRIVAL", w * 0.5, y - 24);
|
||||
ctx.fillStyle = "#e0f2fe";
|
||||
ctx.font = "700 18px serif";
|
||||
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "900 34px serif";
|
||||
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角矩形路径。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||
* @param {number} x 左上角 x
|
||||
* @param {number} y 左上角 y
|
||||
* @param {number} w 宽度
|
||||
* @param {number} h 高度
|
||||
* @param {number} r 圆角半径
|
||||
*/
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动歼-35 战机入场特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} onEnd 结束回调
|
||||
* @param {object} options 特效附加参数
|
||||
* @returns {{cancel: Function}}
|
||||
*/
|
||||
function start(canvas, onEnd, options = {}) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const speedLines = createSpeedLines(w, h);
|
||||
const title = String(options.effect_title || "中国歼-35 破空入场").trim() || "中国歼-35 破空入场";
|
||||
const userInfo = String(options.effect_user_info || "").trim();
|
||||
const startTime = performance.now();
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
|
||||
/**
|
||||
* 统一结束动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐帧绘制战机入场动画。
|
||||
*
|
||||
* @param {number} now 当前高精度时间
|
||||
*/
|
||||
function animate(now) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(1, elapsed / DURATION);
|
||||
const entry = easeOutCubic(Math.min(1, progress / 0.62));
|
||||
const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24));
|
||||
const jetX = w * 1.2 - entry * w * 0.82 - exit * w * 0.58;
|
||||
const jetY = h * 0.58 + Math.sin(progress * 18) * 10;
|
||||
const scale = Math.min(1.14, Math.max(0.68, w / 1180));
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawBackdrop(ctx, w, h, progress);
|
||||
drawSpeedLines(ctx, speedLines, w, progress);
|
||||
drawSonicRing(ctx, w, h, progress);
|
||||
drawJet(ctx, jetX, jetY, scale, progress);
|
||||
drawHud(ctx, w, h, progress, title, userInfo);
|
||||
|
||||
if (progress < 1) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.J35Effect = J35Effect;
|
||||
@@ -84,6 +84,10 @@
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '🛒 商店管理' !!}
|
||||
</a>
|
||||
<a href="{{ route('admin.rides.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rides.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🚘 座驾管理
|
||||
</a>
|
||||
<a href="{{ route('admin.marriages.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.marriages.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '💒 婚姻管理' !!}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{{--
|
||||
文件功能:后台座驾独立管理页面
|
||||
支持查看、新增、编辑、上下架切换、删除座驾,以及配置价格、使用天数和欢迎语。
|
||||
--}}
|
||||
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '🚘 座驾管理')
|
||||
|
||||
@section('content')
|
||||
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
|
||||
|
||||
@php
|
||||
$isSuperAdmin = Auth::id() === 1;
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
showForm: false,
|
||||
editing: null,
|
||||
form: {
|
||||
name: '',
|
||||
slug: 'ride_',
|
||||
effect_key: '',
|
||||
icon: '🚘',
|
||||
description: '',
|
||||
price: 1000,
|
||||
duration_days: 7,
|
||||
welcome_message: '',
|
||||
sort_order: 80,
|
||||
is_active: true,
|
||||
},
|
||||
|
||||
openCreate() {
|
||||
this.editing = null;
|
||||
this.form = {
|
||||
name: '',
|
||||
slug: 'ride_',
|
||||
effect_key: '',
|
||||
icon: '🚘',
|
||||
description: '',
|
||||
price: 1000,
|
||||
duration_days: 7,
|
||||
welcome_message: '',
|
||||
sort_order: 80,
|
||||
is_active: true,
|
||||
};
|
||||
this.showForm = true;
|
||||
this.$nextTick(() => this.$refs.nameInput?.focus());
|
||||
},
|
||||
|
||||
openEdit(ride) {
|
||||
this.editing = ride;
|
||||
this.form = {
|
||||
name: ride.name,
|
||||
slug: ride.slug,
|
||||
effect_key: ride.effect_key,
|
||||
icon: ride.icon,
|
||||
description: ride.description || '',
|
||||
price: ride.price,
|
||||
duration_days: ride.duration_days || 7,
|
||||
welcome_message: ride.welcome_message || '',
|
||||
sort_order: ride.sort_order,
|
||||
is_active: ride.is_active,
|
||||
};
|
||||
this.showForm = true;
|
||||
this.$nextTick(() => this.$refs.nameInput?.focus());
|
||||
},
|
||||
|
||||
closeForm() {
|
||||
this.showForm = false;
|
||||
this.editing = null;
|
||||
}
|
||||
}">
|
||||
<div class="{{ $adminListHeaderCardClass }} mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="{{ $adminListHeaderTitleClass }}">聊天室座驾列表</h2>
|
||||
<p class="{{ $adminListHeaderSubtitleClass }}">单独管理座驾价格、使用天数、入场欢迎语和全屏特效 key。</p>
|
||||
</div>
|
||||
@if ($isSuperAdmin)
|
||||
<button type="button" @click="openCreate()" class="{{ $adminListPrimaryButtonClass }}">
|
||||
+ 新增座驾
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="{{ $adminListCardClass }}">
|
||||
<table class="{{ $adminListTableClass }}">
|
||||
<thead class="{{ $adminListTableHeadRowClass }}">
|
||||
<tr>
|
||||
<th class="{{ $adminListTableHeadCellClass }}">座驾</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }}">特效 Key</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-right">价格</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-center">使用天数</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-center">排序</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-center">状态</th>
|
||||
<th class="{{ $adminListTableHeadCellClass }} text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ $adminListTableBodyClass }}">
|
||||
@forelse ($rides as $ride)
|
||||
<tr class="{{ $adminListTableRowClass }} {{ $ride->is_active ? '' : 'opacity-50' }}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl leading-none">{{ $ride->icon }}</span>
|
||||
<div>
|
||||
<p class="{{ $adminListPrimaryTextClass }}">{{ $ride->name }}</p>
|
||||
<p class="{{ $adminListSecondaryTextClass }} font-mono">{{ $ride->slug }}</p>
|
||||
@if ($ride->description)
|
||||
<p class="mt-0.5 max-w-xs truncate text-xs text-gray-500" title="{{ $ride->description }}">
|
||||
{{ $ride->description }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600">{{ $ride->effect_key }}</td>
|
||||
<td class="px-4 py-3 text-right font-mono font-bold text-amber-600">
|
||||
{{ number_format($ride->price) }} 金
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $ride->duration_days }} 天</td>
|
||||
<td class="px-4 py-3 text-center font-mono text-xs text-gray-400">{{ $ride->sort_order }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<form method="POST" action="{{ route('admin.rides.toggle', $ride) }}" class="inline">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit"
|
||||
class="rounded-full px-2.5 py-1 text-xs font-bold transition {{ $ride->is_active ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
|
||||
{{ $ride->is_active ? '上架中' : '已下架' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@click="openEdit({{ json_encode([
|
||||
'id' => $ride->id,
|
||||
'name' => $ride->name,
|
||||
'slug' => $ride->slug,
|
||||
'effect_key' => $ride->effect_key,
|
||||
'icon' => $ride->icon,
|
||||
'description' => $ride->description,
|
||||
'price' => $ride->price,
|
||||
'duration_days' => $ride->duration_days,
|
||||
'welcome_message' => $ride->welcome_message,
|
||||
'sort_order' => $ride->sort_order,
|
||||
'is_active' => (bool) $ride->is_active,
|
||||
], JSON_UNESCAPED_UNICODE) }})"
|
||||
class="{{ $adminListActionButtonClass }} text-indigo-600 hover:bg-indigo-50 hover:text-indigo-800">
|
||||
编辑
|
||||
</button>
|
||||
@if ($isSuperAdmin)
|
||||
<form method="POST" action="{{ route('admin.rides.destroy', $ride) }}"
|
||||
data-admin-confirm="确定要删除「{{ $ride->name }}」吗?此操作不可撤销!">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="{{ $adminListActionButtonClass }} text-red-500 hover:bg-red-50 hover:text-red-700">
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="{{ $adminListEmptyClass }}">暂无座驾数据</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div x-show="showForm" x-cloak class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.45);">
|
||||
<div @click.stop class="max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-2xl bg-white shadow-2xl"
|
||||
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="translate-y-4 opacity-0"
|
||||
x-transition:enter-end="translate-y-0 opacity-100">
|
||||
<div class="flex items-center justify-between border-b px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-gray-800" x-text="editing ? '编辑座驾:' + editing.name : '新增座驾'"></h3>
|
||||
<button type="button" @click="closeForm()" class="text-2xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
|
||||
<form method="POST"
|
||||
:action="editing ? '{{ url('admin/rides') }}/' + editing.id : '{{ route('admin.rides.store') }}'"
|
||||
class="space-y-4 px-6 py-5">
|
||||
@csrf
|
||||
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">座驾名称 <span class="text-red-500">*</span></label>
|
||||
<input x-ref="nameInput" type="text" name="name" x-model="form.name" required maxlength="100"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">Slug <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="slug" x-model="form.slug" required maxlength="100"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
<p class="mt-1 text-[11px] text-gray-500">格式:ride_j35、ride_df5c。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">特效 Key <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="effect_key" x-model="form.effect_key" required maxlength="50"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
<p class="mt-1 text-[11px] text-gray-500">对应 resources/js/effects/<key>.js。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">图标 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="icon" x-model="form.icon" required maxlength="20"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">价格(金币)<span class="text-red-500">*</span></label>
|
||||
<input type="number" name="price" x-model="form.price" required min="0"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">座驾描述</label>
|
||||
<textarea name="description" x-model="form.description" rows="2" maxlength="500"
|
||||
class="w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">使用天数 <span class="text-red-500">*</span></label>
|
||||
<input type="number" name="duration_days" x-model="form.duration_days" required min="1"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">排序权重</label>
|
||||
<input type="number" name="sort_order" x-model="form.sort_order" min="0"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-gray-600">入场欢迎语句</label>
|
||||
<textarea name="welcome_message" x-model="form.welcome_message" rows="2" maxlength="255"
|
||||
placeholder="支持 {name} 用户名、{ride} 座驾名,例如:【{name}】驾驶【{ride}】震撼入场!"
|
||||
class="w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="hidden" name="is_active" :value="form.is_active ? 1 : 0">
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" x-model="form.is_active" class="peer sr-only">
|
||||
<div class="h-5 w-10 rounded-full bg-gray-200 transition peer-checked:bg-indigo-500 peer-focus:ring-2 peer-focus:ring-indigo-300"></div>
|
||||
<div class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow transition peer-checked:translate-x-5"></div>
|
||||
</label>
|
||||
<span class="text-sm text-gray-600" x-text="form.is_active ? '上架显示' : '下架隐藏'"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-3 border-t pt-2">
|
||||
<button type="button" @click="closeForm()"
|
||||
class="rounded-lg border border-gray-300 px-5 py-2 text-sm text-gray-600 transition hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-indigo-600 px-6 py-2 text-sm font-bold text-white shadow transition hover:bg-indigo-700">
|
||||
<span x-text="editing ? '保存修改' : '创建座驾'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -126,7 +126,8 @@
|
||||
'localClearStorageKey' => "local_clear_msg_id_{$room->id}",
|
||||
'welcomeMessage' => $initialWelcomeMessage ?? null,
|
||||
'welcomeMessages' => $initialWelcomeMessages ?? [],
|
||||
'entryEffect' => $newbieEffect ?: ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null)),
|
||||
'entryEffect' => $newbieEffect ?: ($initialRideEffect ?? ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null))),
|
||||
'entryEffectOptions' => $newbieEffect ? null : ($initialRideEffectOptions ?? null),
|
||||
'presenceTheme' => $initialPresenceTheme ?? null,
|
||||
'pendingProposal' => $pendingProposal ?? null,
|
||||
'pendingDivorce' => $pendingDivorce ?? null,
|
||||
|
||||
@@ -201,6 +201,8 @@ $welcomeMessages = [
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">✅ 签到</button>
|
||||
<button type="button" data-chat-feature-shortcut="shop"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🛍 商店</button>
|
||||
<button type="button" data-chat-feature-shortcut="ride"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🚘 座驾</button>
|
||||
<button type="button" data-chat-feature-shortcut="vip"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👑 会员</button>
|
||||
<button type="button" data-chat-feature-shortcut="game"
|
||||
@@ -330,6 +332,14 @@ $welcomeMessages = [
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #ddd6fe;border-radius:6px;cursor:pointer;">🎊 彩带庆典</button>
|
||||
<button type="button" data-chat-admin-effect="fireflies"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #bbf7d0;border-radius:6px;cursor:pointer;">✨ 萤火虫</button>
|
||||
<button type="button" data-chat-admin-effect-preview="j35"
|
||||
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#020617,#075985);color:#e0f2fe;border:1px solid #38bdf8;border-radius:8px;cursor:pointer;box-shadow:0 6px 16px rgba(14,165,233,.22);">✈️ 测试歼-35效果(仅自己可见)</button>
|
||||
<button type="button" data-chat-admin-effect-preview="99a"
|
||||
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#1f2a1d,#4d7c0f);color:#fef3c7;border:1px solid #bef264;border-radius:8px;cursor:pointer;box-shadow:0 6px 16px rgba(77,124,15,.26);">🛡️ 测试99A坦克效果(仅自己可见)</button>
|
||||
<button type="button" data-chat-admin-effect-preview="df5c"
|
||||
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#450a0a,#991b1b,#f97316);color:#fff7ed;border:1px solid #fdba74;border-radius:8px;cursor:pointer;box-shadow:0 6px 18px rgba(249,115,22,.3);">🚀 测试东风-5C效果(仅自己可见)</button>
|
||||
<button type="button" data-chat-admin-effect-preview="fujian"
|
||||
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#0f172a,#0f766e,#22d3ee);color:#ecfeff;border:1px solid #67e8f9;border-radius:8px;cursor:pointer;box-shadow:0 6px 18px rgba(34,211,238,.28);">🚢 测试福建舰效果(仅自己可见)</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -718,6 +718,62 @@
|
||||
|
||||
{{-- 商店弹窗业务脚本已迁移到 resources/js/chat-room/shop-controls.js --}}
|
||||
|
||||
{{-- ═══════════ 座驾弹窗 ═══════════ --}}
|
||||
<style>
|
||||
#ride-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; justify-content:center; align-items:center; font-family:'Microsoft YaHei',SimSun,sans-serif; }
|
||||
#ride-modal-inner { width:820px; max-width:96vw; max-height:90vh; background:#f8fafc; border-radius:10px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 14px 44px rgba(15,23,42,.42); }
|
||||
#ride-modal-header { background:linear-gradient(135deg,#0f172a,#0f766e); color:#fff; padding:12px 16px; display:flex; align-items:center; gap:12px; flex-shrink:0; }
|
||||
#ride-modal-title { font-size:15px; font-weight:bold; }
|
||||
#ride-modal-jjb { margin-left:auto; font-size:12px; background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.22); border-radius:999px; padding:4px 10px; }
|
||||
#ride-modal-close { cursor:pointer; font-size:20px; opacity:.82; }
|
||||
#ride-modal-close:hover { opacity:1; }
|
||||
#ride-current { margin:12px 14px 0; padding:9px 12px; background:#ecfdf5; border:1px solid #99f6e4; border-radius:8px; color:#115e59; display:flex; align-items:center; gap:8px; font-size:12px; min-height:38px; }
|
||||
.ride-current-icon { font-size:18px; }
|
||||
.ride-current-expire { margin-left:auto; color:#64748b; }
|
||||
.ride-current-empty { color:#64748b; }
|
||||
#ride-modal-body { padding:12px 14px 14px; overflow-y:auto; display:flex; flex-direction:column; gap:12px; }
|
||||
.ride-section-title { font-size:12px; font-weight:bold; color:#0f172a; display:flex; align-items:center; gap:6px; margin-bottom:8px; }
|
||||
#ride-items-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(188px,1fr)); gap:10px; }
|
||||
.ride-card { background:#fff; border:1px solid #dbe4ef; border-radius:8px; padding:11px; display:flex; flex-direction:column; gap:8px; min-height:156px; box-shadow:0 3px 10px rgba(15,23,42,.04); }
|
||||
.ride-card.active { border-color:#14b8a6; box-shadow:0 0 0 1px rgba(20,184,166,.18),0 6px 16px rgba(20,184,166,.08); }
|
||||
.ride-card-head { display:flex; align-items:center; gap:7px; min-width:0; }
|
||||
.ride-card-icon { font-size:22px; flex-shrink:0; }
|
||||
.ride-card-title { font-size:13px; font-weight:bold; color:#102033; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ride-active-badge { margin-left:auto; font-size:10px; color:#047857; background:#d1fae5; border-radius:999px; padding:2px 7px; flex-shrink:0; }
|
||||
.ride-card-desc { color:#64748b; font-size:11px; line-height:1.45; min-height:32px; }
|
||||
.ride-card-meta { display:flex; justify-content:space-between; color:#475569; font-size:11px; gap:8px; }
|
||||
.ride-buy-btn { margin-top:auto; border:none; border-radius:6px; background:#0f766e; color:#fff; font-size:12px; font-weight:bold; padding:7px 8px; cursor:pointer; }
|
||||
.ride-buy-btn:hover { background:#0d9488; }
|
||||
#ride-purchase-list { background:#fff; border:1px solid #e2e8f0; border-radius:8px; overflow:hidden; }
|
||||
.ride-record { display:grid; grid-template-columns:1.4fr .7fr .8fr 1.1fr; gap:8px; padding:8px 10px; border-bottom:1px solid #f1f5f9; color:#475569; font-size:11px; align-items:center; }
|
||||
.ride-record:last-child { border-bottom:none; }
|
||||
.ride-empty { grid-column:1/-1; text-align:center; padding:22px 0; color:#94a3b8; font-size:12px; }
|
||||
.ride-error { color:#dc2626; }
|
||||
</style>
|
||||
|
||||
<div id="ride-modal" data-ride-items-url="{{ route('rides.items') }}" data-ride-buy-url="{{ route('rides.buy') }}">
|
||||
<div id="ride-modal-inner">
|
||||
<div id="ride-modal-header">
|
||||
<div id="ride-modal-title">🚘 聊天室座驾</div>
|
||||
<div id="ride-modal-jjb">💰 <strong id="ride-jjb">--</strong> 金币</div>
|
||||
<span id="ride-modal-close" data-ride-modal-close>✕</span>
|
||||
</div>
|
||||
<div id="ride-current"><span class="ride-current-empty">当前未启用座驾</span></div>
|
||||
<div id="ride-modal-body">
|
||||
<div>
|
||||
<div class="ride-section-title">可购买座驾</div>
|
||||
<div id="ride-items-list"><div class="ride-empty">加载中...</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ride-section-title">购买记录</div>
|
||||
<div id="ride-purchase-list"><div class="ride-empty">暂无座驾购买记录</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 座驾弹窗业务脚本已迁移到 resources/js/chat-room/ride-controls.js --}}
|
||||
|
||||
{{-- ═══════════ 会员中心弹窗 ═══════════ --}}
|
||||
<style>
|
||||
#vip-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9999; justify-content:center; align-items:center; }
|
||||
|
||||
@@ -372,6 +372,12 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
->name('shop.buy');
|
||||
Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename');
|
||||
|
||||
// ---- 座驾(独立页面,独立座驾购买记录)----
|
||||
Route::get('/rides/items', [\App\Http\Controllers\RideController::class, 'items'])->name('rides.items');
|
||||
Route::post('/rides/buy', [\App\Http\Controllers\RideController::class, 'buy'])
|
||||
->middleware('throttle:chat-shop-buy')
|
||||
->name('rides.buy');
|
||||
|
||||
// ---- 银行资金接口 ----
|
||||
Route::get('/bank', [\App\Http\Controllers\BankController::class, 'info'])->name('bank.info');
|
||||
Route::get('/bank/ranking', [\App\Http\Controllers\BankController::class, 'ranking'])->name('bank.ranking');
|
||||
@@ -513,6 +519,11 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
||||
Route::put('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'update'])->name('shop.update');
|
||||
Route::patch('/shop/{shopItem}/toggle', [\App\Http\Controllers\Admin\ShopItemController::class, 'toggle'])->name('shop.toggle');
|
||||
Route::delete('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'destroy'])->name('shop.destroy');
|
||||
Route::get('/rides', [\App\Http\Controllers\Admin\RideController::class, 'index'])->name('rides.index');
|
||||
Route::post('/rides', [\App\Http\Controllers\Admin\RideController::class, 'store'])->name('rides.store');
|
||||
Route::put('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'update'])->name('rides.update');
|
||||
Route::patch('/rides/{ride}/toggle', [\App\Http\Controllers\Admin\RideController::class, 'toggle'])->name('rides.toggle');
|
||||
Route::delete('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'destroy'])->name('rides.destroy');
|
||||
|
||||
// 💒 婚姻管理(superlevel 及以上)
|
||||
Route::prefix('marriages')->name('marriages.')->group(function () {
|
||||
|
||||
@@ -12,10 +12,12 @@ use App\Events\MessageSent;
|
||||
use App\Models\Department;
|
||||
use App\Models\Gift;
|
||||
use App\Models\Position;
|
||||
use App\Models\Ride;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPosition;
|
||||
use App\Models\UserRidePurchase;
|
||||
use App\Models\VipLevel;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
@@ -44,7 +46,7 @@ class ChatControllerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Redis::flushall();
|
||||
$this->flushChatRoomRedisState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1095,6 +1097,169 @@ class ChatControllerTest extends TestCase
|
||||
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试有效座驾用户首次进房时会生成座驾播报并返回座驾入场特效。
|
||||
*/
|
||||
public function test_active_ride_user_join_message_uses_ride_presence_payload(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'rideroom']);
|
||||
$user = User::factory()->create(['has_received_new_gift' => true]);
|
||||
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_j35'], [
|
||||
'name' => '歼-35测试座驾',
|
||||
'effect_key' => 'j35',
|
||||
'description' => '测试座驾',
|
||||
'icon' => '🛩️',
|
||||
'price' => 18888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 80,
|
||||
'is_active' => true,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】震撼入场',
|
||||
]);
|
||||
UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 18888,
|
||||
'expires_at' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$history = collect($response->viewData('historyMessages'));
|
||||
$rideMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence');
|
||||
$entryBroadcast = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'entry_broadcast');
|
||||
|
||||
$this->assertNotNull($rideMessage);
|
||||
$this->assertNull($entryBroadcast);
|
||||
$this->assertSame('座驾播报', $rideMessage['from_user']);
|
||||
$this->assertSame('j35', $rideMessage['ride_key']);
|
||||
$this->assertSame('乘坐【歼-35测试座驾】闪亮登场', $rideMessage['effect_title']);
|
||||
$this->assertSame("用户 {$user->username} · 部门 无部门 · 职务 无职务 · 会员 普通会员", $rideMessage['effect_user_info']);
|
||||
$this->assertStringContainsString($user->username, $rideMessage['content']);
|
||||
$this->assertStringContainsString('部门 无部门 · 职务 无职务 · 会员 普通会员', $rideMessage['content']);
|
||||
$this->assertStringNotContainsString("用户 {$user->username} · 部门 无部门", $rideMessage['content']);
|
||||
$this->assertSame('j35', $response->viewData('initialRideEffect'));
|
||||
$this->assertSame([
|
||||
'effect_title' => '乘坐【歼-35测试座驾】闪亮登场',
|
||||
'effect_user_info' => "用户 {$user->username} · 部门 无部门 · 职务 无职务 · 会员 普通会员",
|
||||
'ride_name' => '歼-35测试座驾',
|
||||
'operator' => $user->username,
|
||||
], $response->viewData('initialRideEffectOptions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试会员用户有座驾时优先播放座驾,并在座驾播报中展示完整身份摘要。
|
||||
*/
|
||||
public function test_ride_presence_takes_priority_over_vip_presence_and_includes_identity_summary(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'rvip']);
|
||||
$vipLevel = VipLevel::factory()->create([
|
||||
'name' => '至尊会员',
|
||||
'icon' => '👑',
|
||||
'join_effect' => 'lightning',
|
||||
'join_banner_style' => 'storm',
|
||||
'allow_custom_messages' => true,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'vip_level_id' => $vipLevel->id,
|
||||
'hy_time' => now()->addDays(30),
|
||||
'custom_join_message' => '{username} 带着风暴王座闪耀降临',
|
||||
'has_received_new_gift' => true,
|
||||
]);
|
||||
$department = Department::create([
|
||||
'name' => '战备部',
|
||||
'rank' => 90,
|
||||
'color' => '#0f766e',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$position = Position::create([
|
||||
'department_id' => $department->id,
|
||||
'name' => '试飞官',
|
||||
'icon' => '🛡️',
|
||||
'rank' => 90,
|
||||
'level' => 90,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
UserPosition::create([
|
||||
'user_id' => $user->id,
|
||||
'position_id' => $position->id,
|
||||
'appointed_at' => now(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_99a'], [
|
||||
'name' => '99A测试座驾',
|
||||
'effect_key' => '99a',
|
||||
'description' => '测试座驾',
|
||||
'icon' => '🛡️',
|
||||
'price' => 28888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 90,
|
||||
'is_active' => true,
|
||||
'welcome_message' => '【{name}】乘坐【{ride}】闪亮登场',
|
||||
]);
|
||||
UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 28888,
|
||||
'expires_at' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$history = collect($response->viewData('historyMessages'));
|
||||
$rideMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence');
|
||||
$vipPresenceMessage = $history->first(fn (array $message): bool => ($message['action'] ?? '') === 'vip_presence');
|
||||
|
||||
$this->assertNotNull($rideMessage);
|
||||
$this->assertNull($vipPresenceMessage);
|
||||
$this->assertStringContainsString('部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员', $rideMessage['content']);
|
||||
$this->assertStringNotContainsString("用户 {$user->username} · 部门 战备部", $rideMessage['content']);
|
||||
$this->assertSame("用户 {$user->username} · 部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员", $rideMessage['effect_user_info']);
|
||||
$this->assertStringContainsString($user->username, $rideMessage['content']);
|
||||
$this->assertSame('99a', $response->viewData('initialRideEffect'));
|
||||
$this->assertSame("用户 {$user->username} · 部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员", $response->viewData('initialRideEffectOptions')['effect_user_info']);
|
||||
$this->assertNull($response->viewData('initialWelcomeMessage'));
|
||||
$this->assertNull($response->viewData('initialPresenceTheme'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试过期座驾用户进房时不会触发座驾播报。
|
||||
*/
|
||||
public function test_expired_ride_does_not_trigger_ride_presence_payload(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => '过期房']);
|
||||
$user = User::factory()->create(['has_received_new_gift' => true]);
|
||||
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_df5c'], [
|
||||
'name' => '过期座驾',
|
||||
'effect_key' => 'df5c',
|
||||
'description' => '测试座驾',
|
||||
'icon' => '🚀',
|
||||
'price' => 18888,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 80,
|
||||
'is_active' => true,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】震撼入场',
|
||||
]);
|
||||
$purchase = UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 18888,
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$history = collect($response->viewData('historyMessages'));
|
||||
$this->assertNull($history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence'));
|
||||
$this->assertNull($response->viewData('initialRideEffect'));
|
||||
$this->assertSame('expired', $purchase->fresh()->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试新人首次进房时首屏历史包含礼包公告、AI 欢迎和普通进场播报。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾接口功能测试。
|
||||
*
|
||||
* 覆盖座驾列表、购买、续期、替换、余额不足和后台配置保存。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\Ride;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRidePurchase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 座驾控制器功能测试
|
||||
* 验证前台座驾购买接口与后台商品配置的核心行为。
|
||||
*/
|
||||
class RideControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 每个测试前清空 Redis,避免房间在线状态串扰。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->flushChatRoomRedisState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试座驾列表返回上架座驾、当前座驾和购买记录。
|
||||
*/
|
||||
public function test_items_returns_ride_items_and_purchase_state(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ride = $this->createRide(['name' => '歼-35测试座驾', 'slug' => 'ride_j35']);
|
||||
|
||||
UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 18888,
|
||||
'expires_at' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('rides.items'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['slug' => 'ride_j35'])
|
||||
->assertJsonPath('current_ride.item.slug', 'ride_j35')
|
||||
->assertJsonPath('purchases.0.item.slug', 'ride_j35');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试金币不足时不能购买座驾。
|
||||
*/
|
||||
public function test_cannot_buy_ride_when_balance_is_not_enough(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 100]);
|
||||
$room = Room::create(['room_name' => '座驾余额房']);
|
||||
$this->joinRoom($user, $room);
|
||||
$ride = $this->createRide(['price' => 18888]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('rides.buy'), [
|
||||
'item_id' => $ride->id,
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJsonPath('status', 'error');
|
||||
|
||||
$this->assertSame(100, (int) $user->fresh()->jjb);
|
||||
$this->assertDatabaseMissing('user_ride_purchases', [
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('user_currency_logs', [
|
||||
'user_id' => $user->id,
|
||||
'source' => CurrencySource::RIDE_BUY->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试购买座驾会扣金币并生成当前激活记录。
|
||||
*/
|
||||
public function test_can_buy_ride_and_activate_it(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 30000]);
|
||||
$room = Room::create(['room_name' => '座驾购买房']);
|
||||
$this->joinRoom($user, $room);
|
||||
$ride = $this->createRide(['slug' => 'ride_99a', 'price' => 18888, 'duration_days' => 7]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('rides.buy'), [
|
||||
'item_id' => $ride->id,
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('current_ride.item.slug', 'ride_99a')
|
||||
->assertJsonPath('jjb', 11112);
|
||||
|
||||
$this->assertDatabaseHas('user_ride_purchases', [
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 18888,
|
||||
]);
|
||||
$this->assertDatabaseHas('user_currency_logs', [
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'gold',
|
||||
'amount' => -18888,
|
||||
'balance_after' => 11112,
|
||||
'source' => CurrencySource::RIDE_BUY->value,
|
||||
'remark' => '购买聊天室座驾:测试座驾',
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试同款座驾续购会叠加有效期且保持一条 active 当前座驾。
|
||||
*/
|
||||
public function test_buying_same_ride_extends_active_purchase(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 50000]);
|
||||
$room = Room::create(['room_name' => '座驾续费房']);
|
||||
$this->joinRoom($user, $room);
|
||||
$ride = $this->createRide(['price' => 1000, 'duration_days' => 7]);
|
||||
|
||||
$purchase = UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $ride->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 1000,
|
||||
'expires_at' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('rides.buy'), [
|
||||
'item_id' => $ride->id,
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertSame('cancelled', $purchase->fresh()->status);
|
||||
$activePurchase = UserRidePurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('ride_id', $ride->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
$this->assertSame(1000, (int) $activePurchase->price_paid);
|
||||
$this->assertTrue($activePurchase->expires_at->greaterThan(now()->addDays(8)));
|
||||
$this->assertSame(1, UserRidePurchase::query()->where('user_id', $user->id)->where('status', 'active')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试购买不同座驾会取消旧座驾并激活新座驾。
|
||||
*/
|
||||
public function test_buying_different_ride_cancels_previous_active_ride(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 50000]);
|
||||
$room = Room::create(['room_name' => '座驾替换房']);
|
||||
$this->joinRoom($user, $room);
|
||||
$oldRide = $this->createRide(['slug' => 'ride_j35', 'price' => 1000]);
|
||||
$newRide = $this->createRide(['slug' => 'ride_df5c', 'price' => 2000]);
|
||||
|
||||
$oldPurchase = UserRidePurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $oldRide->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 1000,
|
||||
'expires_at' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('rides.buy'), [
|
||||
'item_id' => $newRide->id,
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('current_ride.item.slug', 'ride_df5c');
|
||||
|
||||
$this->assertSame('cancelled', $oldPurchase->fresh()->status);
|
||||
$this->assertDatabaseHas('user_ride_purchases', [
|
||||
'user_id' => $user->id,
|
||||
'ride_id' => $newRide->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试后台座驾独立模块可以保存欢迎语字段。
|
||||
*/
|
||||
public function test_admin_can_store_ride_with_welcome_message(): void
|
||||
{
|
||||
$admin = User::factory()->create(['id' => 1, 'user_level' => 100]);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.rides.store'), [
|
||||
'name' => '测试座驾',
|
||||
'slug' => 'ride_test',
|
||||
'effect_key' => 'test',
|
||||
'icon' => '🚘',
|
||||
'description' => '测试座驾说明',
|
||||
'price' => 12345,
|
||||
'duration_days' => 7,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】入场',
|
||||
'sort_order' => 99,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.rides.index'));
|
||||
$this->assertDatabaseHas('rides', [
|
||||
'slug' => 'ride_test',
|
||||
'effect_key' => 'test',
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】入场',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试后台座驾管理页面可以显示独立座驾数据。
|
||||
*/
|
||||
public function test_admin_can_view_ride_management_page(): void
|
||||
{
|
||||
$admin = User::factory()->create(['id' => 1, 'user_level' => 100]);
|
||||
$ride = $this->createRide(['name' => '后台可见座驾', 'slug' => 'ride_admin_visible']);
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.rides.index'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('座驾管理')
|
||||
->assertSee($ride->name)
|
||||
->assertSee('ride_admin_visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试用独立座驾。
|
||||
*
|
||||
* @param array<string, mixed> $attributes 覆盖字段
|
||||
*/
|
||||
private function createRide(array $attributes = []): Ride
|
||||
{
|
||||
$data = array_merge([
|
||||
'name' => '测试座驾',
|
||||
'slug' => 'ride_test_'.str()->random(8),
|
||||
'effect_key' => 'test_'.str()->random(8),
|
||||
'description' => '测试座驾说明',
|
||||
'icon' => '🚘',
|
||||
'price' => 1000,
|
||||
'duration_days' => 7,
|
||||
'sort_order' => 80,
|
||||
'is_active' => true,
|
||||
'welcome_message' => '【{name}】驾驶【{ride}】入场',
|
||||
], $attributes);
|
||||
|
||||
return Ride::query()->updateOrCreate(
|
||||
['slug' => $data['slug']],
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。
|
||||
*/
|
||||
private function joinRoom(User $user, Room $room): void
|
||||
{
|
||||
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class ShopControllerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Redis::flushall();
|
||||
$this->flushChatRoomRedisState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-1
@@ -3,8 +3,30 @@
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 测试基类
|
||||
* 提供项目 Feature Test 共用的辅助方法。
|
||||
*/
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
/**
|
||||
* 清理聊天室测试产生的 Redis 房间状态,避免误删浏览器登录会话。
|
||||
*/
|
||||
protected function flushChatRoomRedisState(): void
|
||||
{
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$cursor = '0';
|
||||
|
||||
do {
|
||||
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*', 'count' => 200]);
|
||||
|
||||
foreach ($keys ?? [] as $fullKey) {
|
||||
// Laravel Redis Facade 写入时会自动追加前缀,删除时要还原成业务短 key。
|
||||
$shortKey = $prefix ? substr((string) $fullKey, strlen($prefix)) : (string) $fullKey;
|
||||
Redis::del($shortKey);
|
||||
}
|
||||
} while ($cursor !== '0');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user