feat: 神秘箱子系统完整实现 + 婚姻状态弹窗 + 工具栏优化
## 新功能 - 神秘箱子系统(MysteryBox)完整实现: - 新增 MysteryBox / MysteryBoxClaim 模型及迁移文件 - DropMysteryBoxJob / ExpireMysteryBoxJob 队列作业 - MysteryBoxController(/mystery-box/status + /mystery-box/claim) - 支持三种类型:普通箱(500~2000金)/ 稀有箱(5000~20000金)/ 黑化箱(陷阱扣200~1000金) - 调度器自动投放 + 管理员手动投放 - CurrencySource 新增 MYSTERY_BOX / MYSTERY_BOX_TRAP 枚举 - 婚姻状态弹窗(工具栏「婚姻」按钮): - 工具栏「呼叫」改为「婚姻」,点击打开婚姻状态弹窗 - 动态渲染三种状态:单身 / 求婚中 / 已婚 - 被求婚方可直接「答应 / 婉拒」;已婚可申请离婚(含二次确认) ## 优化修复 - frame.blade.php:Alpine.js CDN 补加 defer,修复所有组件初始化报错 - scripts.blade.php:神秘箱子暗号主动拦截(不依赖轮询),领取成功后弹 chatDialog 展示结果,更新金币余额 - MysteryBoxController:claim() 时 change() 补传 room_id 记录来源房间 - 后台游戏管理页(game-configs):投放箱子按钮颜色修复;弹窗替换为 window.adminDialog - admin/layouts:新增全局 adminDialog 弹窗组件(替代原生 alert/confirm) - baccarat-panel:FAB 拖动重构为 Alpine.js baccaratFab() 组件,与 slotFab 一致 - GAMES_TODO.md:神秘箱子移入已完成区,补全修复记录
This commit is contained in:
@@ -144,3 +144,7 @@ class ChatStateService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 迁移文件注意事项
|
||||
|
||||
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🎮 聊天室游戏开发进度
|
||||
|
||||
> 更新时间:2026-03-01
|
||||
> 更新时间:2026-03-03
|
||||
|
||||
---
|
||||
|
||||
@@ -30,29 +30,23 @@
|
||||
- **货币来源**:`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
|
||||
- **后台配置**:`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
|
||||
|
||||
---
|
||||
|
||||
## 🕐 待开发(明天继续)
|
||||
|
||||
### 📦 神秘箱子(Mystery Box)
|
||||
|
||||
**核心玩法**:系统定时或管理员手动投放神秘箱,最快发送暗号的用户开箱获奖
|
||||
|
||||
**待开发清单:**
|
||||
|
||||
- [ ] 数据库:`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
|
||||
- [ ] 模型:`MysteryBox` / `MysteryBoxClaim`
|
||||
- [ ] 队列 Job:`DropMysteryBoxJob`(投放箱子 + 公屏广播暗号 + 定时关闭)
|
||||
- [ ] 控制器:`MysteryBoxController`(`/mystery-box/claim` 领取接口)
|
||||
- [ ] 调度器:`routes/console.php` 按配置间隔自动投放
|
||||
- [ ] 后台:管理员可手动投放(管理员面板新增"投放箱子"按钮)
|
||||
- [ ] 前端:无需弹窗,用户直接在聊天框发送**暗号**(系统给的口令)领取
|
||||
- [ ] 货币来源:`CurrencySource::MYSTERY_BOX`
|
||||
- [ ] 特殊类型:普通箱(500~2000)/ 稀有箱(5000~20000)/ 黑化箱(陷阱,倒扣)
|
||||
- [ ] 配置参数:`auto_drop_enabled` / `auto_interval_hours` / `claim_window_seconds` / 各奖励范围 / `trap_chance_percent`
|
||||
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
|
||||
- **数据库**:`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
|
||||
- **模型**:`MysteryBox` / `MysteryBoxClaim`
|
||||
- **队列 Job**:`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob)/ `ExpireMysteryBoxJob`(到期处理)
|
||||
- **控制器**:`MysteryBoxController`(`/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
|
||||
- **前端**:`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
|
||||
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
|
||||
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
|
||||
- **货币来源**:`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
|
||||
- **后台配置**:`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
|
||||
|
||||
---
|
||||
|
||||
## 🕐 待开发
|
||||
|
||||
### 🐎 赛马竞猜(Horse Racing)
|
||||
|
||||
**核心玩法**:定时举办赛马,用户押注马匹,按注池赔率结算,跑马过程 WebSocket 实时播报
|
||||
@@ -98,7 +92,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔧 今日已修复的 Bug
|
||||
## 🔧 已修复的 Bug
|
||||
|
||||
1. **百家乐广播频道**:`Channel` → `PresenceChannel`,解决前端收不到 WebSocket 事件
|
||||
2. **百家乐余额检查**:`$user->gold` → `$user->jjb`(字段名错误)
|
||||
@@ -106,3 +100,8 @@
|
||||
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
|
||||
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
|
||||
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
|
||||
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
|
||||
8. **Alpine.js 初始化顺序**:`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
|
||||
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
|
||||
10. **神秘箱子流水记录**:`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
|
||||
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
|
||||
|
||||
@@ -96,6 +96,12 @@ enum CurrencySource: string
|
||||
/** 领取礼包红包——经验(用户抢到经验礼包时收入) */
|
||||
case RED_PACKET_RECV_EXP = 'red_packet_recv_exp';
|
||||
|
||||
/** 神秘箱子——领取奖励(普通箱/稀有箱,正数金币) */
|
||||
case MYSTERY_BOX = 'mystery_box';
|
||||
|
||||
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
|
||||
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -127,6 +133,8 @@ enum CurrencySource: string
|
||||
self::SLOT_CURSE => '老虎机诅咒',
|
||||
self::RED_PACKET_RECV => '领取礼包红包(金币)',
|
||||
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
|
||||
self::MYSTERY_BOX => '神秘箱子奖励',
|
||||
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,36 @@ class GameConfigController extends Controller
|
||||
|
||||
return back()->with('success', "「{$gameConfig->name}」参数已保存!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员手动投放神秘箱子。
|
||||
*
|
||||
* 立即分发 DropMysteryBoxJob 到队列,由 Horizon 执行箱子投放和公屏广播。
|
||||
*/
|
||||
public function dropMysteryBox(Request $request): JsonResponse
|
||||
{
|
||||
if (! \App\Models\GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放,请先开启。']);
|
||||
}
|
||||
|
||||
$boxType = $request->input('box_type', 'normal');
|
||||
|
||||
if (! in_array($boxType, ['normal', 'rare', 'trap'], true)) {
|
||||
return response()->json(['ok' => false, 'message' => '无效的箱子类型。']);
|
||||
}
|
||||
|
||||
// 检查是否有正在开放的箱子(避免同时多个)
|
||||
if (\App\Models\MysteryBox::currentOpenBox()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
|
||||
}
|
||||
|
||||
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
|
||||
|
||||
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
174
app/Http/Controllers/MysteryBoxController.php
Normal file
174
app/Http/Controllers/MysteryBoxController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子前台控制器
|
||||
*
|
||||
* 提供神秘箱子相关接口:
|
||||
* - /mystery-box/status 查询当前可领取的箱子(给前端轮询)
|
||||
* - /mystery-box/claim 用户发送暗号领取箱子
|
||||
*
|
||||
* 领取流程:
|
||||
* 1. 用户在聊天框发送暗号(前端拦截后调用此接口)
|
||||
* 2. 验证暗号匹配、箱子未过期、未已领取
|
||||
* 3. 随机奖励金额(trap=扣,其余=加)
|
||||
* 4. 写货币流水日志
|
||||
* 5. 公屏广播结果(中奖/踩雷)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Models\MysteryBoxClaim;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MysteryBoxController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
$box = MysteryBox::currentOpenBox();
|
||||
|
||||
if (! $box) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
// 计算剩余时间
|
||||
$secondsLeft = $box->expires_at ? max(0, now()->diffInSeconds($box->expires_at, false)) : null;
|
||||
|
||||
return response()->json([
|
||||
'active' => true,
|
||||
'box_id' => $box->id,
|
||||
'box_type' => $box->box_type,
|
||||
'type_name' => $box->typeName(),
|
||||
'type_emoji' => $box->typeEmoji(),
|
||||
'passcode' => $box->passcode,
|
||||
'seconds_left' => $secondsLeft,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户用暗号领取箱子。
|
||||
*/
|
||||
public function claim(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放。']);
|
||||
}
|
||||
|
||||
$passcode = strtoupper(trim((string) $request->input('passcode', '')));
|
||||
|
||||
if ($passcode === '') {
|
||||
return response()->json(['ok' => false, 'message' => '请输入暗号。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $passcode): JsonResponse {
|
||||
// 查找匹配暗号的可领取箱子(加锁防并发)
|
||||
$box = MysteryBox::query()
|
||||
->where('passcode', $passcode)
|
||||
->where('status', 'open')
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $box) {
|
||||
return response()->json(['ok' => false, 'message' => '暗号不正确,或箱子已被领走/已过期。']);
|
||||
}
|
||||
|
||||
// ① 随机奖励金额
|
||||
$reward = $box->rollReward();
|
||||
|
||||
// ② 货币变更
|
||||
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
|
||||
$remark = $reward >= 0
|
||||
? "神秘箱子【{$box->typeName()}】奖励"
|
||||
: "神秘箱子【黑化箱】陷阱扣除";
|
||||
|
||||
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
|
||||
|
||||
// ③ 写领取记录 + 更新箱子状态
|
||||
MysteryBoxClaim::create([
|
||||
'mystery_box_id' => $box->id,
|
||||
'user_id' => $user->id,
|
||||
'reward_amount' => $reward,
|
||||
]);
|
||||
|
||||
$box->update(['status' => 'claimed']);
|
||||
|
||||
// ④ 公屏广播结果
|
||||
$user->refresh();
|
||||
$this->broadcastResult($box, $user->username, $reward);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reward' => $reward,
|
||||
'balance' => $user->jjb ?? 0,
|
||||
'message' => $reward >= 0
|
||||
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
|
||||
: "☠️ 中了黑化陷阱!扣除 " . abs($reward) . ' 金币!',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 公屏广播开箱结果。
|
||||
*
|
||||
* @param MysteryBox $box 箱子实例
|
||||
* @param string $username 领取者用户名
|
||||
* @param int $reward 奖励金额(正/负)
|
||||
*/
|
||||
private function broadcastResult(MysteryBox $box, string $username, int $reward): void
|
||||
{
|
||||
$emoji = $box->typeEmoji();
|
||||
$typeName = $box->typeName();
|
||||
|
||||
if ($reward >= 0) {
|
||||
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}!"
|
||||
. "获得 🪙" . number_format($reward) . " 金币!";
|
||||
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
||||
} else {
|
||||
$content = "☠️【黑化陷阱】haha!【{$username}】 中了神秘黑化箱的陷阱!"
|
||||
. "被扣除 🪙" . number_format(abs($reward)) . " 金币!点背~";
|
||||
$color = '#f87171';
|
||||
}
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => $color,
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
122
app/Jobs/DropMysteryBoxJob.php
Normal file
122
app/Jobs/DropMysteryBoxJob.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:投放神秘箱子队列任务
|
||||
*
|
||||
* 由调度器或管理员手动触发,执行以下操作:
|
||||
* 1. 创建神秘箱记录(含暗号、类型、奖励范围)
|
||||
* 2. 公屏广播暗号提示(全服可见)
|
||||
* 3. 设定定时关闭任务(windows_seconds 秒后过期)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DropMysteryBoxJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* 最大重试次数。
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* @param string $boxType 箱子类型:normal | rare | trap
|
||||
* @param int|null $roomId 投放目标房间,null 时默认 1
|
||||
* @param string|null $passcode 手动指定暗号(null=自动生成)
|
||||
* @param int|null $droppedBy 投放者用户ID(null=系统自动)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $boxType = 'normal',
|
||||
public readonly ?int $roomId = 1,
|
||||
public readonly ?string $passcode = null,
|
||||
public readonly ?int $droppedBy = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行投放逻辑。
|
||||
*/
|
||||
public function handle(ChatStateService $chatState): void
|
||||
{
|
||||
// 检查游戏是否开启
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('mystery_box')?->params ?? [];
|
||||
$claimWindow = (int) ($config['claim_window_seconds'] ?? 120);
|
||||
$targetRoom = $this->roomId ?? 1;
|
||||
|
||||
// 自动生成随机暗号(若未指定)
|
||||
$passcode = $this->passcode ?? strtoupper(Str::random(6));
|
||||
|
||||
// 根据类型确定奖励范围
|
||||
[$rewardMin, $rewardMax] = match ($this->boxType) {
|
||||
'rare' => [
|
||||
(int) ($config['rare_reward_min'] ?? 5000),
|
||||
(int) ($config['rare_reward_max'] ?? 20000),
|
||||
],
|
||||
'trap' => [
|
||||
(int) ($config['trap_penalty_min'] ?? 200),
|
||||
(int) ($config['trap_penalty_max'] ?? 1000),
|
||||
],
|
||||
default => [
|
||||
(int) ($config['normal_reward_min'] ?? 500),
|
||||
(int) ($config['normal_reward_max'] ?? 2000),
|
||||
],
|
||||
};
|
||||
|
||||
// 创建箱子记录
|
||||
$box = MysteryBox::create([
|
||||
'box_type' => $this->boxType,
|
||||
'passcode' => $passcode,
|
||||
'reward_min' => $rewardMin,
|
||||
'reward_max' => $rewardMax,
|
||||
'status' => 'open',
|
||||
'expires_at' => now()->addSeconds($claimWindow),
|
||||
'dropped_by' => $this->droppedBy,
|
||||
]);
|
||||
|
||||
// 公屏广播暗号提示
|
||||
$emoji = $box->typeEmoji();
|
||||
$typeName = $box->typeName();
|
||||
$source = $this->droppedBy ? '管理员' : '系统';
|
||||
|
||||
$content = "{$emoji}【{$typeName}】{$source}投放了一个神秘箱子!"
|
||||
. "发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
|
||||
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId($targetRoom),
|
||||
'room_id' => $targetRoom,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => match ($this->boxType) {
|
||||
'rare' => '#c4b5fd',
|
||||
'trap' => '#f87171',
|
||||
default => '#34d399',
|
||||
},
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$chatState->pushMessage($targetRoom, $msg);
|
||||
broadcast(new MessageSent($targetRoom, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 定时关闭任务:到期后将箱子标记为 expired
|
||||
ExpireMysteryBoxJob::dispatch($box->id)->delay(now()->addSeconds($claimWindow + 5));
|
||||
}
|
||||
}
|
||||
66
app/Jobs/ExpireMysteryBoxJob.php
Normal file
66
app/Jobs/ExpireMysteryBoxJob.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:过期神秘箱子队列任务
|
||||
*
|
||||
* 在箱子到期后将其状态更新为 expired(若尚未被领取),并向公屏广播过期通知。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ExpireMysteryBoxJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* 最大重试次数。
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* @param int $boxId 要过期的箱子ID
|
||||
*/
|
||||
public function __construct(public readonly int $boxId) {}
|
||||
|
||||
/**
|
||||
* 执行过期逻辑。
|
||||
*/
|
||||
public function handle(ChatStateService $chatState): void
|
||||
{
|
||||
$box = MysteryBox::find($this->boxId);
|
||||
|
||||
// 箱子不存在或已经被领取/过期,跳过
|
||||
if (! $box || $box->status !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为过期
|
||||
$box->update(['status' => 'expired']);
|
||||
|
||||
// 公屏广播过期通知
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#9ca3af',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
124
app/Models/MysteryBox.php
Normal file
124
app/Models/MysteryBox.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子模型
|
||||
*
|
||||
* 管理聊天室内投放的神秘箱记录,提供领取状态管理及类型标签工具方法。
|
||||
* 对应表:mystery_boxes
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class MysteryBox extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'box_type',
|
||||
'passcode',
|
||||
'reward_min',
|
||||
'reward_max',
|
||||
'status',
|
||||
'expires_at',
|
||||
'dropped_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'reward_min' => 'integer',
|
||||
'reward_max' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 关联关系 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 领取记录(一个箱子只能被一人领取,但关联为 HasOne)
|
||||
*/
|
||||
public function claim(): HasOne
|
||||
{
|
||||
return $this->hasOne(MysteryBoxClaim::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有领取记录(逻辑上只有一条,保留 HasMany 供统计使用)
|
||||
*/
|
||||
public function claims(): HasMany
|
||||
{
|
||||
return $this->hasMany(MysteryBoxClaim::class);
|
||||
}
|
||||
|
||||
// ─── 查询作用域 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 当前可领取(open 状态 + 未过期)的箱子。
|
||||
*/
|
||||
public static function currentOpenBox(): ?static
|
||||
{
|
||||
return static::query()
|
||||
->where('status', 'open')
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
// ─── 工具方法 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 返回箱子类型的 emoji 前缀。
|
||||
*/
|
||||
public function typeEmoji(): string
|
||||
{
|
||||
return match ($this->box_type) {
|
||||
'normal' => '📦',
|
||||
'rare' => '💎',
|
||||
'trap' => '☠️',
|
||||
default => '📦',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回箱子类型中文名称。
|
||||
*/
|
||||
public function typeName(): string
|
||||
{
|
||||
return match ($this->box_type) {
|
||||
'normal' => '普通箱',
|
||||
'rare' => '稀有箱',
|
||||
'trap' => '黑化箱',
|
||||
default => '神秘箱',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机生成奖励金额(trap 类型为负数)。
|
||||
*/
|
||||
public function rollReward(): int
|
||||
{
|
||||
$amount = random_int(
|
||||
min(abs($this->reward_min), abs($this->reward_max)),
|
||||
max(abs($this->reward_min), abs($this->reward_max)),
|
||||
);
|
||||
|
||||
// trap 类型:倒扣金币(负数)
|
||||
return $this->box_type === 'trap' ? -$amount : $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断箱子是否已过期
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
53
app/Models/MysteryBoxClaim.php
Normal file
53
app/Models/MysteryBoxClaim.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子领取记录模型
|
||||
*
|
||||
* 记录每次神秘箱被用户发送暗号成功领取后的详情(关联箱子 + 领取者 + 实际奖励)。
|
||||
* 对应表:mystery_box_claims
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MysteryBoxClaim extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'mystery_box_id',
|
||||
'user_id',
|
||||
'reward_amount',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'reward_amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 关联关系 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 关联神秘箱子。
|
||||
*/
|
||||
public function mysteryBox(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MysteryBox::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联领取用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子主表迁移
|
||||
*
|
||||
* 记录每次系统/管理员投放的神秘箱信息,包含类型、暗号、奖惩范围及领取状态。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建神秘箱子表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mystery_boxes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// 箱子类型:normal=普通(500~2000) / rare=稀有(5000~20000) / trap=黑化(陷阱,倒扣)
|
||||
$table->enum('box_type', ['normal', 'rare', 'trap'])->default('normal')->comment('箱子类型');
|
||||
|
||||
// 领取暗号(管理员设置或自动生成,4~8位随机字串)
|
||||
$table->string('passcode', 20)->comment('领取暗号');
|
||||
|
||||
// 奖励金额范围(trap 类型此字段为负值上下界)
|
||||
$table->integer('reward_min')->default(500)->comment('最低奖励金币');
|
||||
$table->integer('reward_max')->default(2000)->comment('最高奖励金币');
|
||||
|
||||
// 状态:open=可领取 / claimed=已被领取 / expired=已过期
|
||||
$table->enum('status', ['open', 'claimed', 'expired'])->default('open')->comment('箱子状态');
|
||||
|
||||
// 领取截止时间(投放后 N 秒内有效)
|
||||
$table->timestamp('expires_at')->nullable()->comment('过期时间');
|
||||
|
||||
// 由哪个管理员用户投放(null=系统自动投放)
|
||||
$table->unsignedBigInteger('dropped_by')->nullable()->comment('投放者用户ID,null=系统自动');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除神秘箱子表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mystery_boxes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子领取记录表迁移
|
||||
*
|
||||
* 记录每个箱子被哪位用户在何时用什么暗号领取,以及实际奖励金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建神秘箱子领取记录表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mystery_box_claims', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// 关联箱子
|
||||
$table->unsignedBigInteger('mystery_box_id')->comment('关联神秘箱子ID');
|
||||
$table->foreign('mystery_box_id')->references('id')->on('mystery_boxes')->cascadeOnDelete();
|
||||
|
||||
// 领取用户
|
||||
$table->unsignedBigInteger('user_id')->comment('领取用户ID');
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
|
||||
// 实际奖励(正数=获得,负数=被扣)
|
||||
$table->integer('reward_amount')->comment('实际奖励金额(负数表示扣除)');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('mystery_box_id');
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除领取记录表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mystery_box_claims');
|
||||
}
|
||||
};
|
||||
@@ -70,14 +70,16 @@ class GameConfigSeeder extends Seeder
|
||||
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
|
||||
'enabled' => false,
|
||||
'params' => [
|
||||
'auto_drop_enabled' => false, // 是否自动定时投放
|
||||
'auto_interval_hours' => 2, // 自动投放间隔(小时)
|
||||
'auto_drop_enabled' => false, // 是否自动定时投放
|
||||
'auto_interval_hours' => 2, // 自动投放间隔(小时)
|
||||
'claim_window_seconds' => 60, // 领取窗口(秒)
|
||||
'min_reward' => 500, // 普通箱最低奖励
|
||||
'max_reward' => 2000, // 普通箱最高奖励
|
||||
'rare_min_reward' => 5000, // 稀有箱最低奖励
|
||||
'rare_max_reward' => 20000, // 稀有箱最高奖励
|
||||
'trap_chance_percent' => 10, // 黑化箱触发概率(%)
|
||||
'normal_reward_min' => 500, // 普通箱最低奖励
|
||||
'normal_reward_max' => 2000, // 普通箱最高奖励
|
||||
'rare_reward_min' => 5000, // 稀有箱最低奖励
|
||||
'rare_reward_max' => 20000, // 稀有箱最高奖励
|
||||
'trap_penalty_min' => 200, // 黑化箱最低惩罚
|
||||
'trap_penalty_max' => 1000, // 黑化箱最高惩罚
|
||||
'trap_chance_percent' => 10, // 黑化箱触发概率(%)
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -100,6 +100,31 @@
|
||||
<span class="text-xs text-gray-400">修改后立即生效(缓存60秒刷新)</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- 神秘箱子:手动投放区域 --}}
|
||||
@if ($game->game_key === 'mystery_box')
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<div class="text-xs font-bold text-gray-600 mb-2">🎯 手动投放箱子</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button onclick="dropBox('normal', {{ $game->id }})"
|
||||
style="padding:8px 16px; background:linear-gradient(135deg,#059669,#10b981); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
|
||||
📦 投放普通箱
|
||||
</button>
|
||||
<button onclick="dropBox('rare', {{ $game->id }})"
|
||||
style="padding:8px 16px; background:linear-gradient(135deg,#7c3aed,#a78bfa); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
|
||||
💎 投放稀有箱
|
||||
</button>
|
||||
<button onclick="dropBox('trap', {{ $game->id }})"
|
||||
style="padding:8px 16px; background:linear-gradient(135deg,#7f1d1d,#ef4444); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
|
||||
☠️ 投放黑化箱
|
||||
</button>
|
||||
<span class="text-xs text-gray-400">直接向 #1 房间投放,立即广播暗号</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@@ -153,10 +178,58 @@
|
||||
header.classList.toggle('bg-gray-50', !enabled);
|
||||
}
|
||||
|
||||
// Toast 提示
|
||||
alert(data.message);
|
||||
// 全局弹窗提示
|
||||
window.adminDialog.alert(data.message, enabled ? '游戏已开启' : '游戏已关闭', enabled ? '✅' : '⏸');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员手动投放神秘箱子
|
||||
*
|
||||
* @param {string} boxType 箱子类型:normal | rare | trap
|
||||
*/
|
||||
function dropBox(boxType) {
|
||||
const typeNames = {
|
||||
normal: '普通箱',
|
||||
rare: '稀有箱',
|
||||
trap: '黑化箱'
|
||||
};
|
||||
const typeIcons = {
|
||||
normal: '📦',
|
||||
rare: '💎',
|
||||
trap: '☠️'
|
||||
};
|
||||
const name = typeNames[boxType] || boxType;
|
||||
const icon = typeIcons[boxType] || '📦';
|
||||
|
||||
window.adminDialog.confirm(
|
||||
`确定要向 <b>#1 房间</b> 投放一个「${name}」吗?<br><span style="color:#64748b; font-size:12px;">箱子投放后将立即在公屏广播暗号,用户限时领取。</span>`,
|
||||
`投放${name}`,
|
||||
() => {
|
||||
fetch('/admin/mystery-box/drop', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
box_type: boxType
|
||||
}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
window.adminDialog.alert(
|
||||
data.message || (data.ok ? '投放成功!' : '投放失败'),
|
||||
data.ok ? '投放成功' : '投放失败',
|
||||
data.ok ? icon : '❌'
|
||||
);
|
||||
})
|
||||
.catch(() => window.adminDialog.alert('网络错误,请重试', '网络错误', '🌐'));
|
||||
},
|
||||
icon
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@@ -200,6 +273,14 @@
|
||||
'auto_drop_enabled' => ['label' => '自动定时投放', 'type' => 'boolean', 'unit' => ''],
|
||||
'auto_interval_hours' => ['label' => '自动投放间隔', 'type' => 'number', 'unit' => '小时', 'min' => 1],
|
||||
'claim_window_seconds' => ['label' => '领取窗口', 'type' => 'number', 'unit' => '秒', 'min' => 10],
|
||||
// 新键名
|
||||
'normal_reward_min' => ['label' => '普通箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'normal_reward_max' => ['label' => '普通箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'rare_reward_min' => ['label' => '稀有箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'rare_reward_max' => ['label' => '稀有箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'trap_penalty_min' => ['label' => '黑化箱最低惩罚', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'trap_penalty_max' => ['label' => '黑化箱最高惩罚', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
// 旧键名兼容(数据库中已存在的旧配置)
|
||||
'min_reward' => ['label' => '普通箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'max_reward' => ['label' => '普通箱最高奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
'rare_min_reward' => ['label' => '稀有箱最低奖励', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||
|
||||
@@ -175,6 +175,136 @@
|
||||
@yield('content')
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{-- ══════════════════════════════════════════════════════════
|
||||
全局弹窗组件:window.adminDialog.alert / window.adminDialog.confirm
|
||||
用法:
|
||||
window.adminDialog.alert('操作成功!', '✅ 提示');
|
||||
window.adminDialog.confirm('确定要删除?', '⚠️ 确认', () => { ... });
|
||||
══════════════════════════════════════════════════════════ --}}
|
||||
<div id="admin-dialog-overlay"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(15,23,42,.55);
|
||||
backdrop-filter:blur(3px); z-index:99999; align-items:center; justify-content:center;">
|
||||
<div id="admin-dialog-box"
|
||||
style="background:#fff; border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.22);
|
||||
min-width:320px; max-width:480px; width:90%; padding:32px 32px 24px; text-align:center;
|
||||
animation:admin-dialog-pop .25s cubic-bezier(.175,.885,.32,1.275);">
|
||||
<div id="admin-dialog-icon" style="font-size:36px; margin-bottom:10px;"></div>
|
||||
<div id="admin-dialog-title" style="font-size:16px; font-weight:800; color:#1e293b; margin-bottom:8px;">
|
||||
</div>
|
||||
<div id="admin-dialog-msg" style="font-size:14px; color:#475569; line-height:1.6; margin-bottom:20px;">
|
||||
</div>
|
||||
<div id="admin-dialog-btns" style="display:flex; gap:10px; justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes admin-dialog-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(.8);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
/**
|
||||
* 后台全局弹窗组件。
|
||||
*
|
||||
* 提供 alert / confirm 两种模式,替换原生 alert/confirm。
|
||||
*/
|
||||
window.adminDialog = (function() {
|
||||
const overlay = document.getElementById('admin-dialog-overlay');
|
||||
const box = document.getElementById('admin-dialog-box');
|
||||
const elIcon = document.getElementById('admin-dialog-icon');
|
||||
const elTitle = document.getElementById('admin-dialog-title');
|
||||
const elMsg = document.getElementById('admin-dialog-msg');
|
||||
const elBtns = document.getElementById('admin-dialog-btns');
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function close() {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
/** 点击遮罩层关闭 */
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建按钮元素
|
||||
*
|
||||
* @param {string} label 按钮文字
|
||||
* @param {string} color 按钮背景色
|
||||
* @param {Function} onClick 点击回调
|
||||
*/
|
||||
function makeBtn(label, color, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.cssText = `padding:9px 24px; border-radius:8px; border:none; cursor:pointer;
|
||||
font-size:14px; font-weight:700; color:#fff; background:${color};
|
||||
transition:opacity .15s; box-shadow:0 3px 10px rgba(0,0,0,.12);`;
|
||||
btn.onmouseover = () => btn.style.opacity = '.82';
|
||||
btn.onmouseout = () => btn.style.opacity = '1';
|
||||
btn.addEventListener('click', () => {
|
||||
close();
|
||||
if (onClick) onClick();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出提示框(仅「确定」按钮)
|
||||
*
|
||||
* @param {string} message 消息内容(支持 HTML)
|
||||
* @param {string} title 标题
|
||||
* @param {string} icon 图标 Emoji
|
||||
* @param {Function} onOk 确定回调
|
||||
*/
|
||||
function alert(message, title = '提示', icon = 'ℹ️', onOk = null) {
|
||||
elIcon.textContent = icon;
|
||||
elTitle.textContent = title;
|
||||
elMsg.innerHTML = message;
|
||||
elBtns.innerHTML = '';
|
||||
elBtns.appendChild(makeBtn('确定', '#4f46e5', onOk));
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出确认框(「确定」+「取消」按钮)
|
||||
*
|
||||
* @param {string} message 消息内容
|
||||
* @param {string} title 标题
|
||||
* @param {Function} onConfirm 确认回调
|
||||
* @param {string} icon 图标 Emoji
|
||||
*/
|
||||
function confirm(message, title = '确认操作', onConfirm = null, icon = '⚠️') {
|
||||
elIcon.textContent = icon;
|
||||
elTitle.textContent = title;
|
||||
elMsg.innerHTML = message;
|
||||
elBtns.innerHTML = '';
|
||||
|
||||
const confirmBtn = makeBtn('确定', '#4f46e5', onConfirm);
|
||||
const cancelBtn = makeBtn('取消', '#94a3b8', null);
|
||||
elBtns.appendChild(confirmBtn);
|
||||
elBtns.appendChild(cancelBtn);
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
return {
|
||||
alert,
|
||||
confirm,
|
||||
close
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
};
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
<link rel="stylesheet" href="/css/chat.css">
|
||||
</head>
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
@include('chat.partials.baccarat-panel')
|
||||
{{-- ═══════════ 老虎机游戏面板 ═══════════ --}}
|
||||
@include('chat.partials.slot-machine')
|
||||
{{-- ═══════════ 神秘箱子游戏面板 ═══════════ --}}
|
||||
@include('chat.partials.mystery-box')
|
||||
|
||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||
<script src="/js/effects/effect-sounds.js"></script>
|
||||
|
||||
@@ -295,130 +295,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 骰子悬浮入口(游戏开启时常驻,支持拖拽) ─── --}}
|
||||
<div id="baccarat-fab" x-data="{ visible: false }" x-show="visible" x-cloak
|
||||
style="position:fixed; bottom:90px; right:18px; z-index:9900; touch-action:none;">
|
||||
<button id="baccarat-fab-btn" x-on:click.stop="() => {}"
|
||||
style="width:52px; height:52px; border-radius:50%; border:none; cursor:grab;
|
||||
{{-- ─── 骨骰悬浮入口(游戏开启时常驻,支持拖拽) ─── --}}
|
||||
<div id="baccarat-fab" x-data="baccaratFab()" x-show="visible" x-cloak
|
||||
:style="'position:fixed; right:' + posX + 'px; bottom:' + posY + 'px; z-index:9900; touch-action:none; user-select:none;'"
|
||||
@pointerdown.prevent="startDrag($event)" @pointermove.window="onDrag($event)" @pointerup.window="endDrag($event)"
|
||||
@pointercancel.window="endDrag($event)">
|
||||
<button
|
||||
style="width:52px; height:52px; border-radius:50%; border:none;
|
||||
background:linear-gradient(135deg,#7c3aed,#4f46e5);
|
||||
box-shadow:0 4px 20px rgba(124,58,237,.5);
|
||||
font-size:22px; display:flex; align-items:center; justify-content:center;
|
||||
animation:pulse-fab 2s infinite; user-select:none;"
|
||||
title="百家乐下注中(可拖动)">🎲</button>
|
||||
:style="dragging ? 'cursor:grabbing;' : 'cursor:grab;'" title="百家乐下注中(可拖动)">🎲</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 百家乐骰子悬浮按钮拖拽逻辑
|
||||
* 需在 DOM 就绪后初始化,因为 x-cloak 元素初始隐藏
|
||||
* 百家乐骨骰悬浮按钮 Alpine 组件(拖动 + localStorage 位置持久化)
|
||||
*/
|
||||
(function initBaccaratFabDrag() {
|
||||
const LS_KEY = 'baccaratFabPos';
|
||||
function baccaratFab() {
|
||||
const STORAGE_KEY = 'baccarat_fab_pos';
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
return {
|
||||
visible: false,
|
||||
posX: saved?.x ?? 18,
|
||||
posY: saved?.y ?? 90,
|
||||
dragging: false,
|
||||
_startX: 0,
|
||||
_startY: 0,
|
||||
_origX: 0,
|
||||
_origY: 0,
|
||||
_moved: false,
|
||||
|
||||
function attachDrag() {
|
||||
const fab = document.getElementById('baccarat-fab');
|
||||
const btn = document.getElementById('baccarat-fab-btn');
|
||||
if (!fab || !btn || fab._dragInited) return;
|
||||
fab._dragInited = true;
|
||||
startDrag(e) {
|
||||
this.dragging = true;
|
||||
this._moved = false;
|
||||
this._startX = e.clientX;
|
||||
this._startY = e.clientY;
|
||||
this._origX = this.posX;
|
||||
this._origY = this.posY;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
|
||||
// 恢复上次拖拽位置
|
||||
const saved = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LS_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (saved) {
|
||||
fab.style.left = saved.left + 'px';
|
||||
fab.style.top = saved.top + 'px';
|
||||
fab.style.right = 'auto';
|
||||
fab.style.bottom = 'auto';
|
||||
}
|
||||
onDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
const dx = e.clientX - this._startX;
|
||||
const dy = e.clientY - this._startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
|
||||
this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx));
|
||||
this.posY = Math.max(4, Math.min(window.innerHeight - 60, this._origY + dy));
|
||||
},
|
||||
|
||||
let isDragging = false;
|
||||
let startX, startY, startLeft, startTop;
|
||||
endDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
x: this.posX,
|
||||
y: this.posY
|
||||
}));
|
||||
if (!this._moved) this.openPanel();
|
||||
},
|
||||
|
||||
function onStart(e) {
|
||||
const rect = fab.getBoundingClientRect();
|
||||
// 转为绝对 left/top
|
||||
fab.style.left = rect.left + 'px';
|
||||
fab.style.top = rect.top + 'px';
|
||||
fab.style.right = 'auto';
|
||||
fab.style.bottom = 'auto';
|
||||
|
||||
isDragging = false;
|
||||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
startX = cx;
|
||||
startY = cy;
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
btn.style.cursor = 'grabbing';
|
||||
|
||||
document.addEventListener('mousemove', onMove, {
|
||||
passive: false
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
document.addEventListener('touchmove', onMove, {
|
||||
passive: false
|
||||
});
|
||||
document.addEventListener('touchend', onEnd);
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
e.preventDefault();
|
||||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const dx = cx - startX,
|
||||
dy = cy - startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) isDragging = true;
|
||||
if (!isDragging) return;
|
||||
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - fab.offsetWidth, startLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - fab.offsetHeight, startTop + dy));
|
||||
fab.style.left = newLeft + 'px';
|
||||
fab.style.top = newTop + 'px';
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
btn.style.cursor = 'grab';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onEnd);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onEnd);
|
||||
|
||||
if (isDragging) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
left: parseInt(fab.style.left),
|
||||
top: parseInt(fab.style.top),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('mousedown', onStart);
|
||||
btn.addEventListener('touchstart', onStart, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
// 非拖拽时才打开面板
|
||||
btn.addEventListener('click', () => {
|
||||
if (isDragging) return;
|
||||
const p = Alpine.$data(document.getElementById('baccarat-panel'));
|
||||
if (!p) return;
|
||||
openPanel() {
|
||||
const panel = document.getElementById('baccarat-panel');
|
||||
if (!panel) return;
|
||||
const p = Alpine.$data(panel);
|
||||
p.show = true;
|
||||
if (p.phase === 'betting' && p.countdown > 0 && !p.countdownTimer) {
|
||||
p.startCountdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 首次尝试(元素可能已在 DOM 中)
|
||||
document.addEventListener('DOMContentLoaded', attachDrag);
|
||||
|
||||
// Alpine 使 x-cloak 元素出现后再次尝试
|
||||
document.addEventListener('alpine:initialized', () => setTimeout(attachDrag, 100));
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -634,7 +582,7 @@
|
||||
*/
|
||||
updateFab(visible) {
|
||||
const fab = document.getElementById('baccarat-fab');
|
||||
if (fab) fab._x_dataStack[0].visible = visible;
|
||||
if (fab) Alpine.$data(fab).visible = visible;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
422
resources/views/chat/partials/mystery-box.blade.php
Normal file
422
resources/views/chat/partials/mystery-box.blade.php
Normal file
@@ -0,0 +1,422 @@
|
||||
{{--
|
||||
文件功能:神秘箱子游戏前台UI组件
|
||||
|
||||
功能描述:
|
||||
- 右下角悬浮提示标(检测到可领取箱子时显示,支持拖动移位)
|
||||
- 监听聊天消息事件,识别公屏暗号提示后自动出现
|
||||
- 用户在聊天框输入暗号(由前端拦截 /mystery-box/claim 接口)
|
||||
- 或点击悬浮图标展开快速输入界面
|
||||
- 开箱结果展示 toast 通知
|
||||
--}}
|
||||
|
||||
{{-- ─── 神秘箱子悬浮提示(可拖动) ─── --}}
|
||||
<div id="mystery-box-fab" x-data="mysteryBoxFab()" x-show="visible" x-cloak
|
||||
:style="'position:fixed; left:' + posX + 'px; top:' + posY + 'px; z-index:9880; touch-action:none; user-select:none;'"
|
||||
@pointerdown.prevent="startDrag($event)" @pointermove.window="onDrag($event)" @pointerup.window="endDrag($event)"
|
||||
@pointercancel.window="endDrag($event)">
|
||||
|
||||
{{-- 悬浮圆形按钮 --}}
|
||||
<button
|
||||
style="width:56px; height:56px; border-radius:50%; border:none; position:relative;
|
||||
font-size:24px; display:flex; align-items:center; justify-content:center;
|
||||
animation:mb-pulse 1.8s ease-in-out infinite; user-select:none;"
|
||||
:style="[
|
||||
dragging ? 'cursor:grabbing;' : 'cursor:grab;',
|
||||
boxType === 'rare' ?
|
||||
'background:linear-gradient(135deg,#7c3aed,#a78bfa); box-shadow:0 4px 24px rgba(124,58,237,.6);' :
|
||||
boxType === 'trap' ?
|
||||
'background:linear-gradient(135deg,#7f1d1d,#dc2626); box-shadow:0 4px 24px rgba(220,38,38,.6);' :
|
||||
'background:linear-gradient(135deg,#065f46,#10b981); box-shadow:0 4px 24px rgba(16,185,129,.6);'
|
||||
]"
|
||||
title="神秘箱子开箱中!(可拖动)">
|
||||
<span x-text="boxEmoji">📦</span>
|
||||
{{-- 倒计时badge --}}
|
||||
<span x-show="secondsLeft !== null && secondsLeft <= 30"
|
||||
style="position:absolute; top:-6px; right:-6px; background:#ef4444; color:#fff;
|
||||
font-size:10px; font-weight:bold; border-radius:10px; padding:1px 5px; min-width:18px; text-align:center;"
|
||||
x-text="secondsLeft">
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{{-- 悬浮提示标签 --}}
|
||||
<div style="position:absolute; left:62px; top:50%; transform:translateY(-50%);
|
||||
background:rgba(0,0,0,.85); color:#fff; border-radius:8px; padding:4px 10px;
|
||||
font-size:12px; white-space:nowrap; pointer-events:none;"
|
||||
:style="boxType === 'rare' ? 'border:1px solid rgba(167,139,250,.4);' :
|
||||
boxType === 'trap' ? 'border:1px solid rgba(239,68,68,.3);' :
|
||||
'border:1px solid rgba(16,185,129,.3);'">
|
||||
<span x-text="boxTypeName">神秘箱</span>
|
||||
<span x-show="secondsLeft !== null" style="margin-left:4px; opacity:.7;" x-text="'⏰ ' + secondsLeft + 's'">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 神秘箱子快捷输入面板 ─── --}}
|
||||
<div id="mystery-box-panel" x-data="mysteryBoxPanel()" x-show="show" x-cloak>
|
||||
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
style="position:fixed; inset:0; background:rgba(0,0,0,.75); z-index:9920;
|
||||
display:flex; align-items:center; justify-content:center;">
|
||||
|
||||
<div style="width:380px; max-width:94vw; border-radius:24px; overflow:hidden;
|
||||
font-family:system-ui,sans-serif; position:relative;"
|
||||
:style="boxType === 'rare' ?
|
||||
'box-shadow:0 24px 80px rgba(124,58,237,.5); background:linear-gradient(180deg,#1e1b4b,#2e1065);' :
|
||||
boxType === 'trap' ?
|
||||
'box-shadow:0 24px 80px rgba(220,38,38,.4); background:linear-gradient(180deg,#1c0606,#2d0909);' :
|
||||
'box-shadow:0 24px 80px rgba(16,185,129,.4); background:linear-gradient(180deg,#022c22,#064e3b);'">
|
||||
|
||||
{{-- ─── 顶部 ─── --}}
|
||||
<div style="padding:20px 22px 16px;"
|
||||
:style="boxType === 'rare' ?
|
||||
'background:linear-gradient(135deg,#4c1d95,#6d28d9,#7c3aed);' :
|
||||
boxType === 'trap' ?
|
||||
'background:linear-gradient(135deg,#7f1d1d,#b91c1c,#dc2626);' :
|
||||
'background:linear-gradient(135deg,#064e3b,#059669,#10b981);'">
|
||||
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<div style="font-size:36px;" x-text="boxEmoji">📦</div>
|
||||
<div>
|
||||
<div style="color:#fff; font-weight:900; font-size:17px;" x-text="boxTypeName + ' 开箱!'"></div>
|
||||
<div style="color:rgba(255,255,255,.6); font-size:12px; margin-top:2px;">
|
||||
输入暗号即可开箱,限时
|
||||
<span x-text="secondsLeft !== null ? secondsLeft + ' 秒' : ''"
|
||||
style="color:#fbbf24; font-weight:bold;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 倒计时进度条 --}}
|
||||
<div x-show="secondsLeft !== null && totalSeconds > 0"
|
||||
style="margin-top:10px; height:3px; background:rgba(255,255,255,.15); border-radius:2px; overflow:hidden;">
|
||||
<div style="height:100%; border-radius:2px; transition:width 1s linear;"
|
||||
:style="'width:' + Math.max(0, secondsLeft / totalSeconds * 100) + '%; background:' +
|
||||
(boxType === 'rare' ? '#c4b5fd' : boxType === 'trap' ? '#fca5a5' : '#6ee7b7')">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 输入区 ─── --}}
|
||||
<div style="padding:20px;">
|
||||
{{-- 奖励提示 --}}
|
||||
<div style="text-align:center; margin-bottom:14px; padding:10px; border-radius:12px;"
|
||||
:style="boxType === 'rare' ?
|
||||
'background:rgba(124,58,237,.15); border:1px solid rgba(167,139,250,.25);' :
|
||||
boxType === 'trap' ?
|
||||
'background:rgba(220,38,38,.1); border:1px solid rgba(248,113,113,.2);' :
|
||||
'background:rgba(16,185,129,.1); border:1px solid rgba(52,211,153,.2);'">
|
||||
<div style="font-size:13px; opacity:.8;"
|
||||
x-text="
|
||||
boxType === 'rare' ? '✨ 稀有箱 · 奖励丰厚,手速要快!' :
|
||||
boxType === 'trap' ? '⚠️ 黑化箱 · 开了可能倒扣金币!谨慎开启!' :
|
||||
'🎁 发送正确暗号即可开箱领奖'
|
||||
"
|
||||
style="color:rgba(255,255,255,.75);"></div>
|
||||
</div>
|
||||
|
||||
{{-- 暗号输入框 --}}
|
||||
<div style="margin-bottom:12px;">
|
||||
<input type="text" x-model="inputCode" placeholder="请输入暗号(区分大小写)"
|
||||
@keydown.enter="doClaimFromPanel()" @input="inputCode = $event.target.value.toUpperCase()"
|
||||
style="width:100%; border-radius:10px; padding:12px 14px; font-size:15px;
|
||||
font-weight:bold; text-align:center; letter-spacing:4px; text-transform:uppercase;
|
||||
background:rgba(255,255,255,.1); border:2px solid rgba(255,255,255,.2);
|
||||
color:#fff; box-sizing:border-box; outline:none; transition:border-color .15s;"
|
||||
:style="boxType === 'rare' ? '&:focus { border-color:#a78bfa; }' : ''">
|
||||
</div>
|
||||
|
||||
{{-- 提交按钮 --}}
|
||||
<button @click="doClaimFromPanel()" :disabled="!inputCode.trim() || claiming"
|
||||
style="width:100%; border:none; border-radius:12px; padding:13px; font-size:15px;
|
||||
font-weight:900; cursor:pointer; transition:all .2s; letter-spacing:2px;"
|
||||
:style="(!inputCode.trim() || claiming) ? {
|
||||
background: 'rgba(255,255,255,.08)',
|
||||
color: 'rgba(255,255,255,.3)',
|
||||
cursor: 'not-allowed'
|
||||
} : boxType === 'rare' ? {
|
||||
background: 'linear-gradient(135deg,#7c3aed,#8b5cf6,#a78bfa)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(124,58,237,.5)'
|
||||
} : boxType === 'trap' ? {
|
||||
background: 'linear-gradient(135deg,#b91c1c,#dc2626,#ef4444)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(220,38,38,.5)'
|
||||
} : {
|
||||
background: 'linear-gradient(135deg,#059669,#10b981,#34d399)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(16,185,129,.5)'
|
||||
}">
|
||||
<span x-text="claiming ? '🎁 开箱中…' : '🎁 开箱!'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- ─── 底部关闭 ─── --}}
|
||||
<div style="padding:8px 20px 12px; display:flex; justify-content:center;">
|
||||
<button @click="show = false"
|
||||
style="padding:6px 24px; background:rgba(255,255,255,.06); border:none; border-radius:20px;
|
||||
font-size:12px; color:rgba(255,255,255,.4); cursor:pointer; transition:all .15s;"
|
||||
onmouseover="this.style.background='rgba(255,255,255,.12)'"
|
||||
onmouseout="this.style.background='rgba(255,255,255,.06)'">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes mb-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mb-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 神秘箱子悬浮按钮 Alpine 组件
|
||||
* 支持拖动 + 位置持久化 + 倒计时显示
|
||||
*/
|
||||
function mysteryBoxFab() {
|
||||
const STORAGE_KEY = 'mystery_box_fab_pos';
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
|
||||
return {
|
||||
visible: false,
|
||||
posX: saved?.x ?? 18,
|
||||
posY: saved?.y ?? 240,
|
||||
dragging: false,
|
||||
_startX: 0,
|
||||
_startY: 0,
|
||||
_origX: 0,
|
||||
_origY: 0,
|
||||
_moved: false,
|
||||
|
||||
boxType: 'normal',
|
||||
boxTypeName: '普通神秘箱',
|
||||
boxEmoji: '📦',
|
||||
secondsLeft: null,
|
||||
totalSeconds: null,
|
||||
_timer: null,
|
||||
|
||||
/**
|
||||
* 初始化:轮询接口检测箱子
|
||||
*/
|
||||
init() {
|
||||
this.checkStatus();
|
||||
// 每5秒轮询一次
|
||||
setInterval(() => this.checkStatus(), 5000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查当前是否有可领取的箱子
|
||||
*/
|
||||
async checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/mystery-box/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.active) {
|
||||
this.visible = true;
|
||||
this.boxType = data.box_type;
|
||||
this.boxTypeName = data.type_name;
|
||||
this.boxEmoji = data.type_emoji;
|
||||
this.secondsLeft = data.seconds_left;
|
||||
if (this.totalSeconds === null || data.seconds_left > this.totalSeconds) {
|
||||
this.totalSeconds = data.seconds_left;
|
||||
}
|
||||
this.startCountdown();
|
||||
|
||||
// ── 全局标志:供聊天框暗号拦截使用 ──
|
||||
window._mysteryBoxActive = true;
|
||||
window._mysteryBoxPasscode = data.passcode ?? null;
|
||||
|
||||
// 同步面板数据
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) {
|
||||
const pd = Alpine.$data(panel);
|
||||
pd.boxType = data.box_type;
|
||||
pd.boxTypeName = data.type_name;
|
||||
pd.boxEmoji = data.type_emoji;
|
||||
pd.secondsLeft = data.seconds_left;
|
||||
pd.totalSeconds = this.totalSeconds;
|
||||
}
|
||||
} else {
|
||||
this.visible = false;
|
||||
clearInterval(this._timer);
|
||||
|
||||
// ── 清除全局标志 ──
|
||||
window._mysteryBoxActive = false;
|
||||
window._mysteryBoxPasscode = null;
|
||||
|
||||
// 关闭面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).show = false;
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动倒计时(本地)
|
||||
*/
|
||||
startCountdown() {
|
||||
clearInterval(this._timer);
|
||||
this._timer = setInterval(() => {
|
||||
if (this.secondsLeft !== null && this.secondsLeft > 0) {
|
||||
this.secondsLeft--;
|
||||
// 同步面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).secondsLeft = this.secondsLeft;
|
||||
} else {
|
||||
clearInterval(this._timer);
|
||||
this.visible = false;
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动开始
|
||||
*/
|
||||
startDrag(e) {
|
||||
this.dragging = true;
|
||||
this._moved = false;
|
||||
this._startX = e.clientX;
|
||||
this._startY = e.clientY;
|
||||
this._origX = this.posX;
|
||||
this._origY = this.posY;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动移动
|
||||
*/
|
||||
onDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
const dx = e.clientX - this._startX;
|
||||
const dy = e.clientY - this._startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
|
||||
this.posX = Math.max(4, Math.min(window.innerWidth - 80, this._origX + dx));
|
||||
this.posY = Math.max(4, Math.min(window.innerHeight - 70, this._origY + dy));
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动结束,非拖动时打开面板
|
||||
*/
|
||||
endDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
x: this.posX,
|
||||
y: this.posY
|
||||
}));
|
||||
if (!this._moved) {
|
||||
// 打开快捷输入面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).show = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 神秘箱子快捷输入面板 Alpine 组件
|
||||
*/
|
||||
function mysteryBoxPanel() {
|
||||
return {
|
||||
show: false,
|
||||
boxType: 'normal',
|
||||
boxTypeName: '神秘箱',
|
||||
boxEmoji: '📦',
|
||||
secondsLeft: null,
|
||||
totalSeconds: null,
|
||||
inputCode: '',
|
||||
claiming: false,
|
||||
|
||||
/**
|
||||
* 从面板提交开箱暗号
|
||||
*/
|
||||
async doClaimFromPanel() {
|
||||
if (!this.inputCode.trim() || this.claiming) return;
|
||||
this.claiming = true;
|
||||
|
||||
const result = await window.mysteryBoxClaim(this.inputCode.trim());
|
||||
|
||||
this.claiming = false;
|
||||
|
||||
if (result.ok) {
|
||||
this.show = false;
|
||||
this.inputCode = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局开箱函数(可被聊天框暗号处理逻辑调用)
|
||||
*
|
||||
* @param {string} passcode 用户输入的暗号
|
||||
* @returns {Promise<{ok: boolean, message?: string, reward?: number}>}
|
||||
*/
|
||||
window.mysteryBoxClaim = async function(passcode) {
|
||||
try {
|
||||
const res = await fetch('/mystery-box/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
passcode
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
// 成功动画提示
|
||||
const isPositive = data.reward > 0;
|
||||
window.chatDialog?.alert(
|
||||
data.message || '开箱成功!',
|
||||
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
|
||||
isPositive ? '#10b981' : '#ef4444',
|
||||
);
|
||||
// 更新全局金币余额
|
||||
if (window.__chatUser) window.__chatUser.jjb = data.balance;
|
||||
} else {
|
||||
window.chatDialog?.alert(data.message || '开箱失败', '提示', '#f59e0b');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
|
||||
return {
|
||||
ok: false
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1262,6 +1262,59 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 神秘箱子暗号拦截 ────────────────────────────────────
|
||||
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
|
||||
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
|
||||
const passcodePattern = /^[A-Z0-9]{4,8}$/;
|
||||
if (passcodePattern.test(content.trim())) {
|
||||
_isSending = false;
|
||||
|
||||
try {
|
||||
const claimRes = await fetch('/mystery-box/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
passcode: content.trim()
|
||||
}),
|
||||
});
|
||||
const claimData = await claimRes.json();
|
||||
|
||||
if (claimData.ok) {
|
||||
// ✅ 领取成功:清空输入框,不发送普通消息
|
||||
contentInput.value = '';
|
||||
contentInput.focus();
|
||||
|
||||
// 清除活跃箱子全局标志
|
||||
window._mysteryBoxActive = false;
|
||||
window._mysteryBoxPasscode = null;
|
||||
|
||||
// 弹出开箱结果卡片
|
||||
const isPositive = (claimData.reward ?? 1) >= 0;
|
||||
window.chatDialog?.alert(
|
||||
claimData.message || '开箱成功!',
|
||||
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
|
||||
isPositive ? '#10b981' : '#ef4444',
|
||||
);
|
||||
|
||||
// 更新全局金币余额显示
|
||||
if (window.__chatUser && claimData.balance !== undefined) {
|
||||
window.__chatUser.jjb = claimData.balance;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ 领取失败(暗号错误 / 无活跃箱子 / 已被领走)
|
||||
// 静默回退到正常发送——不弹错误提示,让消息正常发出
|
||||
} catch (_) {
|
||||
// 网络错误时同样静默回退正常发送
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<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="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
|
||||
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
|
||||
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像</div>
|
||||
<div class="tool-btn" onclick="document.getElementById('settings-modal').style.display='flex'" title="个人设置">设置
|
||||
@@ -1051,3 +1051,280 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{-- ═══════════ 婚姻状态弹窗 ═══════════ --}}
|
||||
<div id="marriage-status-modal"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||
z-index:9999; justify-content:center; align-items:center;">
|
||||
<div
|
||||
style="background:#fff; border-radius:10px; width:360px; max-width:94vw;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden;
|
||||
animation:gdSlideIn .18s ease; display:flex; flex-direction:column;">
|
||||
|
||||
{{-- 标题栏 --}}
|
||||
<div
|
||||
style="background:linear-gradient(135deg,#be185d,#f43f5e,#ec4899);
|
||||
color:#fff; padding:12px 16px;
|
||||
display:flex; align-items:center; justify-content:space-between;">
|
||||
<span style="font-size:14px; font-weight:bold;">💍 我的婚姻</span>
|
||||
<span onclick="closeMarriageStatusModal()"
|
||||
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1;">✕</span>
|
||||
</div>
|
||||
|
||||
{{-- 内容区(动态渲染) --}}
|
||||
<div id="marriage-status-body" style="padding:16px; min-height:120px;">
|
||||
<div style="text-align:center; color:#aaa; padding:30px 0; font-size:12px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 底部操作区 --}}
|
||||
<div id="marriage-status-footer" style="padding:0 16px 16px; display:flex; gap:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 婚姻状态弹窗——工具栏点击「婚姻」按钮触发。
|
||||
* 调用 /marriage/status 接口,展示当前用户婚姻状态(单身/求婚中/已婚)。
|
||||
*/
|
||||
(function() {
|
||||
|
||||
const CSRF = () => document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
/** 打开弹窗并拉取状态 */
|
||||
window.openMarriageStatusModal = function() {
|
||||
$('marriage-status-modal').style.display = 'flex';
|
||||
$('marriage-status-body').innerHTML =
|
||||
'<div style="text-align:center;color:#aaa;padding:30px 0;font-size:12px;">加载中…</div>';
|
||||
$('marriage-status-footer').innerHTML = '';
|
||||
|
||||
fetch('/marriage/status', {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(renderMarriageStatus)
|
||||
.catch(() => {
|
||||
$('marriage-status-body').innerHTML =
|
||||
'<div style="text-align:center;color:#e55;padding:30px 0;font-size:12px;">❌ 加载失败,请稍后重试</div>';
|
||||
});
|
||||
};
|
||||
|
||||
/** 关闭弹窗 */
|
||||
window.closeMarriageStatusModal = function() {
|
||||
$('marriage-status-modal').style.display = 'none';
|
||||
};
|
||||
|
||||
// 点击遮罩关闭
|
||||
$('marriage-status-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeMarriageStatusModal();
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据接口返回数据渲染弹窗内容。
|
||||
*
|
||||
* @param {object} data `/marriage/status` 响应 JSON
|
||||
*/
|
||||
function renderMarriageStatus(data) {
|
||||
const body = $('marriage-status-body');
|
||||
const footer = $('marriage-status-footer');
|
||||
|
||||
// ── 单身 ────────────────────────────────────
|
||||
if (!data.status || data.status === 'none' || !data.marriage) {
|
||||
body.innerHTML = `
|
||||
<div style="text-align:center; padding:16px 0;">
|
||||
<div style="font-size:40px; margin-bottom:10px;">🕊️</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#555;">目前单身</div>
|
||||
<div style="font-size:11px; color:#999; margin-top:6px; line-height:1.7;">
|
||||
还没有婚姻记录。<br>可在用户名片上点击「求婚」发起求婚。
|
||||
</div>
|
||||
</div>`;
|
||||
footer.innerHTML = `
|
||||
<button onclick="closeMarriageStatusModal()"
|
||||
style="flex:1; padding:9px; background:#f3f4f6; color:#555;
|
||||
border:1px solid #d1d5db; border-radius:6px; font-size:13px; cursor:pointer;">
|
||||
关闭
|
||||
</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const m = data.marriage;
|
||||
const isMine = (m.user && m.user.username === window.__chatUser?.username) ||
|
||||
window.__chatUser?.id === m.user?.id ||
|
||||
window.__chatUser?.id === m.partner?.id;
|
||||
|
||||
// 确定"另一方"信息(我可能是 user 也可能是 partner)
|
||||
const me = window.__chatUser;
|
||||
const other = (m.user?.id === me?.id) ? m.partner : m.user;
|
||||
const iAmUser = (m.user?.id === me?.id);
|
||||
|
||||
// ── 求婚中 ──────────────────────────────────
|
||||
if (data.status === 'pending') {
|
||||
const iProposed = iAmUser; // user_id 是发起方
|
||||
const expireAt = m.expires_at ? new Date(m.expires_at).toLocaleString('zh-CN', {
|
||||
hour12: false
|
||||
}) : '—';
|
||||
const ringHtml = m.ring ?
|
||||
`<span style="font-size:13px;">${m.ring.icon ?? '💍'} ${m.ring.name}</span>` : '';
|
||||
|
||||
body.innerHTML = `
|
||||
<div style="text-align:center; padding:8px 0;">
|
||||
<div style="font-size:36px; margin-bottom:8px;">💌</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#be185d;">
|
||||
${iProposed ? '你向 ' + (other?.username ?? '—') + ' 发出了求婚' : (other?.username ?? '—') + ' 向你求婚啦!'}
|
||||
</div>
|
||||
${ringHtml ? `<div style="margin:8px 0; font-size:12px; color:#666;">戒指:${ringHtml}</div>` : ''}
|
||||
<div style="font-size:11px; color:#999; margin-top:6px;">
|
||||
过期时间:${expireAt}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (!iProposed) {
|
||||
// 被求婚方:可以接受 / 拒绝
|
||||
footer.innerHTML = `
|
||||
<button onclick="marriageAction('${m.id}','reject'); closeMarriageStatusModal();"
|
||||
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
|
||||
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
|
||||
😢 婉拒
|
||||
</button>
|
||||
<button onclick="marriageAction('${m.id}','accept'); closeMarriageStatusModal();"
|
||||
style="flex:1;padding:9px;background:linear-gradient(135deg,#be185d,#f43f5e);
|
||||
color:#fff;border:none;border-radius:6px;font-size:13px;
|
||||
font-weight:bold;cursor:pointer;">
|
||||
💑 答应啦!
|
||||
</button>`;
|
||||
} else {
|
||||
footer.innerHTML = `
|
||||
<button onclick="closeMarriageStatusModal()"
|
||||
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
|
||||
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
|
||||
关闭(等待对方回应)
|
||||
</button>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 已婚 ────────────────────────────────────
|
||||
if (data.status === 'married') {
|
||||
const levelIcon = m.level_icon ?? '💑';
|
||||
const levelName = m.level_name ?? '新婚';
|
||||
const days = m.days ?? 0;
|
||||
const intimacy = m.intimacy ?? 0;
|
||||
const marriedAt = m.married_at ?? '—';
|
||||
const ringHtml = m.ring ? `${m.ring.icon ?? '💍'} ${m.ring.name}` : '无';
|
||||
|
||||
body.innerHTML = `
|
||||
<div style="text-align:center; margin-bottom:12px;">
|
||||
<div style="font-size:36px; margin-bottom:6px;">${levelIcon}</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#be185d;">
|
||||
已与 <strong>${other?.username ?? '—'}</strong> 成婚 🎉
|
||||
</div>
|
||||
<div style="font-size:12px; color:#999; margin-top:4px;">婚姻等级:${levelName}</div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:12px;">
|
||||
<div style="background:#fdf2f8;border:1px solid #fbcfe8;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="color:#be185d;font-weight:bold;font-size:18px;">${days}</div>
|
||||
<div style="color:#888;margin-top:2px;">携手天数</div>
|
||||
</div>
|
||||
<div style="background:#fdf4ff;border:1px solid #e9d5ff;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="color:#7c3aed;font-weight:bold;font-size:18px;">${Number(intimacy).toLocaleString()}</div>
|
||||
<div style="color:#888;margin-top:2px;">亲密度</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;padding:8px 10px;grid-column:1/-1;">
|
||||
<span style="color:#666;">💍 戒指:</span><span style="color:#333;">${ringHtml}</span>
|
||||
|
|
||||
<span style="color:#666;">📅 婚期:</span><span style="color:#333;">${marriedAt}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 已婚底部:离婚入口(需要二次确认)
|
||||
footer.innerHTML = `
|
||||
<button onclick="closeMarriageStatusModal()"
|
||||
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
|
||||
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
|
||||
关闭
|
||||
</button>
|
||||
<button onclick="tryDivorce('${m.id}')"
|
||||
style="flex:.8;padding:9px;border:1px solid #fca5a5;background:#fff;
|
||||
color:#dc2626;border-radius:6px;font-size:12px;cursor:pointer;">
|
||||
💔 申请离婚
|
||||
</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他状态(divorced 等)
|
||||
body.innerHTML =
|
||||
`<div style="text-align:center;color:#999;padding:30px 0;font-size:12px;">暂无有效婚姻记录</div>`;
|
||||
footer.innerHTML = `
|
||||
<button onclick="closeMarriageStatusModal()"
|
||||
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
|
||||
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">关闭</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用婚姻操作(接受 / 拒绝求婚)
|
||||
*
|
||||
* @param {string|number} marriageId marriage 记录 ID
|
||||
* @param {string} action 'accept' | 'reject'
|
||||
*/
|
||||
window.marriageAction = async function(marriageId, action) {
|
||||
try {
|
||||
const res = await fetch(`/marriage/${marriageId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': CSRF(),
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
window.chatDialog?.alert(data.message || (action === 'accept' ? '已接受求婚!' : '已婉拒求婚'),
|
||||
action === 'accept' ? '💑 恭喜!' : '提示', action === 'accept' ? '#be185d' :
|
||||
'#6b7280');
|
||||
} else {
|
||||
window.chatDialog?.alert(data.message || '操作失败', '提示', '#f59e0b');
|
||||
}
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 申请离婚(先弹确认框,再调接口)
|
||||
*
|
||||
* @param {string|number} marriageId marriage 记录 ID
|
||||
*/
|
||||
window.tryDivorce = async function(marriageId) {
|
||||
closeMarriageStatusModal();
|
||||
const confirmed = await window.chatDialog?.confirm(
|
||||
'申请协议离婚后,对方有权同意或拒绝(拒绝即转为强制离婚,双方均扣除魅力值)。\n\n确定要申请吗?',
|
||||
'💔 申请离婚',
|
||||
'#dc2626',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/marriage/${marriageId}/divorce`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': CSRF(),
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'mutual'
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
window.chatDialog?.alert(data.message || '申请已发送', '提示', data.ok ? '#10b981' : '#f59e0b');
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -61,3 +61,45 @@ Schedule::call(function () {
|
||||
\App\Jobs\OpenBaccaratRoundJob::dispatch();
|
||||
}
|
||||
})->everyMinute()->name('baccarat:open-round')->withoutOverlapping();
|
||||
|
||||
// ──────────── 神秘箱子定时投放 ─────────────────────────────────
|
||||
|
||||
// 每分钟:检查是否应自动投放一个新箱子
|
||||
Schedule::call(function () {
|
||||
if (! \App\Models\GameConfig::isEnabled('mystery_box')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = \App\Models\GameConfig::forGame('mystery_box')?->params ?? [];
|
||||
|
||||
// 自动投放开关
|
||||
if (! ($config['auto_drop_enabled'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当前已有可领取的箱子时跳过(一次只投放一个)
|
||||
if (\App\Models\MysteryBox::currentOpenBox()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$intervalHours = (float) ($config['auto_interval_hours'] ?? 2);
|
||||
|
||||
// 检查距上次投放时间
|
||||
$lastBox = \App\Models\MysteryBox::latest()->first();
|
||||
if ($lastBox && $lastBox->created_at->diffInHours(now()) < $intervalHours) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按配置的陷阱概率决定箱子类型
|
||||
$trapChance = (int) ($config['trap_chance_percent'] ?? 10);
|
||||
$rand = random_int(1, 100);
|
||||
|
||||
$boxType = match (true) {
|
||||
$rand <= $trapChance => 'trap',
|
||||
$rand <= $trapChance + 15 => 'rare',
|
||||
default => 'normal',
|
||||
};
|
||||
|
||||
\App\Jobs\DropMysteryBoxJob::dispatch($boxType);
|
||||
})->everyMinute()->name('mystery-box:auto-drop')->withoutOverlapping();
|
||||
|
||||
|
||||
@@ -132,6 +132,14 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::get('/history', [\App\Http\Controllers\SlotMachineController::class, 'history'])->name('history');
|
||||
});
|
||||
|
||||
// ── 神秘箱子(前台)──────────────────────────────────────────────
|
||||
Route::prefix('mystery-box')->name('mystery-box.')->group(function () {
|
||||
// 查询当前可领取的箱子(前端轮询/显示悬浮提示用)
|
||||
Route::get('/status', [\App\Http\Controllers\MysteryBoxController::class, 'status'])->name('status');
|
||||
// 用户发送暗号领取箱子
|
||||
Route::post('/claim', [\App\Http\Controllers\MysteryBoxController::class, 'claim'])->name('claim');
|
||||
});
|
||||
|
||||
// ---- 第五阶段:具体房间内部聊天核心 ----
|
||||
// 进入具体房间界面的初始化
|
||||
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
|
||||
@@ -350,6 +358,9 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
||||
Route::post('/{gameConfig}/params', [\App\Http\Controllers\Admin\GameConfigController::class, 'updateParams'])->name('params');
|
||||
});
|
||||
|
||||
// 📦 神秘箱子:管理员手动投放
|
||||
Route::post('/mystery-box/drop', [\App\Http\Controllers\Admin\GameConfigController::class, 'dropMysteryBox'])->name('mystery-box.drop');
|
||||
|
||||
// 🎣 钓鱼事件管理
|
||||
Route::prefix('fishing')->name('fishing.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\FishingEventController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user