功能:单次特效卡支持赠送——送礼弹框、广播给指定用户/全员、公屏系统消息、购买后关闭商店展示特效
This commit is contained in:
@@ -3,12 +3,12 @@
|
|||||||
/**
|
/**
|
||||||
* 文件功能:聊天室全屏特效广播事件
|
* 文件功能:聊天室全屏特效广播事件
|
||||||
*
|
*
|
||||||
* 管理员触发烟花/下雨/雷电等特效后,
|
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
|
||||||
* 通过 WebSocket 广播给房间内所有在线用户,前端收到后播放对应 Canvas 动画。
|
* 支持指定接收者(target_username 为 null 则全员播放)。
|
||||||
*
|
*
|
||||||
* @package App\Events
|
|
||||||
* @author ChatRoom Laravel
|
* @author ChatRoom Laravel
|
||||||
* @version 1.0.0
|
*
|
||||||
|
* @version 2.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Events;
|
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 int $roomId 房间 ID
|
||||||
* @param string $type 特效类型:fireworks / rain / lightning
|
* @param string $type 特效类型:fireworks / rain / lightning / snow
|
||||||
* @param string $operator 触发特效的管理员用户名
|
* @param string $operator 触发特效的用户名(购买者)
|
||||||
|
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||||
|
* @param string|null $giftMessage 附带赠言
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $roomId,
|
public readonly int $roomId,
|
||||||
public readonly string $type,
|
public readonly string $type,
|
||||||
public readonly string $operator,
|
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
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new PresenceChannel('room.' . $this->roomId),
|
new PresenceChannel('room.'.$this->roomId),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 广播数据:特效类型和操作者
|
* 广播数据:特效类型、操作者、目标用户、赠言
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function broadcastWith(): array
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
'operator' => $this->operator,
|
'operator' => $this->operator,
|
||||||
|
'target_username' => $this->targetUsername, // null = 全员
|
||||||
|
'gift_message' => $this->giftMessage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件功能:商店控制器
|
* 文件功能:商店控制器
|
||||||
* 提供商品列表查询、商品购买、改名卡使用 三个接口
|
* 提供商品列表查询、商品购买(含赠送特效广播)、改名卡使用 三个接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Events\EffectBroadcast;
|
||||||
|
use App\Events\MessageSent;
|
||||||
use App\Models\ShopItem;
|
use App\Models\ShopItem;
|
||||||
use App\Services\ShopService;
|
use App\Services\ShopService;
|
||||||
use Illuminate\Http\JsonResponse;
|
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
|
public function buy(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -72,9 +78,59 @@ class ShopController extends Controller
|
|||||||
|
|
||||||
$response = ['status' => 'success', 'message' => $result['message']];
|
$response = ['status' => 'success', 'message' => $result['message']];
|
||||||
|
|
||||||
// 单次特效卡:告诉前端立即播放哪个特效
|
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||||
if (isset($result['play_effect'])) {
|
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['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(),
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回最新金币余额
|
// 返回最新金币余额
|
||||||
|
|||||||
@@ -622,11 +622,17 @@
|
|||||||
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
|
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
|
||||||
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
|
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
|
||||||
|
|
||||||
// ── 全屏特效事件监听(烟花/下雨/雷电)───────────────
|
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
|
||||||
window.addEventListener('chat:effect', (e) => {
|
window.addEventListener('chat:effect', (e) => {
|
||||||
const type = e.detail?.type;
|
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') {
|
if (type && typeof EffectManager !== 'undefined') {
|
||||||
EffectManager.play(type);
|
if (!target || target === myName) {
|
||||||
|
EffectManager.play(type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
{{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
|
{{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
|
||||||
<div class="chat-toolbar" id="toolbar-strip">
|
<div class="chat-toolbar" id="toolbar-strip">
|
||||||
<div class="tool-btn" onclick="openShopModal()" title="购买道具">🛍商店</div>
|
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
|
||||||
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
|
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
|
||||||
<div class="tool-btn" onclick="alert('🚧 娱乐功能开发中,敬请期待!')" title="娱乐(待开发)">娱乐</div>
|
<div class="tool-btn" onclick="alert('🚧 娱乐功能开发中,敬请期待!')" title="娱乐(待开发)">娱乐</div>
|
||||||
<div class="tool-btn" onclick="alert('🚧 银行功能开发中,敬请期待!')" title="银行(待开发)">银行</div>
|
<div class="tool-btn" onclick="alert('🚧 银行功能开发中,敬请期待!')" title="银行(待开发)">银行</div>
|
||||||
@@ -586,6 +586,79 @@
|
|||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
min-height: 14px;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="shop-modal">
|
<div id="shop-modal">
|
||||||
@@ -619,6 +692,27 @@
|
|||||||
<div id="rename-err"></div>
|
<div id="rename-err"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- 送礼对话框:填写接收人 + 赠言 --}}
|
||||||
|
<div id="gift-dialog">
|
||||||
|
<div id="gift-dialog-box">
|
||||||
|
<h4>🎁 赠出单次特效卡</h4>
|
||||||
|
<span id="gift-item-name"></span>
|
||||||
|
<label class="gift-label">送给谁?</label>
|
||||||
|
<input id="gift-recipient" class="gift-input" type="text" maxlength="20"
|
||||||
|
placeholder="用户名(留空 = 全场可见)">
|
||||||
|
<span class="gift-hint">💡 留空表示所有人;购买者必定可见</span>
|
||||||
|
<label class="gift-label">说一句话(公屏发送)</label>
|
||||||
|
<input id="gift-message" class="gift-input" type="text" maxlength="50" placeholder="可不填,百字以内">
|
||||||
|
<div id="gift-err"></div>
|
||||||
|
<div class="rename-btn-row" style="margin-top:8px;">
|
||||||
|
<button onclick="confirmGift()"
|
||||||
|
style="flex:1;background:#336699;color:#fff;border:none;border-radius:4px;padding:7px;cursor:pointer;font-size:12px;font-weight:bold;">确认赠出</button>
|
||||||
|
<button onclick="closeGiftDialog()"
|
||||||
|
style="flex:1;background:#eee;color:#555;border:1px solid #ccc;border-radius:4px;padding:7px;cursor:pointer;font-size:12px;">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -732,10 +826,16 @@
|
|||||||
btn.className = 'shop-btn shop-btn-use';
|
btn.className = 'shop-btn shop-btn-use';
|
||||||
btn.textContent = '✦ 使用改名卡';
|
btn.textContent = '✦ 使用改名卡';
|
||||||
btn.onclick = openRenameModal;
|
btn.onclick = openRenameModal;
|
||||||
} else {
|
} else if (item.type === 'instant') {
|
||||||
|
// 单次卡:打开送礼弹框
|
||||||
btn.className = 'shop-btn';
|
btn.className = 'shop-btn';
|
||||||
btn.innerHTML = `🪙 ${Number(item.price).toLocaleString()}`;
|
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);
|
card.appendChild(btn);
|
||||||
list.appendChild(card);
|
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') }}', {
|
fetch('{{ route('shop.buy') }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -754,24 +885,38 @@
|
|||||||
'X-CSRF-TOKEN': _csrf()
|
'X-CSRF-TOKEN': _csrf()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
item_id: itemId
|
item_id: itemId,
|
||||||
|
recipient: recipient || 'all',
|
||||||
|
message: message || '',
|
||||||
|
room_id: roomId,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
showShopToast(data.message, data.status === 'success');
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
|
// 更新金币
|
||||||
if (data.jjb !== undefined)
|
if (data.jjb !== undefined)
|
||||||
document.getElementById('shop-jjb').textContent = Number(data.jjb)
|
document.getElementById('shop-jjb').textContent = Number(data.jjb)
|
||||||
.toLocaleString();
|
.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;
|
shopLoaded = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchShopData();
|
fetchShopData();
|
||||||
shopLoaded = true;
|
shopLoaded = true;
|
||||||
}, 800);
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showShopToast(data.message, false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => showShopToast('⚠ 网络异常,请重试', false));
|
.catch(() => showShopToast('⚠ 网络异常,请重试', false));
|
||||||
|
|||||||
Reference in New Issue
Block a user