feat(chat): 完善五子棋功能,包含AI对战、PvP邀请、断线重连及界面美化

This commit is contained in:
2026-03-12 08:35:21 +08:00
parent b9c703b755
commit 1c42f05e20
17 changed files with 2740 additions and 6 deletions
+12
View File
@@ -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 => '五子棋入场费返还',
};
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* 文件功能:五子棋对局结束广播事件
*
* 对局结束(胜负/平局/认输/超时)时广播两个频道:
* 1. 私有对局频道:通知双方结算并关闭棋盘
* 2. 房间公共频道:广播战报消息
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuFinishedEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 当前对局
* @param string $winnerName 胜者用户名(平局时为空字符串)
* @param string $loserName 败者用户名(平局时为空字符串)
* @param string $reason 结束原因:win | draw | resign | timeout
*/
public function __construct(
public readonly GomokuGame $game,
public readonly string $winnerName,
public readonly string $loserName,
public readonly string $reason,
) {}
/**
* 同时广播至对局私有频道 + 房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
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<string, mixed>
*/
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,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:五子棋对战邀请广播事件
*
* 玩家发起对战邀请时广播至房间 Presence 频道,
* 前端在聊天消息流中渲染「接受挑战」按钮。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuInviteEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 对局记录
* @param string $inviterName 发起者用户名
*/
public function __construct(
public readonly GomokuGame $game,
public readonly string $inviterName,
) {}
/**
* 广播至对应房间频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel("room.{$this->game->room_id}")];
}
/**
* 广播事件名(前端监听 .gomoku.invite)。
*/
public function broadcastAs(): string
{
return 'gomoku.invite';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'game_id' => $this->game->id,
'inviter_name' => $this->inviterName,
'expires_at' => $this->game->invite_expires_at?->toIso8601String(),
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:五子棋落子广播事件
*
* 每次玩家(或 AI)落子后通过私有对局频道广播,
* 双方前端实时更新棋盘显示并切换行棋方。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuMovedEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 当前对局
* @param int $row 落子行(0-14
* @param int $col 落子列(0-14
* @param int $color 落子颜色(1= 2=白)
*/
public function __construct(
public readonly GomokuGame $game,
public readonly int $row,
public readonly int $col,
public readonly int $color,
) {}
/**
* 广播至对局私有频道(仅双方可见)。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel("gomoku.{$this->game->id}")];
}
/**
* 广播事件名(前端监听 .gomoku.moved)。
*/
public function broadcastAs(): string
{
return 'gomoku.moved';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
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,
];
}
}
+557
View File
@@ -0,0 +1,557 @@
<?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,
]);
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:五子棋对局模型
*
* 管理 PvP(玩家对战)和 PvE(人机对战)两种模式的对局记录。
* 提供棋盘操作、胜负判断及状态管理方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GomokuGame extends Model
{
protected $fillable = [
'mode',
'room_id',
'player_black_id',
'player_white_id',
'ai_level',
'status',
'board',
'current_turn',
'winner',
'moves_history',
'reward_gold',
'entry_fee',
'invite_expires_at',
'started_at',
'finished_at',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'board' => '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;
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
/**
* 文件功能:五子棋 AI 对战算法服务
*
* 实现四个难度的 AI 决策逻辑:
* - 简单:随机有效落点 + 单步威胁阻挡
* - 普通:Minimax 深度 3
* - 困难:Minimax + Alpha-Beta 剪枝 深度 5
* - 专家:Minimax + Alpha-Beta 剪枝 深度 7 + 进攻优先调整
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
class GomokuAiService
{
/** @var int 棋盘尺寸 */
private const BOARD_SIZE = 15;
/** @var int 黑棋(玩家先手) */
private const BLACK = 1;
/** @var int 白棋/AI */
private const WHITE = 2;
/** @var int 无穷大分数 */
private const INF = 999999;
/**
* 根据当前棋盘和 AI 难度,返回最优落点坐标。
*
* @param array $board 当前棋盘状态(15×15
* @param int $aiLevel AI 难度:1=简单 2=普通 3=困难 4=专家
* @return array{row: int, col: int} 最优落点
*/
public function think(array $board, int $aiLevel): array
{
return match ($aiLevel) {
1 => $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<array{row: int, col: int}>
*/
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;
}
}