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, ]); } }