diff --git a/app/Events/EffectBroadcast.php b/app/Events/EffectBroadcast.php index 567aede..0d61a48 100644 --- a/app/Events/EffectBroadcast.php +++ b/app/Events/EffectBroadcast.php @@ -3,12 +3,12 @@ /** * 文件功能:聊天室全屏特效广播事件 * - * 管理员触发烟花/下雨/雷电等特效后, - * 通过 WebSocket 广播给房间内所有在线用户,前端收到后播放对应 Canvas 动画。 + * 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。 + * 支持指定接收者(target_username 为 null 则全员播放)。 * - * @package App\Events * @author ChatRoom Laravel - * @version 1.0.0 + * + * @version 2.0.0 */ namespace App\Events; @@ -26,19 +26,23 @@ class EffectBroadcast implements ShouldBroadcastNow /** * 支持的特效类型列表(用于校验) */ - public const TYPES = ['fireworks', 'rain', 'lightning']; + public const TYPES = ['fireworks', 'rain', 'lightning', 'snow']; /** * 构造函数 * - * @param int $roomId 房间 ID - * @param string $type 特效类型:fireworks / rain / lightning - * @param string $operator 触发特效的管理员用户名 + * @param int $roomId 房间 ID + * @param string $type 特效类型:fireworks / rain / lightning / snow + * @param string $operator 触发特效的用户名(购买者) + * @param string|null $targetUsername 接收者用户名(null = 全员) + * @param string|null $giftMessage 附带赠言 */ public function __construct( public readonly int $roomId, public readonly string $type, public readonly string $operator, + public readonly ?string $targetUsername = null, + public readonly ?string $giftMessage = null, ) {} /** @@ -49,20 +53,22 @@ class EffectBroadcast implements ShouldBroadcastNow public function broadcastOn(): array { return [ - new PresenceChannel('room.' . $this->roomId), + new PresenceChannel('room.'.$this->roomId), ]; } /** - * 广播数据:特效类型和操作者 + * 广播数据:特效类型、操作者、目标用户、赠言 * * @return array */ public function broadcastWith(): array { return [ - 'type' => $this->type, + 'type' => $this->type, 'operator' => $this->operator, + 'target_username' => $this->targetUsername, // null = 全员 + 'gift_message' => $this->giftMessage, ]; } } diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 9c7e38e..d38857d 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -2,11 +2,13 @@ /** * 文件功能:商店控制器 - * 提供商品列表查询、商品购买、改名卡使用 三个接口 + * 提供商品列表查询、商品购买(含赠送特效广播)、改名卡使用 三个接口 */ namespace App\Http\Controllers; +use App\Events\EffectBroadcast; +use App\Events\MessageSent; use App\Models\ShopItem; use App\Services\ShopService; use Illuminate\Http\JsonResponse; @@ -53,7 +55,11 @@ class ShopController extends Controller /** * 购买商品 * - * @param Request $request 含 item_id + * 单次特效卡额外支持: + * - recipient 接收者用户名(传 "all" 或留空则全员可见) + * - message 公屏赠言(可选) + * + * @param Request $request 含 item_id, recipient?, message? */ public function buy(Request $request): JsonResponse { @@ -72,9 +78,59 @@ class ShopController extends Controller $response = ['status' => 'success', 'message' => $result['message']]; - // 单次特效卡:告诉前端立即播放哪个特效 + // ── 单次特效卡:广播给指定用户或全员 ──────────────────────── if (isset($result['play_effect'])) { + $user = Auth::user(); + $roomId = (int) $request->room_id; + $recipient = trim($request->input('recipient', '')); // 空字符串 = 全员 + $message = trim($request->input('message', '')); + + // recipient 为空或 "all" 表示全员 + $targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient; + + // 广播特效事件(全员频道) + broadcast(new EffectBroadcast( + roomId: $roomId, + type: $result['play_effect'], + operator: $user->username, + targetUsername: $targetUsername, + giftMessage: $message ?: null, + ))->toOthers(); + + // 同时前端也需要播放(自己也要看到) $response['play_effect'] = $result['play_effect']; + $response['target_username'] = $targetUsername; + $response['gift_message'] = $message ?: null; + + // 公屏系统消息 + if ($roomId > 0) { + $icons = [ + 'fireworks' => '🎆', + 'rain' => '🌧', + 'lightning' => '⚡', + 'snow' => '❄️', + ]; + $icon = $icons[$result['play_effect']] ?? '✨'; + $toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友'; + $msgText = $message + ? "「{$message}」" + : ''; + $sysContent = "{$icon} 【{$user->username}】送出了一张 [{$item->name}],赠给 {$toStr}!{$msgText}"; + + // 广播系统消息到公屏 + broadcast(new MessageSent( + roomId: $roomId, + message: [ + 'id' => 0, + 'room_id' => $roomId, + 'username' => '系统', + 'content' => $sysContent, + 'type' => 'sys', + 'color' => '#cc6600', + 'created_at' => now()->toDateTimeString(), + ] + )); + } } // 返回最新金币余额 diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index b30bc8e..6fc332d 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -622,11 +622,17 @@ // DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载 document.addEventListener('DOMContentLoaded', setupScreenClearedListener); - // ── 全屏特效事件监听(烟花/下雨/雷电)─────────────── + // ── 全屏特效事件监听(烟花/下雨/雷电/下雪)───────── window.addEventListener('chat:effect', (e) => { const type = e.detail?.type; + const target = e.detail?.target_username; // null = 全员,otherwise 指定昵称 + const myName = window.chatContext?.username; + + // null 表示全员,或者 target 匹配自己才播放 if (type && typeof EffectManager !== 'undefined') { - EffectManager.play(type); + if (!target || target === myName) { + EffectManager.play(type); + } } }); diff --git a/resources/views/chat/partials/toolbar.blade.php b/resources/views/chat/partials/toolbar.blade.php index 8772e65..93b9ce5 100644 --- a/resources/views/chat/partials/toolbar.blade.php +++ b/resources/views/chat/partials/toolbar.blade.php @@ -15,7 +15,7 @@ {{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
-
🛍商店
+
商店
存点
娱乐
银行
@@ -586,6 +586,79 @@ margin-top: 5px; min-height: 14px; } + + /* 送礼对话框 */ + #gift-dialog { + display: none; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 10001; + justify-content: center; + align-items: center; + border-radius: 8px; + } + + #gift-dialog-box { + background: #fff; + border: 1px solid #5a8fc0; + border-radius: 8px; + padding: 16px 14px; + width: 250px; + box-shadow: 0 4px 20px rgba(0, 0, 0, .25); + } + + #gift-dialog-box h4 { + font-size: 12px; + font-weight: bold; + color: #336699; + margin: 0 0 3px; + } + + #gift-item-name { + font-size: 11px; + color: #555; + display: block; + border-bottom: 1px dashed #cde; + padding-bottom: 6px; + margin-bottom: 10px; + } + + .gift-label { + font-size: 11px; + color: #555; + margin-bottom: 3px; + display: block; + } + + .gift-input { + width: 100%; + border: 1px solid #aac; + border-radius: 4px; + padding: 5px 8px; + font-size: 12px; + box-sizing: border-box; + margin-bottom: 8px; + outline: none; + } + + .gift-input:focus { + border-color: #336699; + } + + .gift-hint { + font-size: 10px; + color: #999; + margin: -4px 0 8px; + display: block; + } + + #gift-err { + color: #c00; + font-size: 10px; + margin-top: 4px; + min-height: 14px; + }
@@ -619,6 +692,27 @@
+ + {{-- 送礼对话框:填写接收人 + 赠言 --}} +
+
+

🎁 赠出单次特效卡

+ + + + 💡 留空表示所有人;购买者必定可见 + + +
+
+ + +
+
+
@@ -732,10 +826,16 @@ btn.className = 'shop-btn shop-btn-use'; btn.textContent = '✦ 使用改名卡'; btn.onclick = openRenameModal; - } else { + } else if (item.type === 'instant') { + // 单次卡:打开送礼弹框 btn.className = 'shop-btn'; btn.innerHTML = `🪙 ${Number(item.price).toLocaleString()}`; - btn.onclick = () => buyItem(item.id, item.name, item.price); + btn.onclick = () => openGiftDialog(item); + } else { + // 周卡等其他商品:直接确认购买 + btn.className = 'shop-btn'; + btn.innerHTML = `🪙 ${Number(item.price).toLocaleString()}`; + btn.onclick = () => buyItem(item.id, item.name, item.price, 'all', ''); } card.appendChild(btn); list.appendChild(card); @@ -743,9 +843,40 @@ }); } - /** 购买商品 */ - window.buyItem = function(itemId, name, price) { - if (!confirm(`确定花费 ${Number(price).toLocaleString()} 金币购买【${name}】吗?`)) return; + /** + * 打开送礼弹框(仅单次特效卡) + * 让用户填写:送给谁 + 说一句话 + */ + let _giftItem = null; + window.openGiftDialog = function(item) { + _giftItem = item; + // 重置内容 + document.getElementById('gift-recipient').value = ''; + document.getElementById('gift-message').value = ''; + document.getElementById('gift-err').textContent = ''; + document.getElementById('gift-item-name').textContent = + `${item.icon} ${item.name}(🪙 ${Number(item.price).toLocaleString()})`; + document.getElementById('gift-dialog').style.display = 'flex'; + }; + + window.closeGiftDialog = function() { + document.getElementById('gift-dialog').style.display = 'none'; + _giftItem = null; + }; + + /** 送礼确认,提交购买 */ + window.confirmGift = function() { + if (!_giftItem) return; + const recipient = document.getElementById('gift-recipient').value.trim(); + const message = document.getElementById('gift-message').value.trim(); + document.getElementById('gift-err').textContent = ''; + closeGiftDialog(); + buyItem(_giftItem.id, _giftItem.name, _giftItem.price, recipient, message); + }; + + /** 购买商品(最终执行) */ + window.buyItem = function(itemId, name, price, recipient, message) { + const roomId = window.chatContext?.roomId ?? 0; fetch('{{ route('shop.buy') }}', { method: 'POST', headers: { @@ -754,24 +885,38 @@ 'X-CSRF-TOKEN': _csrf() }, body: JSON.stringify({ - item_id: itemId + item_id: itemId, + recipient: recipient || 'all', + message: message || '', + room_id: roomId, }), }) .then(r => r.json()) .then(data => { - showShopToast(data.message, data.status === 'success'); if (data.status === 'success') { + // 更新金币 if (data.jjb !== undefined) document.getElementById('shop-jjb').textContent = Number(data.jjb) .toLocaleString(); - if (data.play_effect && window.EffectManager) - window.EffectManager.play(data.play_effect); - // 刷新商品状态 + // 播放本地特效(购买者自己可见) + if (data.play_effect && window.EffectManager) { + const myTarget = data.target_username; + const myName = window.chatContext?.username; + // null = 全员,或者 target 是自己 + if (!myTarget || myTarget === myName) { + window.EffectManager.play(data.play_effect); + } + } + // 关闭商店弹窗,让用户欣赏特效 + closeShopModal(); + // 延迟刷新商品数据 shopLoaded = false; setTimeout(() => { fetchShopData(); shopLoaded = true; - }, 800); + }, 1000); + } else { + showShopToast(data.message, false); } }) .catch(() => showShopToast('⚠ 网络异常,请重试', false));