diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index eb6c629..da6cdcf 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -117,6 +117,15 @@ enum CurrencySource: string /** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */ case LOTTERY_WIN = 'lottery_win'; + /** 五子棋 PvP 对战入场费(PvE 欻入场费) */ + case GOMOKU_ENTRY_FEE = 'gomoku_entry_fee'; + + /** 五子棋对战胜利奖励(PvP/PvE 获胜时收入) */ + case GOMOKU_WIN = 'gomoku_win'; + + /** 五子棋 PvE 入场费返还(平局时返还) */ + case GOMOKU_REFUND = 'gomoku_refund'; + /** * 返回该来源的中文名称,用于后台统计展示。 */ @@ -155,6 +164,9 @@ enum CurrencySource: string self::FORTUNE_COST => '神秘占卜消耗', self::LOTTERY_BUY => '双色球购票', self::LOTTERY_WIN => '双色球中奖', + self::GOMOKU_ENTRY_FEE => '五子棋入场费', + self::GOMOKU_WIN => '五子棋获胜奖励', + self::GOMOKU_REFUND => '五子棋入场费返还', }; } } diff --git a/app/Events/GomokuFinishedEvent.php b/app/Events/GomokuFinishedEvent.php new file mode 100644 index 0000000..1455311 --- /dev/null +++ b/app/Events/GomokuFinishedEvent.php @@ -0,0 +1,80 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel("gomoku.{$this->game->id}"), + new PresenceChannel("room.{$this->game->room_id}"), + ]; + } + + /** + * 广播事件名(前端监听 .gomoku.finished)。 + */ + public function broadcastAs(): string + { + return 'gomoku.finished'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'game_id' => $this->game->id, + 'winner' => $this->game->winner, + 'winner_name' => $this->winnerName, + 'loser_name' => $this->loserName, + 'reason' => $this->reason, + 'reward_gold' => $this->game->reward_gold, + 'mode' => $this->game->mode, + ]; + } +} diff --git a/app/Events/GomokuInviteEvent.php b/app/Events/GomokuInviteEvent.php new file mode 100644 index 0000000..7a959ab --- /dev/null +++ b/app/Events/GomokuInviteEvent.php @@ -0,0 +1,67 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel("room.{$this->game->room_id}")]; + } + + /** + * 广播事件名(前端监听 .gomoku.invite)。 + */ + public function broadcastAs(): string + { + return 'gomoku.invite'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'game_id' => $this->game->id, + 'inviter_name' => $this->inviterName, + 'expires_at' => $this->game->invite_expires_at?->toIso8601String(), + ]; + } +} diff --git a/app/Events/GomokuMovedEvent.php b/app/Events/GomokuMovedEvent.php new file mode 100644 index 0000000..3bdb157 --- /dev/null +++ b/app/Events/GomokuMovedEvent.php @@ -0,0 +1,74 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel("gomoku.{$this->game->id}")]; + } + + /** + * 广播事件名(前端监听 .gomoku.moved)。 + */ + public function broadcastAs(): string + { + return 'gomoku.moved'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'game_id' => $this->game->id, + 'row' => $this->row, + 'col' => $this->col, + 'color' => $this->color, + 'current_turn' => $this->game->current_turn, + 'board' => $this->game->board, + ]; + } +} diff --git a/app/Http/Controllers/GomokuController.php b/app/Http/Controllers/GomokuController.php new file mode 100644 index 0000000..499f7f6 --- /dev/null +++ b/app/Http/Controllers/GomokuController.php @@ -0,0 +1,557 @@ +json(['ok' => false, 'message' => '五子棋当前未开启。']); + } + + $data = $request->validate([ + 'mode' => 'required|in:pvp,pve', + 'room_id' => 'required|integer|exists:rooms,id', + 'ai_level' => 'required_if:mode,pve|nullable|integer|min:1|max:4', + ]); + + $user = $request->user(); + + // PvP:检查是否已在等待/对局中(一次只能参与一场) + $activeGame = GomokuGame::query() + ->where(function ($q) use ($user) { + $q->where('player_black_id', $user->id) + ->orWhere('player_white_id', $user->id); + }) + ->whereIn('status', ['waiting', 'playing']) + ->first(); + + if ($activeGame) { + return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局,请先完成或取消。']); + } + + // PvE:扣除入场费 + $entryFee = 0; + if ($data['mode'] === 'pve') { + $entryFee = $this->getPveEntryFee((int) $data['ai_level']); + if ($entryFee > 0 && ($user->jjb ?? 0) < $entryFee) { + return response()->json(['ok' => false, 'message' => "金币不足,此难度需 {$entryFee} 金币入场费。"]); + } + } + + return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse { + // PvE 扣除入场费 + if ($entryFee > 0) { + $this->currency->change( + $user, + 'gold', + -$entryFee, + CurrencySource::GOMOKU_ENTRY_FEE, + "五子棋 AI 对战入场费(难度{$data['ai_level']})", + ); + } + + $timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60); + + $game = GomokuGame::create([ + 'mode' => $data['mode'], + 'room_id' => $data['room_id'], + 'player_black_id' => $user->id, + 'ai_level' => $data['mode'] === 'pve' ? ($data['ai_level'] ?? 1) : null, + 'status' => $data['mode'] === 'pve' ? 'playing' : 'waiting', + 'board' => GomokuGame::emptyBoard(), + 'current_turn' => 1, + 'entry_fee' => $entryFee, + 'invite_expires_at' => $data['mode'] === 'pvp' ? now()->addSeconds($timeout) : null, + 'started_at' => $data['mode'] === 'pve' ? now() : null, + ]); + + // PvP:广播邀请通知至房间 + if ($data['mode'] === 'pvp') { + broadcast(new GomokuInviteEvent($game, $user->username)); + } + + return response()->json([ + 'ok' => true, + 'game_id' => $game->id, + 'message' => $data['mode'] === 'pvp' + ? '已发起对战邀请,等待其他玩家加入…' + : '对局已开始,您执黑棋先手!', + ]); + }); + } + + /** + * 加入 PvP 对战(白棋方)。 + */ + public function join(Request $request, GomokuGame $game): JsonResponse + { + $user = $request->user(); + + if ($game->status !== 'waiting') { + return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']); + } + + if ($game->player_black_id === $user->id) { + return response()->json(['ok' => false, 'message' => '不能加入自己发起的对局。']); + } + + if ($game->invite_expires_at && now()->isAfter($game->invite_expires_at)) { + $game->update(['status' => 'cancelled']); + + return response()->json(['ok' => false, 'message' => '该邀请已超时,请重新发起。']); + } + + // 检查接受方是否已在其他对局中 + $activeGame = GomokuGame::query() + ->where(function ($q) use ($user) { + $q->where('player_black_id', $user->id) + ->orWhere('player_white_id', $user->id); + }) + ->whereIn('status', ['waiting', 'playing']) + ->first(); + + if ($activeGame) { + return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局。']); + } + + $game->update([ + 'player_white_id' => $user->id, + 'status' => 'playing', + 'started_at' => now(), + ]); + + return response()->json([ + 'ok' => true, + 'game_id' => $game->id, + 'message' => '已成功加入对战!您执白棋。', + ]); + } + + /** + * 落子。 + * + * PvP 模式:验证轮次后广播落子。 + * PvE 模式:玩家落子后,自动计算 AI 落点并一并返回。 + */ + public function move(Request $request, GomokuGame $game): JsonResponse + { + $user = $request->user(); + + if ($game->status !== 'playing') { + return response()->json(['ok' => false, 'message' => '对局未在进行中。']); + } + + $data = $request->validate([ + 'row' => 'required|integer|min:0|max:14', + 'col' => 'required|integer|min:0|max:14', + ]); + + $row = (int) $data['row']; + $col = (int) $data['col']; + $board = $game->board; + + // 坐标已被占用 + if (GomokuGame::isOccupied($board, $row, $col)) { + return response()->json(['ok' => false, 'message' => '该位置已有棋子。']); + } + + // PvP:验证是否轮到该玩家 + if ($game->mode === 'pvp') { + if (! $game->belongsToUser($user->id)) { + return response()->json(['ok' => false, 'message' => '您不在该对局中。']); + } + if (! $game->isUserTurn($user->id)) { + return response()->json(['ok' => false, 'message' => '当前不是您的回合。']); + } + } else { + // PvE:只允许黑棋玩家操作 + if ($game->player_black_id !== $user->id) { + return response()->json(['ok' => false, 'message' => '您不在该对局中。']); + } + if ($game->current_turn !== 1) { + return response()->json(['ok' => false, 'message' => 'AI 正在思考,请等待。']); + } + } + + return DB::transaction(function () use ($game, $row, $col, $board, $user): JsonResponse { + // 玩家落子 + $playerColor = $game->mode === 'pvp' ? $game->colorOf($user->id) : 1; + $board = GomokuGame::placeStone($board, $row, $col, $playerColor); + + // 记录落子历史 + $history = $game->moves_history ?? []; + $history[] = ['row' => $row, 'col' => $col, 'color' => $playerColor, 'at' => now()->toIso8601String()]; + + // 判断玩家是否胜利 + if (GomokuGame::checkWin($board, $row, $col, $playerColor)) { + return $this->finishGame($game, $board, $history, $playerColor, 'win', $user); + } + + // 判断平局 + if (GomokuGame::isBoardFull($board)) { + return $this->finishGame($game, $board, $history, 0, 'draw', $user); + } + + // 切换回合 + $nextTurn = $playerColor === 1 ? 2 : 1; + $game->update([ + 'board' => $board, + 'current_turn' => $nextTurn, + 'moves_history' => $history, + ]); + + // PvP:广播落子事件 + if ($game->mode === 'pvp') { + broadcast(new GomokuMovedEvent($game->fresh(), $row, $col, $playerColor)); + + return response()->json(['ok' => true, 'moved' => compact('row', 'col')]); + } + + // PvE:AI 落子 + $aiMove = $this->ai->think($board, $game->ai_level ?? 1); + $aiRow = $aiMove['row']; + $aiCol = $aiMove['col']; + $aiColor = 2; + $board = GomokuGame::placeStone($board, $aiRow, $aiCol, $aiColor); + + $history[] = ['row' => $aiRow, 'col' => $aiCol, 'color' => $aiColor, 'at' => now()->toIso8601String()]; + + // 判断 AI 是否胜利 + if (GomokuGame::checkWin($board, $aiRow, $aiCol, $aiColor)) { + return $this->finishGame($game, $board, $history, $aiColor, 'win', $user); + } + + // 再次检查平局(AI 落子后) + if (GomokuGame::isBoardFull($board)) { + return $this->finishGame($game, $board, $history, 0, 'draw', $user); + } + + // AI 落子后切换回玩家回合 + $game->update([ + 'board' => $board, + 'current_turn' => 1, + 'moves_history' => $history, + ]); + + return response()->json([ + 'ok' => true, + 'moved' => ['row' => $row, 'col' => $col], + 'ai_moved' => ['row' => $aiRow, 'col' => $aiCol], + ]); + }); + } + + /** + * 认输(当前玩家主动认输,对手获胜)。 + */ + public function resign(Request $request, GomokuGame $game): JsonResponse + { + $user = $request->user(); + + if (! in_array($game->status, ['playing', 'waiting'])) { + return response()->json(['ok' => false, 'message' => '对局已结束。']); + } + + if (! $game->belongsToUser($user->id)) { + return response()->json(['ok' => false, 'message' => '您不在该对局中。']); + } + + // 认输者对应颜色,胜方为另一色 + $resignColor = $game->colorOf($user->id); + $winnerColor = $resignColor === 1 ? 2 : 1; + + return DB::transaction(function () use ($game, $winnerColor, $user): JsonResponse { + return $this->finishGame($game, $game->board, $game->moves_history ?? [], $winnerColor, 'resign', $user); + }); + } + + /** + * 取消 PvP 邀请(发起者主动取消,或超时后被调用)。 + */ + public function cancel(Request $request, GomokuGame $game): JsonResponse + { + $user = $request->user(); + + if ($game->status !== 'waiting') { + return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']); + } + + if ($game->player_black_id !== $user->id) { + return response()->json(['ok' => false, 'message' => '只有发起者可取消邀请。']); + } + + $game->update(['status' => 'cancelled']); + + return response()->json(['ok' => true, 'message' => '邀请已取消。']); + } + + /** + * 获取对局当前状态(用于前端重连同步)。 + */ + public function state(Request $request, GomokuGame $game): JsonResponse + { + $user = $request->user(); + + if (! $game->belongsToUser($user->id) && $game->mode === 'pvp') { + return response()->json(['ok' => false, 'message' => '无权访问该对局。']); + } + + return response()->json([ + 'ok' => true, + 'game_id' => $game->id, + 'mode' => $game->mode, + 'status' => $game->status, + 'board' => $game->board, + 'current_turn' => $game->current_turn, + 'winner' => $game->winner, + 'your_color' => $game->colorOf($user->id), + 'ai_level' => $game->ai_level, + 'reward_gold' => $game->reward_gold, + ]); + } + + // ─── 私有工具方法 ───────────────────────────────────────────────── + + /** + * 结算对局:更新状态、发放奖励、广播事件。 + * + * @param GomokuGame $game 当前对局 + * @param array $board 最终棋盘 + * @param array $history 落子历史 + * @param int $winnerColor 胜方颜色(0=平局) + * @param string $reason 结束原因(win/draw/resign) + * @param \App\Models\User $currentUser 当前操作用户(用于加载用户名) + */ + private function finishGame( + GomokuGame $game, + array $board, + array $history, + int $winnerColor, + string $reason, + mixed $currentUser + ): JsonResponse { + $rewardGold = 0; + $winnerName = ''; + $loserName = ''; + + // 加载对局玩家信息 + $game->load('playerBlack', 'playerWhite'); + + if ($winnerColor === 0) { + // 平局 + $winnerName = ''; + $loserName = ''; + + // PvE 平局:返还入场费 + if ($game->mode === 'pve' && $game->entry_fee > 0) { + $this->currency->change( + $game->playerBlack, + 'gold', + $game->entry_fee, + CurrencySource::GOMOKU_REFUND, + '五子棋 AI 平局返还入场费', + ); + } + } else { + // 有胜负 + $rewardGold = $this->calculateReward($game, $winnerColor); + + if ($game->mode === 'pvp') { + $winnerUser = $winnerColor === 1 ? $game->playerBlack : $game->playerWhite; + $loserUser = $winnerColor === 1 ? $game->playerWhite : $game->playerBlack; + $winnerName = $winnerUser?->username ?? ''; + $loserName = $loserUser?->username ?? ''; + + // 发放 PvP 胜利奖励给获胜玩家 + if ($winnerUser && $rewardGold > 0) { + $this->currency->change( + $winnerUser, + 'gold', + $rewardGold, + CurrencySource::GOMOKU_WIN, + "五子棋对战击败 {$loserName}", + ); + } + } else { + // PvE 模式:winnerColor=1 代表玩家胜 + if ($winnerColor === 1) { + $winnerName = $game->playerBlack->username ?? ''; + $loserName = "AI(难度{$game->ai_level})"; + + if ($rewardGold > 0) { + $this->currency->change( + $game->playerBlack, + 'gold', + $rewardGold, + CurrencySource::GOMOKU_WIN, + "五子棋击败 AI(难度{$game->ai_level})", + ); + } + } else { + // AI 获胜:入场费已扣,无返还 + $winnerName = "AI(难度{$game->ai_level})"; + $loserName = $game->playerBlack->username ?? ''; + } + } + } + + $game->update([ + 'status' => 'finished', + 'board' => $board, + 'moves_history' => $history, + 'winner' => $winnerColor, + 'reward_gold' => $rewardGold, + 'finished_at' => now(), + ]); + + // 广播对局结束事件 + broadcast(new GomokuFinishedEvent($game->fresh(), $winnerName, $loserName, $reason)); + + return response()->json([ + 'ok' => true, + 'finished' => true, + 'winner' => $winnerColor, + 'winner_name' => $winnerName, + 'reason' => $reason, + 'reward_gold' => $rewardGold, + ]); + } + + /** + * 根据对局模式和获胜方计算奖励金币。 + * + * @param GomokuGame $game 对局 + * @param int $winnerColor 胜方颜色 + */ + private function calculateReward(GomokuGame $game, int $winnerColor): int + { + if ($game->mode === 'pvp') { + // PvP 胜利奖励从游戏配置读取 + return (int) GameConfig::param('gomoku', 'pvp_reward', 80); + } + + // PvE:AI 胜利无奖励 + if ($winnerColor !== 1) { + return 0; + } + + // 按难度从游戏配置读取胜利奖励 + $key = match ((int) $game->ai_level) { + 1 => 'pve_easy_reward', + 2 => 'pve_normal_reward', + 3 => 'pve_hard_reward', + default => 'pve_expert_reward', + }; + + $defaults = ['pve_easy_reward' => 20, 'pve_normal_reward' => 50, 'pve_hard_reward' => 120, 'pve_expert_reward' => 300]; + + return (int) GameConfig::param('gomoku', $key, $defaults[$key]); + } + + /** + * 根据 AI 难度获取 PvE 入场费。 + * + * @param int $aiLevel AI 难度(1-4) + */ + private function getPveEntryFee(int $aiLevel): int + { + // 从游戏配置读取各难度入场费,支持后台实时调整 + $key = match ($aiLevel) { + 1 => 'pve_easy_fee', + 2 => 'pve_normal_fee', + 3 => 'pve_hard_fee', + default => 'pve_expert_fee', + }; + + $defaults = ['pve_easy_fee' => 0, 'pve_normal_fee' => 10, 'pve_hard_fee' => 30, 'pve_expert_fee' => 80]; + + return (int) GameConfig::param('gomoku', $key, $defaults[$key]); + } + + /** + * 查询当前用户是否有进行中的对局(重进页面时用于恢复)。 + * + * 返回对局基础信息,包含模式、棋盘状态与双方用户名, + * 让前端弹出「继续 / 认输」选择。 + */ + public function active(Request $request): JsonResponse + { + $user = $request->user(); + + $game = GomokuGame::query() + ->where(function ($q) use ($user) { + $q->where('player_black_id', $user->id) + ->orWhere('player_white_id', $user->id); + }) + ->whereIn('status', ['waiting', 'playing']) + ->with('playerBlack', 'playerWhite') + ->latest() + ->first(); + + if (! $game) { + return response()->json(['ok' => true, 'has_active' => false]); + } + + // 对阵双方用户名 + $blackName = $game->playerBlack->username ?? '黑棋'; + $whiteName = $game->mode === 'pve' + ? ('AI(难度'.$game->ai_level.')') + : ($game->playerWhite?->username ?? '等待中…'); + + return response()->json([ + 'ok' => true, + 'has_active' => true, + 'game_id' => $game->id, + 'mode' => $game->mode, + 'status' => $game->status, + 'ai_level' => $game->ai_level, + 'your_color' => $game->colorOf($user->id), + 'board' => $game->board, + 'current_turn' => $game->current_turn, + 'black_name' => $blackName, + 'white_name' => $whiteName, + ]); + } +} diff --git a/app/Models/GomokuGame.php b/app/Models/GomokuGame.php new file mode 100644 index 0000000..8700cad --- /dev/null +++ b/app/Models/GomokuGame.php @@ -0,0 +1,224 @@ + 'array', + 'moves_history' => 'array', + 'invite_expires_at' => 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + // ─── 关联 ───────────────────────────────────────────────────────── + + /** + * 黑棋玩家(发起方)。 + */ + public function playerBlack(): BelongsTo + { + return $this->belongsTo(User::class, 'player_black_id'); + } + + /** + * 白棋玩家(PvP 接受方)。 + */ + public function playerWhite(): BelongsTo + { + return $this->belongsTo(User::class, 'player_white_id'); + } + + /** + * 所属聊天室。 + */ + public function room(): BelongsTo + { + return $this->belongsTo(Room::class); + } + + // ─── 棋盘操作 ───────────────────────────────────────────────────── + + /** + * 生成空白 15×15 棋盘(全 0)。 + */ + public static function emptyBoard(): array + { + return array_fill(0, 15, array_fill(0, 15, 0)); + } + + /** + * 在指定坐标落子,返回新棋盘状态。 + * + * @param array $board 当前棋盘 + * @param int $row 行(0-14) + * @param int $col 列(0-14) + * @param int $color 棋子颜色(1=黑 2=白) + */ + public static function placeStone(array $board, int $row, int $col, int $color): array + { + $board[$row][$col] = $color; + + return $board; + } + + /** + * 检查指定坐标是否已有棋子。 + * + * @param array $board 棋盘 + * @param int $row 行 + * @param int $col 列 + */ + public static function isOccupied(array $board, int $row, int $col): bool + { + return ($board[$row][$col] ?? 0) !== 0; + } + + /** + * 判断在指定坐标落子后,该颜色是否已连成五子。 + * + * @param array $board 落子后的棋盘 + * @param int $row 最后落子行 + * @param int $col 最后落子列 + * @param int $color 棋子颜色(1=黑 2=白) + */ + public static function checkWin(array $board, int $row, int $col, int $color): bool + { + // 四个方向:横、竖、左斜、右斜 + $directions = [[0, 1], [1, 0], [1, 1], [1, -1]]; + + foreach ($directions as [$dr, $dc]) { + $count = 1; + + // 向正方向延伸 + for ($i = 1; $i <= 4; $i++) { + $r = $row + $dr * $i; + $c = $col + $dc * $i; + if ($r < 0 || $r >= 15 || $c < 0 || $c >= 15) { + break; + } + if (($board[$r][$c] ?? 0) !== $color) { + break; + } + $count++; + } + + // 向反方向延伸 + for ($i = 1; $i <= 4; $i++) { + $r = $row - $dr * $i; + $c = $col - $dc * $i; + if ($r < 0 || $r >= 15 || $c < 0 || $c >= 15) { + break; + } + if (($board[$r][$c] ?? 0) !== $color) { + break; + } + $count++; + } + + if ($count >= 5) { + return true; + } + } + + return false; + } + + /** + * 判断棋盘是否已下满(平局)。 + * + * @param array $board 棋盘 + */ + public static function isBoardFull(array $board): bool + { + foreach ($board as $row) { + foreach ($row as $cell) { + if ($cell === 0) { + return false; + } + } + } + + return true; + } + + // ─── 状态辅助 ───────────────────────────────────────────────────── + + /** + * 判断对局是否属于某个用户。 + * + * @param int $userId 用户 ID + */ + public function belongsToUser(int $userId): bool + { + return $this->player_black_id === $userId + || $this->player_white_id === $userId; + } + + /** + * 获取指定用户在此对局的棋子颜色。 + * 返回 1(黑棋)或 2(白棋),不在局中返回 null。 + * + * @param int $userId 用户 ID + */ + public function colorOf(int $userId): ?int + { + if ($this->player_black_id === $userId) { + return 1; + } + if ($this->player_white_id === $userId) { + return 2; + } + + return null; + } + + /** + * 判断当前是否轮到指定用户行棋(PvP 用)。 + * + * @param int $userId 用户 ID + */ + public function isUserTurn(int $userId): bool + { + return $this->colorOf($userId) === $this->current_turn; + } +} diff --git a/app/Services/GomokuAiService.php b/app/Services/GomokuAiService.php new file mode 100644 index 0000000..8ea7e82 --- /dev/null +++ b/app/Services/GomokuAiService.php @@ -0,0 +1,447 @@ + $this->thinkSimple($board), + 2 => $this->thinkMinimax($board, 2), // 普通:深度2 + 3 => $this->thinkMinimax($board, 3), // 困难:深度3 + default => $this->thinkMinimax($board, 4), // 专家:深度4 + }; + } + + // ─── 简单难度:随机 + 单步阻挡 ───────────────────────────────── + + /** + * 简单 AI:先检查是否有必须阻挡的威胁,否则随机落子。 + * + * @param array $board 棋盘 + * @return array{row: int, col: int} + */ + private function thinkSimple(array $board): array + { + // 先检查 AI 自己是否能一步胜利 + $win = $this->findImmediateWin($board, self::WHITE); + if ($win !== null) { + return $win; + } + + // 再检查玩家是否要连成五,必须阻挡 + $block = $this->findImmediateWin($board, self::BLACK); + if ($block !== null) { + return $block; + } + + // 随机选择有棋子附近的空位(增加合理性) + $candidates = $this->getCandidates($board, 1); + + // 随机选一个候选点 + if (! empty($candidates)) { + return $candidates[array_rand($candidates)]; + } + + // 棋盘全空时走中心 + return ['row' => 7, 'col' => 7]; + } + + /** + * 寻找能立即获胜(连成五子)的落点。 + * + * @param array $board 棋盘 + * @param int $color 检查哪方颜色 + * @return array{row: int, col: int}|null + */ + private function findImmediateWin(array $board, int $color): ?array + { + for ($r = 0; $r < self::BOARD_SIZE; $r++) { + for ($c = 0; $c < self::BOARD_SIZE; $c++) { + if ($board[$r][$c] !== 0) { + continue; + } + $board[$r][$c] = $color; + if ($this->checkWinAt($board, $r, $c, $color)) { + $board[$r][$c] = 0; + + return ['row' => $r, 'col' => $c]; + } + $board[$r][$c] = 0; + } + } + + return null; + } + + // ─── Minimax + Alpha-Beta 剪枝 ────────────────────────────────── + + /** + * 使用 Minimax 算法(含 Alpha-Beta 剪枝)找最优落点。 + * + * @param array $board 棋盘 + * @param int $depth 搜索深度 + * @return array{row: int, col: int} + */ + private function thinkMinimax(array $board, int $depth): array + { + $bestScore = -self::INF; + $bestMove = ['row' => 7, 'col' => 7]; + + // 先检查即时胜利(避免算法绕过) + $win = $this->findImmediateWin($board, self::WHITE); + if ($win !== null) { + return $win; + } + $block = $this->findImmediateWin($board, self::BLACK); + if ($block !== null) { + return $block; + } + + // 获取候选点(半径1,避免候选点过多导致超时) + $candidates = $this->getCandidates($board, 1); + + if (empty($candidates)) { + return ['row' => 7, 'col' => 7]; + } + + // 对候选点预排序(快速评分优先,提升剪枝效率) + usort($candidates, function ($a, $b) use ($board) { + return $this->evaluatePoint($board, $b['row'], $b['col'], self::WHITE) + - $this->evaluatePoint($board, $a['row'], $a['col'], self::WHITE); + }); + + // 只取前 20 个高分候选点,进一步减少搜索空间 + $candidates = array_slice($candidates, 0, 20); + + foreach ($candidates as $move) { + $board[$move['row']][$move['col']] = self::WHITE; + $score = $this->minimax($board, $depth - 1, -self::INF, self::INF, false, $move['row'], $move['col']); + $board[$move['row']][$move['col']] = 0; + + if ($score > $bestScore) { + $bestScore = $score; + $bestMove = $move; + } + } + + return $bestMove; + } + + /** + * Minimax 递归搜索。 + * + * @param array $board 棋盘 + * @param int $depth 剩余深度 + * @param int $alpha Alpha 值(剪枝用) + * @param int $beta Beta 值(剪枝用) + * @param bool $isMaximize 是否为最大化层(AI 落子) + * @param int $lastRow 上一步落子行(用于快速胜负检测) + * @param int $lastCol 上一步落子列 + */ + private function minimax( + array $board, + int $depth, + int $alpha, + int $beta, + bool $isMaximize, + int $lastRow, + int $lastCol + ): int { + $lastColor = $isMaximize ? self::BLACK : self::WHITE; + + // 终止条件:上一步是否已经胜利 + if ($this->checkWinAt($board, $lastRow, $lastCol, $lastColor)) { + return $isMaximize ? -self::INF : self::INF; + } + + // 深度耗尽:评估当前局面 + if ($depth === 0) { + return $this->evaluateBoard($board); + } + + // 递归层同样限制候选点范围(半径1,最多15个),防止指数爆炸 + $candidates = array_slice($this->getCandidates($board, 1), 0, 15); + if (empty($candidates)) { + return $this->evaluateBoard($board); + } + + if ($isMaximize) { + // AI 落子(最大化) + $best = -self::INF; + foreach ($candidates as $move) { + $board[$move['row']][$move['col']] = self::WHITE; + $score = $this->minimax($board, $depth - 1, $alpha, $beta, false, $move['row'], $move['col']); + $board[$move['row']][$move['col']] = 0; + $best = max($best, $score); + $alpha = max($alpha, $best); + if ($beta <= $alpha) { + break; // Beta 剪枝 + } + } + + return $best; + } else { + // 玩家落子(最小化) + $best = self::INF; + foreach ($candidates as $move) { + $board[$move['row']][$move['col']] = self::BLACK; + $score = $this->minimax($board, $depth - 1, $alpha, $beta, true, $move['row'], $move['col']); + $board[$move['row']][$move['col']] = 0; + $best = min($best, $score); + $beta = min($beta, $best); + if ($beta <= $alpha) { + break; // Alpha 剪枝 + } + } + + return $best; + } + } + + // ─── 棋盘评估 ──────────────────────────────────────────────────── + + /** + * 整体棋盘评估:AI 得分 - 玩家得分(正值对 AI 有利)。 + * + * @param array $board 棋盘 + */ + private function evaluateBoard(array $board): int + { + return $this->evaluateColor($board, self::WHITE) - $this->evaluateColor($board, self::BLACK); + } + + /** + * 评估指定颜色在棋盘上的总得分。 + * + * @param array $board 棋盘 + * @param int $color 棋子颜色 + */ + private function evaluateColor(array $board, int $color): int + { + $score = 0; + $opponent = $color === self::WHITE ? self::BLACK : self::WHITE; + + // 四个方向 + $directions = [[0, 1], [1, 0], [1, 1], [1, -1]]; + + for ($r = 0; $r < self::BOARD_SIZE; $r++) { + for ($c = 0; $c < self::BOARD_SIZE; $c++) { + foreach ($directions as [$dr, $dc]) { + $score += $this->evaluateLine($board, $r, $c, $dr, $dc, $color, $opponent); + } + } + } + + return $score; + } + + /** + * 评估从 (r, c) 出发沿 (dr, dc) 方向的连子得分。 + * + * @param array $board 棋盘 + * @param int $r 起始行 + * @param int $c 起始列 + * @param int $dr 行方向步长 + * @param int $dc 列方向步长 + * @param int $color 我方颜色 + * @param int $opponent 对方颜色 + */ + private function evaluateLine( + array $board, + int $r, int $c, + int $dr, int $dc, + int $color, + int $opponent + ): int { + // 统计连续同色棋子数 + $count = 0; + $open = 0; // 两端开口数 + + for ($i = 0; $i < 5; $i++) { + $nr = $r + $dr * $i; + $nc = $c + $dc * $i; + if ($nr < 0 || $nr >= self::BOARD_SIZE || $nc < 0 || $nc >= self::BOARD_SIZE) { + return 0; // 越界,无效 + } + $cell = $board[$nr][$nc]; + if ($cell === $opponent) { + return 0; // 被对方截断,无价值 + } + if ($cell === $color) { + $count++; + } + } + + // 检测前端开口 + $prevR = $r - $dr; + $prevC = $c - $dc; + if ($prevR >= 0 && $prevR < self::BOARD_SIZE && $prevC >= 0 && $prevC < self::BOARD_SIZE) { + if ($board[$prevR][$prevC] === 0) { + $open++; + } + } + + // 检测后端开口 + $nextR = $r + $dr * 5; + $nextC = $c + $dc * 5; + if ($nextR >= 0 && $nextR < self::BOARD_SIZE && $nextC >= 0 && $nextC < self::BOARD_SIZE) { + if ($board[$nextR][$nextC] === 0) { + $open++; + } + } + + // 根据连子数和开口数评分 + return match ($count) { + 5 => 10000, // 五连:胜利 + 4 => $open >= 1 ? 5000 : 500, // 四连活四/眠四 + 3 => $open === 2 ? 500 : 50, // 活三/眠三 + 2 => $open === 2 ? 50 : 10, // 活二/眠二 + default => 0, + }; + } + + /** + * 评估在指定点落子后的局部得分(用于候选点预排序)。 + * + * @param array $board 棋盘 + * @param int $row 行 + * @param int $col 列 + * @param int $color 落子颜色 + */ + private function evaluatePoint(array $board, int $row, int $col, int $color): int + { + $board[$row][$col] = $color; + $score = $this->evaluateColor($board, $color); + + return $score; + } + + // ─── 辅助工具 ───────────────────────────────────────────────────── + + /** + * 获取棋盘上已有棋子周边 $range 格内的所有空位(候选落点)。 + * + * @param array $board 棋盘 + * @param int $range 搜索半径(格数) + * @return array + */ + private function getCandidates(array $board, int $range = 1): array + { + $candidates = []; + $visited = []; + $hasStone = false; + + for ($r = 0; $r < self::BOARD_SIZE; $r++) { + for ($c = 0; $c < self::BOARD_SIZE; $c++) { + if ($board[$r][$c] === 0) { + continue; + } + $hasStone = true; + // 在该棋子周边 $range 格内寻找空位 + for ($dr = -$range; $dr <= $range; $dr++) { + for ($dc = -$range; $dc <= $range; $dc++) { + $nr = $r + $dr; + $nc = $c + $dc; + if ($nr < 0 || $nr >= self::BOARD_SIZE || $nc < 0 || $nc >= self::BOARD_SIZE) { + continue; + } + $key = "{$nr},{$nc}"; + if (! isset($visited[$key]) && $board[$nr][$nc] === 0) { + $candidates[] = ['row' => $nr, 'col' => $nc]; + $visited[$key] = true; + } + } + } + } + } + + // 棋盘全空时返回中心点 + if (! $hasStone) { + return [['row' => 7, 'col' => 7]]; + } + + return $candidates; + } + + /** + * 检查指定位置落子后是否连成五子。 + * + * @param array $board 棋盘(已包含该子) + * @param int $row 行 + * @param int $col 列 + * @param int $color 棋子颜色 + */ + private function checkWinAt(array $board, int $row, int $col, int $color): bool + { + $directions = [[0, 1], [1, 0], [1, 1], [1, -1]]; + + foreach ($directions as [$dr, $dc]) { + $count = 1; + + for ($i = 1; $i <= 4; $i++) { + $r = $row + $dr * $i; + $c = $col + $dc * $i; + if ($r < 0 || $r >= self::BOARD_SIZE || $c < 0 || $c >= self::BOARD_SIZE) { + break; + } + if (($board[$r][$c] ?? 0) !== $color) { + break; + } + $count++; + } + + for ($i = 1; $i <= 4; $i++) { + $r = $row - $dr * $i; + $c = $col - $dc * $i; + if ($r < 0 || $r >= self::BOARD_SIZE || $c < 0 || $c >= self::BOARD_SIZE) { + break; + } + if (($board[$r][$c] ?? 0) !== $color) { + break; + } + $count++; + } + + if ($count >= 5) { + return true; + } + } + + return false; + } +} diff --git a/database/migrations/2026_03_12_074719_create_gomoku_games_table.php b/database/migrations/2026_03_12_074719_create_gomoku_games_table.php new file mode 100644 index 0000000..c3e8c37 --- /dev/null +++ b/database/migrations/2026_03_12_074719_create_gomoku_games_table.php @@ -0,0 +1,89 @@ +id(); + + // 对战模式:pvp(玩家对玩家)| pve(人机对战) + $table->string('mode', 10)->index(); + + // 所在房间 ID(用于广播邀请和战报) + $table->unsignedBigInteger('room_id')->index(); + + // 黑棋玩家(先手,也是发起方) + $table->unsignedBigInteger('player_black_id'); + + // 白棋玩家(PvP 时为接受方,PvE 时为 null) + $table->unsignedBigInteger('player_white_id')->nullable(); + + // AI 难度:1=简单 2=普通 3=困难 4=专家(PvE 时使用) + $table->tinyInteger('ai_level')->nullable(); + + // 对局状态:waiting | playing | finished | cancelled + $table->string('status', 20)->default('waiting')->index(); + + // 15×15 棋盘状态(二维数组:0=空 1=黑 2=白) + $table->json('board'); + + // 当前行棋方:1=黑棋 2=白棋/AI + $table->tinyInteger('current_turn')->default(1); + + // 胜者:1=黑棋胜 2=白棋/AI胜 0=平局 null=未结束 + $table->tinyInteger('winner')->nullable(); + + // 落子历史(用于战绩回放)[{row, col, color, at}] + $table->json('moves_history')->nullable(); + + // 奖励金币(对局结束时写入,记录实际到账金额) + $table->unsignedInteger('reward_gold')->default(0); + + // PvE 入场费(对局开始时扣除,平局/认输时结算) + $table->unsignedInteger('entry_fee')->default(0); + + // 邀请过期时间(waiting 状态下,超时自动 cancelled) + $table->timestamp('invite_expires_at')->nullable(); + + // 对局开始时间 + $table->timestamp('started_at')->nullable(); + + // 对局结束时间 + $table->timestamp('finished_at')->nullable(); + + $table->timestamps(); + + // 外键约束 + $table->foreign('room_id')->references('id')->on('rooms')->onDelete('cascade'); + $table->foreign('player_black_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('player_white_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * 回滚。 + */ + public function down(): void + { + Schema::dropIfExists('gomoku_games'); + } +}; diff --git a/database/seeders/GomokuConfigSeeder.php b/database/seeders/GomokuConfigSeeder.php new file mode 100644 index 0000000..266001b --- /dev/null +++ b/database/seeders/GomokuConfigSeeder.php @@ -0,0 +1,41 @@ + 'gomoku', 'key' => 'pvp_reward', 'value' => '80'], + ['type' => 'gomoku', 'key' => 'pvp_invite_timeout', 'value' => '60'], + ['type' => 'gomoku', 'key' => 'pvp_move_timeout', 'value' => '60'], + ['type' => 'gomoku', 'key' => 'pvp_ready_timeout', 'value' => '30'], + + // PvE AI 难度入口费 + ['type' => 'gomoku', 'key' => 'pve_fee_level_1', 'value' => '0'], + ['type' => 'gomoku', 'key' => 'pve_fee_level_2', 'value' => '10'], + ['type' => 'gomoku', 'key' => 'pve_fee_level_3', 'value' => '30'], + ['type' => 'gomoku', 'key' => 'pve_fee_level_4', 'value' => '80'], + + // PvE AI 难度胜利奖励 + ['type' => 'gomoku', 'key' => 'pve_reward_level_1', 'value' => '20'], + ['type' => 'gomoku', 'key' => 'pve_reward_level_2', 'value' => '50'], + ['type' => 'gomoku', 'key' => 'pve_reward_level_3', 'value' => '120'], + ['type' => 'gomoku', 'key' => 'pve_reward_level_4', 'value' => '300'], + ]; + + foreach ($configs as $config) { + \App\Models\GameConfig::updateOrCreate( + ['type' => $config['type'], 'key' => $config['key']], + ['value' => $config['value']] + ); + } + } +} diff --git a/id b/id new file mode 100644 index 0000000..d917b2c --- /dev/null +++ b/id @@ -0,0 +1 @@ +已存在:.$existing- diff --git a/resources/views/admin/game-configs/index.blade.php b/resources/views/admin/game-configs/index.blade.php index db28d28..2fb1fbc 100644 --- a/resources/views/admin/game-configs/index.blade.php +++ b/resources/views/admin/game-configs/index.blade.php @@ -497,11 +497,11 @@
${card.items.map(item => ` -
- ${item.label} - ${item.value} -
- `).join('')} +
+ ${item.label} + ${item.value} +
+ `).join('')}
`).join(''); @@ -677,6 +677,25 @@ 'min' => 0, ], ], + 'gomoku' => [ + // ── PvP 随机对战 ── + 'pvp_reward' => ['label' => 'PvP 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0], + // ── 人机对战:简单 ── + 'pve_easy_fee' => ['label' => 'AI简单 入场费', 'type' => 'number', 'unit' => '金币', 'min' => 0], + 'pve_easy_reward' => ['label' => 'AI简单 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0], + // ── 人机对战:普通 ── + 'pve_normal_fee' => ['label' => 'AI普通 入场费', 'type' => 'number', 'unit' => '金币', 'min' => 0], + 'pve_normal_reward' => ['label' => 'AI普通 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0], + // ── 人机对战:困难 ── + 'pve_hard_fee' => ['label' => 'AI困难 入场费', 'type' => 'number', 'unit' => '金币', 'min' => 0], + 'pve_hard_reward' => ['label' => 'AI困难 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0], + // ── 人机对战:专家 ── + 'pve_expert_fee' => ['label' => 'AI专家 入场费', 'type' => 'number', 'unit' => '金币', 'min' => 0], + 'pve_expert_reward' => ['label' => 'AI专家 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0], + // ── 超时配置 ── + 'invite_timeout' => ['label' => 'PvP邀请超时', 'type' => 'number', 'unit' => '秒', 'min' => 10], + 'move_timeout' => ['label' => '每步落子超时', 'type' => 'number', 'unit' => '秒', 'min' => 10], + ], default => [], }; } diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index f17e15b..7182d89 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -154,6 +154,8 @@ @include('chat.partials.games.lottery-panel') @include('chat.partials.games.red-packet-panel') @include('chat.partials.games.fishing-panel') + @include('chat.partials.games.game-hall') + @include('chat.partials.games.gomoku-panel') {{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}} diff --git a/resources/views/chat/partials/games/game-hall.blade.php b/resources/views/chat/partials/games/game-hall.blade.php index d8ee107..583eb05 100644 --- a/resources/views/chat/partials/games/game-hall.blade.php +++ b/resources/views/chat/partials/games/game-hall.blade.php @@ -23,6 +23,7 @@ 'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'), 'fishing' => \App\Models\GameConfig::isEnabled('fishing'), 'lottery' => \App\Models\GameConfig::isEnabled('lottery'), + 'gomoku' => \App\Models\GameConfig::isEnabled('gomoku'), ]; @endphp diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index cb0dc63..f20efe9 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -735,6 +735,112 @@ } document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener); + // ── 五子棋 PvP 邀请通知(聊天室内显示「接受挑战」按钮)─────── + /** + * 监听 .gomoku.invite 事件,在聊天窗口追加邀请消息行。 + * 发起者收到的邀请(自己发出的)不显示接受按钮。 + */ + function setupGomokuInviteListener() { + if (!window.Echo || !window.chatContext) { + setTimeout(setupGomokuInviteListener, 500); + return; + } + window.Echo.join(`room.${window.chatContext.roomId}`) + .listen('.gomoku.invite', (e) => { + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0') + ':' + + now.getSeconds().toString().padStart(2, '0'); + + const isSelf = (e.inviter_name === window.chatContext.username); + const div = document.createElement('div'); + div.className = 'msg-line'; + div.style.cssText = + 'background:linear-gradient(135deg,#e8eef8,#f0f4fc); ' + + 'border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;'; + + const acceptBtn = isSelf ? + // 自己的邀请:只显示打开面板按钮,方便被关掉后重新进入 + `` : + // 别人的邀请:显示接受挑战按钮 + ``; + + div.innerHTML = ` + ♟️ 【五子棋】${e.inviter_name} 发起了随机对战!${isSelf ? '(等待中)' : ''} + ${acceptBtn} + (${timeStr})`; + + // 追加到公聊窗口 + const say1 = document.getElementById('chat-messages-container'); + if (say1) { + say1.appendChild(div); + say1.scrollTop = say1.scrollHeight; + } + + // 60 秒后移除接受按钮(邀请超时) + if (!isSelf) { + setTimeout(() => { + const btn = document.getElementById(`gomoku-accept-${e.game_id}`); + if (btn) { + btn.textContent = '已超时'; + btn.disabled = true; + btn.style.opacity = '.5'; + btn.style.cursor = 'not-allowed'; + } + }, 60000); + } + }) + .listen('.gomoku.finished', (e) => { + // 对局结束:在公聊展示战报(仅 PvP 有战报意义) + if (e.mode !== 'pvp') return; + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0') + ':' + + now.getSeconds().toString().padStart(2, '0'); + + const div = document.createElement('div'); + div.className = 'msg-line'; + div.style.cssText = + 'background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;'; + + const reason = { + win: '获胜', + draw: '平局', + resign: '认输', + timeout: '超时' + } [e.reason] || '结束'; + let text = ''; + if (e.winner === 0) { + text = `♟️ 五子棋对局以平局结束!`; + } else { + text = + `♟️ ${e.winner_name} 击败 ${e.loser_name}(${reason})获得 ${e.reward_gold} 金币!`; + } + + div.innerHTML = + `${text}(${timeStr})`; + const say1 = document.getElementById('chat-messages-container'); + if (say1) { + say1.appendChild(div); + say1.scrollTop = say1.scrollHeight; + } + }); + console.log('[五子棋] 邀请监听器已注册'); + } + document.addEventListener('DOMContentLoaded', setupGomokuInviteListener); + // ── 全屏特效事件监听(烟花/下雨/雷电/下雪)───────── window.addEventListener('chat:effect', (e) => { const type = e.detail?.type; diff --git a/routes/channels.php b/routes/channels.php index 4b154f9..61dbe3b 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -32,3 +32,13 @@ Broadcast::channel('room.{roomId}', function ($user, $roomId) { Broadcast::channel('user.{id}', function ($user, $id) { return (int) $user->id === (int) $id; }); + +// 五子棋对局私有频道(仅对局双方可订阅,用于实时同步落子) +Broadcast::channel('gomoku.{gameId}', function ($user, $gameId) { + $game = \App\Models\GomokuGame::find($gameId); + if (! $game) { + return false; + } + + return $game->belongsToUser($user->id); +}); diff --git a/routes/web.php b/routes/web.php index eb2cce5..7d2a4ab 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,7 +30,6 @@ Route::post('/login', [AuthController::class, 'login'])->name('login.post'); // 处理退出登录 Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); - // 聊天室系统内部路由 (需要鉴权) Route::middleware(['chat.auth'])->group(function () { // ---- 第六阶段:大厅与房间管理 ---- @@ -177,6 +176,40 @@ Route::middleware(['chat.auth'])->group(function () { Route::get('/my', [\App\Http\Controllers\LotteryController::class, 'my'])->name('my'); }); + // ── 五子棋(前台)─────────────────────────────────────────────── + Route::prefix('gomoku')->name('gomoku.')->group(function () { + // 获取五子棋配置(入场费、奖励,对外暴露给前端面板) + Route::get('/config', function () { + $c = \App\Models\GameConfig::query()->where('game_key', 'gomoku')->first(); + $p = $c?->params ?? []; + + return response()->json([ + 'ok' => true, + 'pvp_reward' => (int) ($p['pvp_reward'] ?? 80), + 'pve_levels' => [ + ['level' => 1, 'name' => '简单', 'fee' => (int) ($p['pve_easy_fee'] ?? 0), 'reward' => (int) ($p['pve_easy_reward'] ?? 20)], + ['level' => 2, 'name' => '普通', 'fee' => (int) ($p['pve_normal_fee'] ?? 10), 'reward' => (int) ($p['pve_normal_reward'] ?? 50)], + ['level' => 3, 'name' => '困难', 'fee' => (int) ($p['pve_hard_fee'] ?? 30), 'reward' => (int) ($p['pve_hard_reward'] ?? 120)], + ['level' => 4, 'name' => '专家', 'fee' => (int) ($p['pve_expert_fee'] ?? 80), 'reward' => (int) ($p['pve_expert_reward'] ?? 300)], + ], + ]); + })->name('config'); + // 查询当前用户是否有进行中的对局(用于重进时恢复) + Route::get('/active', [\App\Http\Controllers\GomokuController::class, 'active'])->name('active'); + // 创建对局(pvp=随机邀请 | pve=人机对战) + Route::post('/create', [\App\Http\Controllers\GomokuController::class, 'create'])->name('create'); + // 加入 PvP 对战 + Route::post('/{game}/join', [\App\Http\Controllers\GomokuController::class, 'join'])->name('join'); + // 落子 + Route::post('/{game}/move', [\App\Http\Controllers\GomokuController::class, 'move'])->name('move'); + // 认输 + Route::post('/{game}/resign', [\App\Http\Controllers\GomokuController::class, 'resign'])->name('resign'); + // 取消等待中的邀请 + Route::post('/{game}/cancel', [\App\Http\Controllers\GomokuController::class, 'cancel'])->name('cancel'); + // 获取当前棋盘状态 + Route::get('/{game}/state', [\App\Http\Controllers\GomokuController::class, 'state'])->name('state'); + }); + // ── 游戏大厅:实时开关状态接口 ──────────────────────────────────── Route::get('/games/enabled', function () { return response()->json([ @@ -187,6 +220,7 @@ Route::middleware(['chat.auth'])->group(function () { 'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'), 'fishing' => \App\Models\GameConfig::isEnabled('fishing'), 'lottery' => \App\Models\GameConfig::isEnabled('lottery'), + 'gomoku' => \App\Models\GameConfig::isEnabled('gomoku'), ]); })->name('games.enabled');