diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4547650 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -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" + } + ] +} diff --git a/app/Events/EffectBroadcast.php b/app/Events/EffectBroadcast.php index fcc945c..92f32f3 100644 --- a/app/Events/EffectBroadcast.php +++ b/app/Events/EffectBroadcast.php @@ -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,13 +29,13 @@ 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 附带赠言 diff --git a/app/Http/Controllers/Admin/ShopItemController.php b/app/Http/Controllers/Admin/ShopItemController.php index ffffd27..91a4efe 100644 --- a/app/Http/Controllers/Admin/ShopItemController.php +++ b/app/Http/Controllers/Admin/ShopItemController.php @@ -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 - */ - 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', - ]); - } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 2335920..2a74b10 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -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,7 @@ class ChatController extends Controller // 3. 广播和初始化欢迎(仅限初次进入) $newbieEffect = null; + $initialRideEffect = null; $initialPresenceTheme = null; $initialWelcomeMessage = null; $initialWelcomeMessages = []; @@ -227,6 +230,33 @@ class ChatController extends Controller if (! empty($vipPresencePayload['presence_effect'])) { broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers(); } + + $ridePresencePayload = $this->rideService->buildPresencePayload($user); + if ($ridePresencePayload) { + $rideWelcomeMsg = [ + 'id' => $this->chatState->nextMessageId($id), + 'room_id' => $id, + 'from_user' => '座驾播报', + 'to_user' => '大家', + 'content' => "{$ridePresencePayload['ride_icon']} {$ridePresencePayload['welcome_text']}", + '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'], + 'sent_at' => now()->toDateTimeString(), + ]; + + // 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。 + $this->chatState->pushMessage($id, $rideWelcomeMsg); + broadcast(new MessageSent($id, $rideWelcomeMsg)); + broadcast(new \App\Events\EffectBroadcast($id, $ridePresencePayload['ride_key'], $user->username))->toOthers(); + + $initialRideEffect = $ridePresencePayload['ride_key']; + $initialWelcomeMessages[] = $rideWelcomeMsg; + } } // 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染 @@ -314,6 +344,7 @@ class ChatController extends Controller 'user' => $user, 'weekEffect' => $this->shopService->getActiveWeekEffect($user), 'newbieEffect' => $newbieEffect, + 'initialRideEffect' => $initialRideEffect, 'initialPresenceTheme' => $initialPresenceTheme, 'initialWelcomeMessage' => $initialWelcomeMessage, 'initialWelcomeMessages' => $initialWelcomeMessages, diff --git a/app/Http/Controllers/RideController.php b/app/Http/Controllers/RideController.php new file mode 100644 index 0000000..8ea7553 --- /dev/null +++ b/app/Http/Controllers/RideController.php @@ -0,0 +1,78 @@ +json([ + 'items' => $this->rideService->activeItems() + ->map(fn (ShopItem $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 = ShopItem::query()->findOrFail((int) $request->integer('item_id')); + $result = $this->rideService->buy($user, $item); + + 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, + ]); + } +} diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index b1dca54..88056d8 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -40,19 +40,24 @@ 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) + ->where('type', '!=', ShopItem::TYPE_RIDE) + ->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); diff --git a/app/Http/Requests/BuyRideRequest.php b/app/Http/Requests/BuyRideRequest.php new file mode 100644 index 0000000..20055c9 --- /dev/null +++ b/app/Http/Requests/BuyRideRequest.php @@ -0,0 +1,55 @@ +user() !== null; + } + + /** + * 获取座驾购买请求验证规则。 + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'item_id' => ['required', 'integer', 'exists:shop_items,id'], + 'room_id' => ['required', 'integer', 'exists:rooms,id'], + ]; + } + + /** + * 获取座驾购买请求中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'item_id.required' => '请选择要购买的座驾。', + 'item_id.exists' => '座驾不存在或已被删除。', + 'room_id.required' => '请先进入聊天室后再购买座驾。', + 'room_id.exists' => '当前房间不存在。', + ]; + } +} diff --git a/app/Http/Requests/StoreShopItemRequest.php b/app/Http/Requests/StoreShopItemRequest.php new file mode 100644 index 0000000..9395e25 --- /dev/null +++ b/app/Http/Requests/StoreShopItemRequest.php @@ -0,0 +1,75 @@ +user()?->id === 1; + } + + /** + * 获取新增商品验证规则。 + * + * @return array|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'], + 'welcome_message' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['required', 'integer', 'min:0'], + 'is_active' => ['boolean'], + ]; + } + + /** + * 获取允许后台配置的商品类型。 + * + * @return array + */ + 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', + ShopItem::TYPE_RIDE, + ]; + } +} diff --git a/app/Http/Requests/UpdateShopItemRequest.php b/app/Http/Requests/UpdateShopItemRequest.php new file mode 100644 index 0000000..67c45a3 --- /dev/null +++ b/app/Http/Requests/UpdateShopItemRequest.php @@ -0,0 +1,77 @@ +user() !== null; + } + + /** + * 获取更新商品验证规则。 + * + * @return array|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'], + 'welcome_message' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['required', 'integer', 'min:0'], + 'is_active' => ['boolean'], + ]; + } + + /** + * 获取允许后台配置的商品类型。 + * + * @return array + */ + 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', + ShopItem::TYPE_RIDE, + ]; + } +} diff --git a/app/Models/ShopItem.php b/app/Models/ShopItem.php index f119a49..d9a0a67 100644 --- a/app/Models/ShopItem.php +++ b/app/Models/ShopItem.php @@ -14,6 +14,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class ShopItem extends Model { public const TYPE_SIGN_REPAIR = 'sign_repair'; + + public const TYPE_RIDE = 'ride'; + public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame']; protected $table = 'shop_items'; @@ -21,7 +24,7 @@ class ShopItem extends Model protected $fillable = [ 'name', 'slug', 'description', 'icon', 'price', 'type', 'duration_days', 'duration_minutes', 'sort_order', 'is_active', - 'intimacy_bonus', 'charm_bonus', + 'intimacy_bonus', 'charm_bonus', 'welcome_message', ]; protected $casts = [ @@ -60,6 +63,14 @@ class ShopItem extends Model return in_array($this->type, self::DECORATION_TYPES, true); } + /** + * 是否为聊天室座驾商品。 + */ + public function isRide(): bool + { + return $this->type === self::TYPE_RIDE; + } + /** * 是否为特效类商品(instant 或 duration,slug 以 once_ 或 week_ 开头) */ @@ -99,6 +110,18 @@ class ShopItem extends Model return null; } + /** + * 获取座驾全屏特效 key(去掉 ride_ 前缀)。 + */ + public function rideKey(): ?string + { + if (str_starts_with($this->slug, 'ride_')) { + return substr($this->slug, 5); + } + + return null; + } + /** * 获取所有上架商品(按排序) */ diff --git a/app/Services/RideService.php b/app/Services/RideService.php new file mode 100644 index 0000000..72ec79b --- /dev/null +++ b/app/Services/RideService.php @@ -0,0 +1,251 @@ + + */ + public function activeItems(): Collection + { + return ShopItem::query() + ->where('type', ShopItem::TYPE_RIDE) + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + /** + * 格式化座驾商品,供前端页面直接渲染。 + * + * @return array + */ + public function formatItem(ShopItem $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): ?UserPurchase + { + $purchase = UserPurchase::query() + ->with('shopItem') + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE)) + ->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|null + */ + public function formatCurrentRide(User $user): ?array + { + $purchase = $this->currentRide($user); + if (! $purchase || ! $purchase->shopItem) { + return null; + } + + return $this->formatPurchase($purchase); + } + + /** + * 获取用户最近座驾购买记录。 + * + * @return array> + */ + public function purchaseRecords(User $user, int $limit = 20): array + { + return UserPurchase::query() + ->with('shopItem') + ->where('user_id', $user->id) + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE)) + ->latest() + ->limit($limit) + ->get() + ->map(fn (UserPurchase $purchase) => $this->formatPurchase($purchase)) + ->values() + ->all(); + } + + /** + * 购买座驾:同款续期,不同款替换旧座驾且不退款。 + * + * @return array{ok:bool, message:string, current_ride?:array} + */ + public function buy(User $user, ShopItem $item): array + { + if (! $item->isRide() || ! $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} 金币。"]; + } + + DB::transaction(function () use ($user, $item, $days): void { + $now = Carbon::now(); + + // 先清理已过期的 active 座驾,避免旧状态影响替换判断。 + UserPurchase::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now) + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE)) + ->update(['status' => 'expired']); + + $activeRide = UserPurchase::query() + ->with('shopItem') + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE)) + ->orderByDesc('expires_at') + ->first(); + + // 座驾购买必须先扣金币,后续续期或替换都在同一个事务内完成。 + $user->decrement('jjb', $item->price); + + if ($activeRide && (int) $activeRide->shop_item_id === (int) $item->id) { + $baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now) + ? $activeRide->expires_at + : $now; + + // 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。 + $activeRide->update(['status' => 'cancelled']); + UserPurchase::create([ + 'user_id' => $user->id, + 'shop_item_id' => $item->id, + 'status' => 'active', + 'price_paid' => $item->price, + 'expires_at' => $baseTime->copy()->addDays($days), + ]); + + return; + } + + if ($activeRide) { + // 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。 + $activeRide->update(['status' => 'cancelled']); + } + + UserPurchase::create([ + 'user_id' => $user->id, + 'shop_item_id' => $item->id, + 'status' => 'active', + 'price_paid' => $item->price, + 'expires_at' => $now->copy()->addDays($days), + ]); + }); + + return [ + 'ok' => true, + 'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。", + 'current_ride' => $this->formatCurrentRide($user->fresh()), + ]; + } + + /** + * 构建进房座驾欢迎语与特效载荷。 + * + * @return array|null + */ + public function buildPresencePayload(User $user): ?array + { + $purchase = $this->currentRide($user); + $item = $purchase?->shopItem; + $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, + ]); + + return [ + 'ride_key' => $rideKey, + 'ride_name' => $item->name, + 'ride_icon' => (string) ($item->icon ?? '🚘'), + 'welcome_text' => ChatContentSanitizer::htmlText($rendered), + ]; + } + + /** + * 格式化单条座驾购买记录。 + * + * @return array + */ + private function formatPurchase(UserPurchase $purchase): array + { + $item = $purchase->shopItem; + + 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, + ]; + } +} diff --git a/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php b/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php new file mode 100644 index 0000000..15be403 --- /dev/null +++ b/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php @@ -0,0 +1,104 @@ +string('welcome_message', 255)->nullable()->after('charm_bonus')->comment('座驾入场欢迎语模板'); + }); + + if (DB::getDriverName() === 'mysql') { + 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','ride') NOT NULL COMMENT '道具类型'"); + } + + $rides = [ + [ + 'name' => '歼-35隐身战机', + 'slug' => 'ride_j35', + 'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。', + 'icon' => '🛩️', + 'price' => 18888, + 'duration_days' => 7, + 'sort_order' => 80, + 'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!', + ], + [ + 'name' => '99A主战坦克', + 'slug' => 'ride_99a', + 'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。', + 'icon' => '🛡️', + 'price' => 18888, + 'duration_days' => 7, + 'sort_order' => 81, + 'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!', + ], + [ + 'name' => '东风-5C战略导弹', + 'slug' => 'ride_df5c', + 'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。', + 'icon' => '🚀', + 'price' => 28888, + 'duration_days' => 7, + 'sort_order' => 82, + 'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!', + ], + [ + 'name' => '福建舰航母', + 'slug' => 'ride_fujian', + 'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。', + 'icon' => '⚓', + 'price' => 28888, + 'duration_days' => 7, + 'sort_order' => 83, + 'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!', + ], + ]; + + foreach ($rides as $ride) { + DB::table('shop_items')->updateOrInsert( + ['slug' => $ride['slug']], + $ride + [ + 'type' => 'ride', + 'duration_minutes' => 0, + 'intimacy_bonus' => 0, + 'charm_bonus' => 0, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + } + + /** + * 方法功能:移除座驾默认商品并回滚字段与类型。 + */ + public function down(): void + { + DB::table('shop_items')->whereIn('slug', ['ride_j35', 'ride_99a', 'ride_df5c', 'ride_fujian'])->delete(); + + if (DB::getDriverName() === 'mysql') { + 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 '道具类型'"); + } + + Schema::table('shop_items', function (Blueprint $table) { + $table->dropColumn('welcome_message'); + }); + } +}; diff --git a/plugins/chatroom-ride-development/.codex-plugin/plugin.json b/plugins/chatroom-ride-development/.codex-plugin/plugin.json new file mode 100644 index 0000000..0938e29 --- /dev/null +++ b/plugins/chatroom-ride-development/.codex-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "chatroom-ride-development", + "version": "0.1.0", + "description": "聊天室座驾开发插件,沉淀新增座驾的代码位置、命名规则和测试清单。", + "interface": { + "displayName": "Chatroom Ride Development" + }, + "skills": [ + { + "name": "chatroom-ride-development", + "path": "skills/chatroom-ride-development/SKILL.md" + } + ] +} diff --git a/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md b/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md new file mode 100644 index 0000000..6293d7a --- /dev/null +++ b/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md @@ -0,0 +1,53 @@ +--- +name: chatroom-ride-development +description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_ 座驾商品、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。" +--- + +# Chatroom Ride Development + +## 适用场景 + +- 新增或修改聊天室座驾商品。 +- 新增 `resources/js/effects/.js` 全屏座驾特效。 +- 调整座驾购买、续期、替换、入场欢迎语或后台价格/天数配置。 +- 排查座驾进房后特效不播放、欢迎语不显示、购买记录不正确的问题。 + +## 必须遵守 + +- 座驾商品类型固定为 `ride`,商品 slug 固定为 `ride_`。 +- `` 必须同时出现在: + - `shop_items.slug` + - `ShopItem::rideKey()` 可解析结果 + - `EffectBroadcast::TYPES` + - `resources/js/effects/effect-manager.js` + - `resources/js/effects/effect-sounds.js` + - `resources/js/effects/.js` +- 新增座驾默认通过迁移或 Seeder 写入 `shop_items`,字段至少包含名称、slug、图标、价格、`duration_days`、排序和 `welcome_message`。 +- `welcome_message` 支持 `{name}` 和 `{ride}`,输出前必须转义,不能直接信任后台输入。 +- 当前版本只允许用户同时拥有一个 active 座驾;同款续购叠加有效期,不同款替换旧座驾并把旧记录置为 `cancelled`。 +- 不要另建座驾购买表;购买记录继续使用 `user_purchases`。 + +## 新增座驾步骤 + +1. 新增全屏特效文件:`resources/js/effects/.js`。 +2. 在 `effect-manager.js` 注册模块加载和启动分支。 +3. 在 `effect-sounds.js` 注册音效启动分支。 +4. 在 `EffectBroadcast::TYPES` 加入 ``。 +5. 在迁移或 Seeder 中新增 `shop_items` 记录,slug 使用 `ride_`。 +6. 若后台预览需要,把按钮加入管理菜单预览区。 +7. 更新座驾相关 PHPUnit 测试,至少覆盖列表、购买、续期、替换和进房触发。 + +## 验证清单 + +- `node --check resources/js/effects/.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 后运行 `vendor/bin/pint --dirty --format=agent`。 + +## 特别注意 + +- 如果从 stash 恢复昨天的座驾特效,必须确认 untracked 父提交中的新特效文件也已恢复,不能只恢复已跟踪文件。 +- `99a` 这种以数字开头的 key 在 JS 对象字面量里必须加引号。 +- 新座驾的展示名可以是中文,但 effect key 必须保持小写短横线/数字/字母风格,避免前后端匹配失败。 diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index e3b9fae..765923d 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -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); diff --git a/resources/js/chat-room/admin-menu.js b/resources/js/chat-room/admin-menu.js index 933dd5d..bddddab 100644 --- a/resources/js/chat-room/admin-menu.js +++ b/resources/js/chat-room/admin-menu.js @@ -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(); } diff --git a/resources/js/chat-room/chat-state.js b/resources/js/chat-room/chat-state.js index 3a7fff8..c6ce250 100644 --- a/resources/js/chat-room/chat-state.js +++ b/resources/js/chat-room/chat-state.js @@ -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 = { diff --git a/resources/js/chat-room/ride-controls.js b/resources/js/chat-room/ride-controls.js new file mode 100644 index 0000000..a8e09be --- /dev/null +++ b/resources/js/chat-room/ride-controls.js @@ -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} + */ +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>} + */ +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} + */ +export async function loadRides() { + const list = document.getElementById("ride-items-list"); + if (list) { + list.innerHTML = '
加载中...
'; + } + + 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 = '
加载失败,请稍后重试
'; + } + } +} + +/** + * 渲染座驾弹窗全部内容。 + * + * @param {Record} 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|null} currentRide 当前座驾记录 + * @returns {void} + */ +function renderCurrentRide(currentRide) { + const box = document.getElementById("ride-current"); + if (!box) { + return; + } + + const item = currentRide?.item; + if (!item) { + box.innerHTML = '当前未启用座驾'; + return; + } + + box.innerHTML = ` + ${escapeHtml(item.icon || "🚘")} + ${escapeHtml(item.name)} 生效中 + 到期:${escapeHtml(currentRide.expires_at || "-")} + `; +} + +/** + * 渲染座驾商品卡片。 + * + * @param {Array>} items 座驾商品列表 + * @returns {void} + */ +function renderRideItems(items) { + const list = document.getElementById("ride-items-list"); + if (!list) { + return; + } + + if (!items.length) { + list.innerHTML = '
暂无上架座驾
'; + 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 ` +
+
+ ${escapeHtml(item.icon || "🚘")} + ${escapeHtml(item.name || "")} + ${isActive ? '当前' : ""} +
+
${escapeHtml(item.description || "")}
+
+ 💰 ${Number(item.price || 0).toLocaleString()} 金币 + ⏱ ${duration > 0 ? `${duration} 天` : "未配置"} +
+ +
+ `; + }).join(""); +} + +/** + * 渲染座驾购买记录。 + * + * @param {Array>} purchases 购买记录 + * @returns {void} + */ +function renderRidePurchases(purchases) { + const list = document.getElementById("ride-purchase-list"); + if (!list) { + return; + } + + if (!purchases.length) { + list.innerHTML = '
暂无座驾购买记录
'; + return; + } + + list.innerHTML = purchases.map((purchase) => { + const item = purchase.item || {}; + const statusMap = { + active: "使用中", + expired: "已过期", + cancelled: "已替换", + used: "已使用", + }; + + return ` +
+ ${escapeHtml(item.icon || "🚘")} ${escapeHtml(item.name || "未知座驾")} + ${escapeHtml(statusMap[purchase.status] || purchase.status || "-")} + ${Number(purchase.price_paid || 0).toLocaleString()} 金币 + ${escapeHtml(purchase.expires_at || "-")} +
+ `; + }).join(""); +} + +/** + * 购买或续费座驾。 + * + * @param {number|string} itemId 商品 ID + * @returns {Promise} + */ +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; +} diff --git a/resources/js/chat-room/toolbar.js b/resources/js/chat-room/toolbar.js index 6802d61..b3669de 100644 --- a/resources/js/chat-room/toolbar.js +++ b/resources/js/chat-room/toolbar.js @@ -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?.(), diff --git a/resources/js/effects/99a.js b/resources/js/effects/99a.js new file mode 100644 index 0000000..e54d3db --- /dev/null +++ b/resources/js/effects/99a.js @@ -0,0 +1,577 @@ +/** + * 文件功能:聊天室 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>} + */ + 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>} 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 播放进度 + */ + function drawHud(ctx, w, h, progress) { + 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 - 226, y - 42, 452, 88, 18); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "#fef3c7"; + ctx.font = "700 16px serif"; + ctx.fillText("ZTZ-99A ARMORED FORCE", w * 0.5, y - 12); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 40px serif"; + ctx.fillText("99A主战坦克 重装入场", w * 0.5, y + 28); + 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 结束回调 + * @returns {{cancel: Function}} + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const dust = createDust(w, h); + 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); + + if (progress < 1) { + animId = requestAnimationFrame(animate); + } else { + finish(false); + } + } + + animId = requestAnimationFrame(animate); + + return { + cancel() { + finish(true); + }, + }; + } + + return { start }; +})(); + +window.Type99AEffect = Type99AEffect; diff --git a/resources/js/effects/df5c.js b/resources/js/effects/df5c.js new file mode 100644 index 0000000..9178251 --- /dev/null +++ b/resources/js/effects/df5c.js @@ -0,0 +1,399 @@ +/** + * 文件功能:聊天室东风-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>} + */ + 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>} 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 播放进度 + */ + function drawHud(ctx, w, h, progress) { + 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 - 246, y - 42, 492, 88, 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 - 12); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 38px serif"; + ctx.fillText("东风-5C 洲际导弹 升空", w * 0.5, y + 28); + 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 结束回调 + * @returns {{cancel: Function}} + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const particles = createParticles(120); + 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); + + if (progress < 1) { + animId = requestAnimationFrame(animate); + } else { + finish(false); + } + } + + animId = requestAnimationFrame(animate); + + return { + cancel() { + finish(true); + }, + }; + } + + return { start }; +})(); + +window.Df5cEffect = Df5cEffect; diff --git a/resources/js/effects/effect-manager.js b/resources/js/effects/effect-manager.js index 32ad9f3..5f51446 100644 --- a/resources/js/effects/effect-manager.js +++ b/resources/js/effects/effect-manager.js @@ -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(); @@ -340,7 +344,7 @@ const EffectManager = (() => { /** * 播放指定特效 * - * @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 */ function play(type) { if (document.hidden) { @@ -434,6 +438,18 @@ const EffectManager = (() => { case "fireflies": started = _startEffect(window.FirefliesEffect, canvas, finishCurrent); break; + case "j35": + started = _startEffect(window.J35Effect, canvas, finishCurrent); + break; + case "99a": + started = _startEffect(window.Type99AEffect, canvas, finishCurrent); + break; + case "df5c": + started = _startEffect(window.Df5cEffect, canvas, finishCurrent); + break; + case "fujian": + started = _startEffect(window.FujianEffect, canvas, finishCurrent); + break; default: console.warn(`[EffectManager] 未知特效类型:${type}`); } diff --git a/resources/js/effects/effect-sounds.js b/resources/js/effects/effect-sounds.js index 09e5651..500f26a 100644 --- a/resources/js/effects/effect-sounds.js +++ b/resources/js/effects/effect-sounds.js @@ -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; } diff --git a/resources/js/effects/fujian.js b/resources/js/effects/fujian.js new file mode 100644 index 0000000..9ab7de8 --- /dev/null +++ b/resources/js/effects/fujian.js @@ -0,0 +1,521 @@ +/** + * 文件功能:聊天室福建舰航空母舰入场预览特效 + * + * 使用全屏透明 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>} + */ + 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>} 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 播放进度 + */ + function drawHud(ctx, w, h, progress) { + 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 - 236, y - 42, 472, 88, 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 - 12); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 38px serif"; + ctx.fillText("福建舰 航母入场", w * 0.5, y + 28); + 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 结束回调 + * @returns {{cancel: Function}} + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const waves = createWaves(w, h); + 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); + + if (progress < 1) { + animId = requestAnimationFrame(animate); + } else { + finish(false); + } + } + + animId = requestAnimationFrame(animate); + + return { + cancel() { + finish(true); + }, + }; + } + + return { start }; +})(); + +window.FujianEffect = FujianEffect; diff --git a/resources/js/effects/j35.js b/resources/js/effects/j35.js new file mode 100644 index 0000000..f7f5c8f --- /dev/null +++ b/resources/js/effects/j35.js @@ -0,0 +1,467 @@ +/** + * 文件功能:聊天室歼-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>} + */ + 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>} 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 播放进度 + */ + function drawHud(ctx, w, h, progress) { + 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 - 230, y - 44, 460, 92, 18); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "#bae6fd"; + ctx.font = "700 16px serif"; + ctx.fillText("STEALTH FIGHTER ARRIVAL", w * 0.5, y - 12); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 42px serif"; + ctx.fillText("中国歼-35 破空入场", w * 0.5, y + 28); + 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 结束回调 + * @returns {{cancel: Function}} + */ + function start(canvas, onEnd) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const speedLines = createSpeedLines(w, h); + 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); + + if (progress < 1) { + animId = requestAnimationFrame(animate); + } else { + finish(false); + } + } + + animId = requestAnimationFrame(animate); + + return { + cancel() { + finish(true); + }, + }; + } + + return { start }; +})(); + +window.J35Effect = J35Effect; diff --git a/resources/views/admin/shop/index.blade.php b/resources/views/admin/shop/index.blade.php index 8b74dd3..d789926 100644 --- a/resources/views/admin/shop/index.blade.php +++ b/resources/views/admin/shop/index.blade.php @@ -26,6 +26,7 @@ 'msg_name_color' => ['label' => '昵称颜色', 'color' => 'bg-pink-100 text-pink-700'], 'msg_text_color' => ['label' => '文字颜色', 'color' => 'bg-cyan-100 text-cyan-700'], 'avatar_frame' => ['label' => '头像框', 'color' => 'bg-amber-100 text-amber-700'], + 'ride' => ['label' => '聊天室座驾', 'color' => 'bg-slate-900 text-white'], ]; $isSuperAdmin = Auth::id() === 1; @endphp @@ -44,6 +45,7 @@ duration_minutes: 0, intimacy_bonus: 0, charm_bonus: 0, + welcome_message: '', sort_order: 0, is_active: true, }, @@ -61,6 +63,7 @@ duration_minutes: 0, intimacy_bonus: 0, charm_bonus: 0, + welcome_message: '', sort_order: 0, is_active: true, }; @@ -81,6 +84,7 @@ duration_minutes: item.duration_minutes || 0, intimacy_bonus: item.intimacy_bonus || 0, charm_bonus: item.charm_bonus || 0, + welcome_message: item.welcome_message || '', sort_order: item.sort_order, is_active: item.is_active, }; @@ -192,6 +196,7 @@ 'duration_minutes' => $item->duration_minutes, 'intimacy_bonus' => $item->intimacy_bonus, 'charm_bonus' => $item->charm_bonus, + 'welcome_message' => $item->welcome_message, 'sort_order' => $item->sort_order, 'is_active' => (bool) $item->is_active, ]) }})" @@ -295,10 +300,19 @@ + +
+ + +

仅座驾类型生效;不填写时使用系统默认欢迎语。

+
+
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 10ae3f7..1888324 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -126,7 +126,7 @@ '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))), 'presenceTheme' => $initialPresenceTheme ?? null, 'pendingProposal' => $pendingProposal ?? null, 'pendingDivorce' => $pendingDivorce ?? null, diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index e243184..5ead63f 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -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;">✅ 签到 + + + + +
@endif
diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 94dc437..16ae93d 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -718,6 +718,62 @@ {{-- 商店弹窗业务脚本已迁移到 resources/js/chat-room/shop-controls.js --}} +{{-- ═══════════ 座驾弹窗 ═══════════ --}} + + +
+
+
+
🚘 聊天室座驾
+
💰 -- 金币
+ +
+
当前未启用座驾
+
+
+
可购买座驾
+
加载中...
+
+
+
购买记录
+
暂无座驾购买记录
+
+
+
+
+ +{{-- 座驾弹窗业务脚本已迁移到 resources/js/chat-room/ride-controls.js --}} + {{-- ═══════════ 会员中心弹窗 ═══════════ --}}