Files
chatroom/app/Http/Controllers/GomokuController.php

558 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:五子棋对战前台控制器
*
* 提供 PvP随机对战和 PvE人机对战两种模式的完整 API
* - 创建对局(支持两种模式)
* - 加入 PvP 对战
* - 落子(自动触发 AI 回应)
* - 认输
* - 取消邀请
* - 获取对局状态
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\GomokuFinishedEvent;
use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
) {}
/**
* 创建对局。
*
* 支持两种模式:
* - pvp: 广播邀请通知,等待其他玩家加入
* - pve: 立即开局与 AI 对战(需支付入场费)
*/
public function create(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('gomoku')) {
return response()->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')]);
}
// PvEAI 落子
$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);
}
// PvEAI 胜利无奖励
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,
]);
}
}