2026-03-12 08:35:21 +08:00
|
|
|
|
<?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')]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 ?? '';
|
|
|
|
|
|
|
2026-03-12 15:59:24 +08:00
|
|
|
|
// 将英文 reason 转为友好的中文后缀
|
|
|
|
|
|
$reasonText = match ($reason) {
|
|
|
|
|
|
'resign' => '(认输)',
|
|
|
|
|
|
'timeout' => '(超时)',
|
|
|
|
|
|
default => '',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 08:35:21 +08:00
|
|
|
|
// 发放 PvP 胜利奖励给获胜玩家
|
|
|
|
|
|
if ($winnerUser && $rewardGold > 0) {
|
|
|
|
|
|
$this->currency->change(
|
|
|
|
|
|
$winnerUser,
|
|
|
|
|
|
'gold',
|
|
|
|
|
|
$rewardGold,
|
|
|
|
|
|
CurrencySource::GOMOKU_WIN,
|
2026-03-12 15:59:24 +08:00
|
|
|
|
"五子棋:击败 {$loserName}{$reasonText}",
|
2026-03-12 08:35:21 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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,
|
2026-03-12 15:59:24 +08:00
|
|
|
|
"五子棋:击败 AI(难度{$game->ai_level})",
|
2026-03-12 08:35:21 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-03-12 15:59:24 +08:00
|
|
|
|
// 广播对局结束事件给参与对局的双方
|
2026-03-12 08:35:21 +08:00
|
|
|
|
broadcast(new GomokuFinishedEvent($game->fresh(), $winnerName, $loserName, $reason));
|
|
|
|
|
|
|
2026-03-12 16:44:16 +08:00
|
|
|
|
// 有胜负,均向房间广播系统通知
|
|
|
|
|
|
if ($winnerColor !== 0) {
|
|
|
|
|
|
if ($game->mode === 'pvp') {
|
|
|
|
|
|
// PvP:胜方玩家获奖通知
|
|
|
|
|
|
$reasonText = match ($reason) {
|
|
|
|
|
|
'resign' => '(认输)',
|
|
|
|
|
|
default => '',
|
|
|
|
|
|
};
|
2026-03-12 17:02:19 +08:00
|
|
|
|
$text = "♟️ 【五子棋】玩家对战结果!恭喜玩家【{$winnerName}】击败了【{$loserName}】{$reasonText},赢得 {$rewardGold} 金币!";
|
2026-03-12 16:44:16 +08:00
|
|
|
|
} elseif ($winnerColor === 1) {
|
|
|
|
|
|
// PvE:玩家获胜
|
2026-03-12 17:02:19 +08:00
|
|
|
|
$text = "♟️ 【五子棋】棋神降临!恭喜玩家【{$winnerName}】在人机对战(难度{$game->ai_level})中击败 AI,赢得 {$rewardGold} 金币!";
|
2026-03-12 16:44:16 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// PvE:AI 获胜(玩家输了)
|
2026-03-12 17:02:19 +08:00
|
|
|
|
$text = "♟️ 【五子棋】AI 大获全胜!玩家【{$loserName}】在人机对战(难度{$game->ai_level})中不敌 AI,再接再厉!";
|
2026-03-12 16:44:16 +08:00
|
|
|
|
}
|
2026-03-12 15:59:24 +08:00
|
|
|
|
$this->broadcastSystemMessage($game->room_id, $text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:35:21 +08:00
|
|
|
|
return response()->json([
|
|
|
|
|
|
'ok' => true,
|
|
|
|
|
|
'finished' => true,
|
|
|
|
|
|
'winner' => $winnerColor,
|
|
|
|
|
|
'winner_name' => $winnerName,
|
|
|
|
|
|
'reason' => $reason,
|
|
|
|
|
|
'reward_gold' => $rewardGold,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 15:59:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 发送系统房间广播。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $roomId 房间ID
|
|
|
|
|
|
* @param string $content 广播内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function broadcastSystemMessage(int $roomId, string $content): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$chatState = app(\App\Services\ChatStateService::class);
|
|
|
|
|
|
$messageData = [
|
|
|
|
|
|
'id' => $chatState->nextMessageId($roomId),
|
|
|
|
|
|
'room_id' => $roomId,
|
2026-03-12 16:55:55 +08:00
|
|
|
|
'from_user' => '系统传音',
|
2026-03-12 15:59:24 +08:00
|
|
|
|
'to_user' => '大家',
|
|
|
|
|
|
'content' => $content,
|
|
|
|
|
|
'is_secret' => false,
|
|
|
|
|
|
'font_color' => '#d97706', // 琥珀橙色
|
|
|
|
|
|
'action' => '大声宣告',
|
|
|
|
|
|
'sent_at' => now()->toDateTimeString(),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
$chatState->pushMessage($roomId, $messageData);
|
|
|
|
|
|
broadcast(new \App\Events\MessageSent($roomId, $messageData));
|
|
|
|
|
|
\App\Jobs\SaveMessageJob::dispatch($messageData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:35:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 根据对局模式和获胜方计算奖励金币。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|