diff --git a/.agents/rules/chat.md b/.agents/rules/chat.md index ce96df9..d4ec8a5 100644 --- a/.agents/rules/chat.md +++ b/.agents/rules/chat.md @@ -144,3 +144,7 @@ class ChatStateService } } ``` + +### 2.5 迁移文件注意事项 + +同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错; diff --git a/GAMES_TODO.md b/GAMES_TODO.md index bdc5415..a2639b5 100644 --- a/GAMES_TODO.md +++ b/GAMES_TODO.md @@ -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`(毛玻璃弹窗) diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index a570fd2..9433487 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -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 => '神秘箱子陷阱', }; } } diff --git a/app/Http/Controllers/Admin/GameConfigController.php b/app/Http/Controllers/Admin/GameConfigController.php index 0f35ed5..70813d8 100644 --- a/app/Http/Controllers/Admin/GameConfigController.php +++ b/app/Http/Controllers/Admin/GameConfigController.php @@ -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 房间,暗号将实时发送到公屏!", + ]); + } } diff --git a/app/Http/Controllers/MysteryBoxController.php b/app/Http/Controllers/MysteryBoxController.php new file mode 100644 index 0000000..36e7399 --- /dev/null +++ b/app/Http/Controllers/MysteryBoxController.php @@ -0,0 +1,174 @@ +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); + } +} diff --git a/app/Jobs/DropMysteryBoxJob.php b/app/Jobs/DropMysteryBoxJob.php new file mode 100644 index 0000000..41ae363 --- /dev/null +++ b/app/Jobs/DropMysteryBoxJob.php @@ -0,0 +1,122 @@ +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)); + } +} diff --git a/app/Jobs/ExpireMysteryBoxJob.php b/app/Jobs/ExpireMysteryBoxJob.php new file mode 100644 index 0000000..a8d440a --- /dev/null +++ b/app/Jobs/ExpireMysteryBoxJob.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/app/Models/MysteryBox.php b/app/Models/MysteryBox.php new file mode 100644 index 0000000..5dc49dc --- /dev/null +++ b/app/Models/MysteryBox.php @@ -0,0 +1,124 @@ + '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(); + } +} diff --git a/app/Models/MysteryBoxClaim.php b/app/Models/MysteryBoxClaim.php new file mode 100644 index 0000000..8d1b04b --- /dev/null +++ b/app/Models/MysteryBoxClaim.php @@ -0,0 +1,53 @@ + 'integer', + ]; + } + + // ─── 关联关系 ──────────────────────────────────────────────────── + + /** + * 关联神秘箱子。 + */ + public function mysteryBox(): BelongsTo + { + return $this->belongsTo(MysteryBox::class); + } + + /** + * 关联领取用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/migrations/2026_03_03_175451_create_mystery_boxes_table.php b/database/migrations/2026_03_03_175451_create_mystery_boxes_table.php new file mode 100644 index 0000000..4fa2843 --- /dev/null +++ b/database/migrations/2026_03_03_175451_create_mystery_boxes_table.php @@ -0,0 +1,59 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_03_175452_create_mystery_box_claims_table.php b/database/migrations/2026_03_03_175452_create_mystery_box_claims_table.php new file mode 100644 index 0000000..0e5eeda --- /dev/null +++ b/database/migrations/2026_03_03_175452_create_mystery_box_claims_table.php @@ -0,0 +1,51 @@ +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'); + } +}; diff --git a/database/seeders/GameConfigSeeder.php b/database/seeders/GameConfigSeeder.php index e913e57..c278abc 100644 --- a/database/seeders/GameConfigSeeder.php +++ b/database/seeders/GameConfigSeeder.php @@ -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, // 黑化箱触发概率(%) ], ], diff --git a/resources/views/admin/game-configs/index.blade.php b/resources/views/admin/game-configs/index.blade.php index 0ceb3df..e81bd30 100644 --- a/resources/views/admin/game-configs/index.blade.php +++ b/resources/views/admin/game-configs/index.blade.php @@ -100,6 +100,31 @@ 修改后立即生效(缓存60秒刷新) + + {{-- 神秘箱子:手动投放区域 --}} + @if ($game->game_key === 'mystery_box') +