feat(chat): 完善五子棋功能,包含AI对战、PvP邀请、断线重连及界面美化
This commit is contained in:
@@ -117,6 +117,15 @@ enum CurrencySource: string
|
|||||||
/** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */
|
/** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */
|
||||||
case LOTTERY_WIN = 'lottery_win';
|
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::FORTUNE_COST => '神秘占卜消耗',
|
||||||
self::LOTTERY_BUY => '双色球购票',
|
self::LOTTERY_BUY => '双色球购票',
|
||||||
self::LOTTERY_WIN => '双色球中奖',
|
self::LOTTERY_WIN => '双色球中奖',
|
||||||
|
self::GOMOKU_ENTRY_FEE => '五子棋入场费',
|
||||||
|
self::GOMOKU_WIN => '五子棋获胜奖励',
|
||||||
|
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
app/Events/GomokuFinishedEvent.php
Normal file
80
app/Events/GomokuFinishedEvent.php
Normal 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
app/Events/GomokuInviteEvent.php
Normal file
67
app/Events/GomokuInviteEvent.php
Normal 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
app/Events/GomokuMovedEvent.php
Normal file
74
app/Events/GomokuMovedEvent.php
Normal 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
app/Http/Controllers/GomokuController.php
Normal file
557
app/Http/Controllers/GomokuController.php
Normal 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')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
app/Models/GomokuGame.php
Normal file
224
app/Models/GomokuGame.php
Normal 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
app/Services/GomokuAiService.php
Normal file
447
app/Services/GomokuAiService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:五子棋对局记录表迁移
|
||||||
|
*
|
||||||
|
* 存储 PvP(玩家对战)和 PvE(人机对战)两种模式的对局信息,
|
||||||
|
* 包含棋盘状态、落子历史、对局结果及奖励记录。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建 gomoku_games 表。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('gomoku_games', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
41
database/seeders/GomokuConfigSeeder.php
Normal file
41
database/seeders/GomokuConfigSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class GomokuConfigSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$configs = [
|
||||||
|
// PvP 配置
|
||||||
|
['type' => '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']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -497,11 +497,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
${card.items.map(item => `
|
${card.items.map(item => `
|
||||||
<div class="flex justify-between text-xs">
|
<div class="flex justify-between text-xs">
|
||||||
<span class="text-gray-500">${item.label}</span>
|
<span class="text-gray-500">${item.label}</span>
|
||||||
<span class="font-bold text-gray-700">${item.value}</span>
|
<span class="font-bold text-gray-700">${item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -677,6 +677,25 @@
|
|||||||
'min' => 0,
|
'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 => [],
|
default => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@
|
|||||||
@include('chat.partials.games.lottery-panel')
|
@include('chat.partials.games.lottery-panel')
|
||||||
@include('chat.partials.games.red-packet-panel')
|
@include('chat.partials.games.red-packet-panel')
|
||||||
@include('chat.partials.games.fishing-panel')
|
@include('chat.partials.games.fishing-panel')
|
||||||
|
@include('chat.partials.games.game-hall')
|
||||||
|
@include('chat.partials.games.gomoku-panel')
|
||||||
|
|
||||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||||
<script src="/js/effects/effect-sounds.js"></script>
|
<script src="/js/effects/effect-sounds.js"></script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
|
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
|
||||||
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
|
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
|
||||||
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
|
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
|
||||||
|
'gomoku' => \App\Models\GameConfig::isEnabled('gomoku'),
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
<script>
|
<script>
|
||||||
@@ -301,6 +302,24 @@
|
|||||||
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
|
btnLabel: (data) => data?.is_open ? '🎟️ 立即购票' : '📊 查看结果',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'gomoku',
|
||||||
|
name: '♟️ 五子棋',
|
||||||
|
desc: '益智对弈,支持 PvP 随机对战和 AI 人机对战(4档难度)',
|
||||||
|
accentColor: '#1e3a5f',
|
||||||
|
fetchUrl: null,
|
||||||
|
openFn: () => {
|
||||||
|
closeGameHall();
|
||||||
|
if (typeof openGomokuPanel === 'function') openGomokuPanel();
|
||||||
|
},
|
||||||
|
renderStatus: () => ({
|
||||||
|
badge: '♟️ 随时对弈',
|
||||||
|
badgeStyle: 'background:#e8eef8; color:#1e3a5f; border:1px solid #9db3d4',
|
||||||
|
detail: 'PvP 胜利 +80 金币 | AI 专家难度胜利 +300 金币',
|
||||||
|
}),
|
||||||
|
btnLabel: () => '♟️ 开始对弈',
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
952
resources/views/chat/partials/games/gomoku-panel.blade.php
Normal file
952
resources/views/chat/partials/games/gomoku-panel.blade.php
Normal file
@@ -0,0 +1,952 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:五子棋对战面板组件
|
||||||
|
|
||||||
|
支持两种模式:
|
||||||
|
- PvP(随机对战):实时 WebSocket 同步双方落子
|
||||||
|
- PvE(人机对战):服务端 AI 同步返回落子坐标
|
||||||
|
|
||||||
|
@author ChatRoom Laravel
|
||||||
|
@version 1.0.0
|
||||||
|
--}}
|
||||||
|
|
||||||
|
{{-- ─── 五子棋面板遮罩 ─── --}}
|
||||||
|
<div id="gomoku-panel" x-data="gomokuPanel()" x-show="show" x-cloak>
|
||||||
|
<div
|
||||||
|
style="position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:9940;
|
||||||
|
display:flex; align-items:center; justify-content:center;">
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="width:520px; max-width:96vw; max-height:96vh; border-radius:12px; overflow:hidden;
|
||||||
|
box-shadow:0 12px 48px rgba(0,0,0,.45); font-family:'Microsoft YaHei',SimSun,sans-serif;
|
||||||
|
background:#fff; display:flex; flex-direction:column;">
|
||||||
|
|
||||||
|
{{-- ─── 标题栏 ─── --}}
|
||||||
|
<div
|
||||||
|
style="background:linear-gradient(135deg,#1e3a5f,#2d6096); padding:10px 16px;
|
||||||
|
display:flex; align-items:center; gap:10px; flex-shrink:0;">
|
||||||
|
<div style="font-size:20px;">♟️</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="color:#fff; font-weight:bold; font-size:14px;" x-text="title">五子棋对战</div>
|
||||||
|
<div style="color:rgba(255,255,255,.75); font-size:11px; margin-top:1px;" x-text="subtitle"></div>
|
||||||
|
</div>
|
||||||
|
{{-- 奖励显示 --}}
|
||||||
|
<div x-show="rewardGold > 0"
|
||||||
|
style="background:rgba(255,193,7,.2); border:1px solid rgba(255,193,7,.5); border-radius:20px;
|
||||||
|
padding:3px 10px; color:#ffd54f; font-size:11px; font-weight:bold;">
|
||||||
|
🏆 <span x-text="'奖励 ' + rewardGold + ' 金币'"></span>
|
||||||
|
</div>
|
||||||
|
{{-- 倒计时 --}}
|
||||||
|
<div x-show="gameStatus === 'playing' && moveTimeout > 0"
|
||||||
|
style="background:rgba(255,255,255,.15); border-radius:20px; padding:3px 10px;
|
||||||
|
color:#fff; font-size:12px; font-weight:bold; min-width:36px; text-align:center;"
|
||||||
|
:style="moveTimeout <= 10 ? 'color:#ff6b6b; animation:pulse 1s infinite' : ''"
|
||||||
|
x-text="moveTimeout + 's'">
|
||||||
|
</div>
|
||||||
|
<span @click="closePanel()"
|
||||||
|
style="cursor:pointer; font-size:20px; color:#fff; opacity:.8; line-height:1;"
|
||||||
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">×</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── 玩家信息栏 ─── --}}
|
||||||
|
<div x-show="gameStatus !== 'idle'"
|
||||||
|
style="background:#f8fafc; border-bottom:1px solid #d0e4f5; padding:8px 16px;
|
||||||
|
display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
|
||||||
|
{{-- 黑棋玩家 --}}
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<span
|
||||||
|
style="width:20px; height:20px; border-radius:50%; background:#1a1a1a;
|
||||||
|
border:2px solid #555; display:inline-block; flex-shrink:0;"></span>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; font-weight:bold; color:#1a1a1a;" x-text="blackName">黑棋</div>
|
||||||
|
<div style="font-size:10px; color:#666;">先手</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{-- 中间状态 --}}
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:11px; color:#336699; font-weight:bold;" x-text="turnText"></div>
|
||||||
|
<div style="font-size:10px; color:#888; margin-top:2px;" x-text="stepCount + ' 步'"></div>
|
||||||
|
</div>
|
||||||
|
{{-- 白棋玩家 --}}
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; flex-direction:row-reverse;">
|
||||||
|
<span
|
||||||
|
style="width:20px; height:20px; border-radius:50%; background:#fff;
|
||||||
|
border:2px solid #888; display:inline-block; flex-shrink:0;"></span>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div style="font-size:12px; font-weight:bold; color:#333;" x-text="whiteName">白棋</div>
|
||||||
|
<div style="font-size:10px; color:#666;" x-text="mode === 'pve' ? 'AI' : '后手'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── 内容区 ─── --}}
|
||||||
|
<div
|
||||||
|
style="flex:1; overflow-y:auto; background:#f0e9d8; display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center; padding:12px; gap:10px;">
|
||||||
|
|
||||||
|
{{-- 模式选择界面(idle 状态) --}}
|
||||||
|
<div x-show="gameStatus === 'idle'" style="width:100%; max-width:400px;">
|
||||||
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
|
<div style="font-size:28px; margin-bottom:6px;">♟️</div>
|
||||||
|
<div style="font-size:14px; font-weight:bold; color:#225588;">选择游戏模式</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- PvP 随机对战 --}}
|
||||||
|
<div @click="startPvP()"
|
||||||
|
style="background:linear-gradient(135deg,#1e3a5f,#2d6096); border-radius:12px;
|
||||||
|
padding:14px 18px; margin-bottom:10px; cursor:pointer; transition:all .22s;
|
||||||
|
box-shadow:0 2px 8px rgba(30,58,95,.18); position:relative; overflow:hidden;"
|
||||||
|
onmouseover="this.style.transform='translateY(-3px)'; this.style.boxShadow='0 8px 24px rgba(30,58,95,.36)'"
|
||||||
|
onmouseout="this.style.transform=''; this.style.boxShadow='0 2px 8px rgba(30,58,95,.18)'">
|
||||||
|
{{-- 背景装饰 --}}
|
||||||
|
<div
|
||||||
|
style="position:absolute;right:-10px;top:-10px;font-size:56px;opacity:.08;line-height:1;pointer-events:none;">
|
||||||
|
⚔️</div>
|
||||||
|
<div style="font-size:14px; font-weight:bold; color:#fff; margin-bottom:5px;">
|
||||||
|
⚔️ 随机对战(PvP)
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:rgba(255,255,255,.75); line-height:1.7;">
|
||||||
|
向聊天室发起挑战邀请,等待其他玩家接受<br>
|
||||||
|
胜利奖励:<strong style="color:#ffd54f;" x-text="(_pvpReward ?? 80) + ' 金币'"></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- PvE 人机对战:4档独立彩色卡片 --}}
|
||||||
|
<div style="margin-bottom:2px;">
|
||||||
|
<div
|
||||||
|
style="font-size:11px; font-weight:bold; color:#666; margin-bottom:8px; letter-spacing:.5px;">
|
||||||
|
🤖 人机对战(AI)— 选择难度
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||||
|
<template x-for="level in aiLevels" :key="level.id">
|
||||||
|
<div @click="startPvE(level)"
|
||||||
|
style="border-radius:12px; padding:14px 16px; cursor:pointer;
|
||||||
|
transition:all .25s cubic-bezier(0.34, 1.56, 0.64, 1); position:relative; overflow:hidden;
|
||||||
|
background:#fff; border:1.5px solid #eaeaea; box-shadow:0 3px 12px rgba(0,0,0,.03);"
|
||||||
|
@mouseover="$el.style.transform='translateY(-4px)';
|
||||||
|
$el.style.boxShadow='0 10px 24px '+level.color+'30';
|
||||||
|
$el.style.borderColor=level.color"
|
||||||
|
@mouseout="$el.style.transform='';
|
||||||
|
$el.style.boxShadow='0 3px 12px rgba(0,0,0,.03)';
|
||||||
|
$el.style.borderColor='#eaeaea'">
|
||||||
|
|
||||||
|
{{-- 底部彩色装饰条 --}}
|
||||||
|
<div style="position:absolute; bottom:0; left:0; width:100%; height:4px;"
|
||||||
|
:style="'background:' + level.color"></div>
|
||||||
|
|
||||||
|
{{-- 大图标装饰(右上角部分溢出隐藏更具高级感) --}}
|
||||||
|
<div style="position:absolute;right:-8px;top:-6px;font-size:46px;opacity:.06;line-height:1;pointer-events:none;transform:rotate(12deg);"
|
||||||
|
x-text="level.icon"></div>
|
||||||
|
|
||||||
|
{{-- 难度名称 --}}
|
||||||
|
<div style="font-size:14px; font-weight:900; margin-bottom:5px; display:flex; align-items:center; gap:6px;"
|
||||||
|
:style="'color:' + level.color">
|
||||||
|
<span x-text="level.icon" style="font-size:16px;"></span>
|
||||||
|
<span x-text="level.name"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 胜利奖励 --}}
|
||||||
|
<div style="font-size:12px; font-weight:bold; color:#444; margin-bottom:2px;">
|
||||||
|
🏆 赢 <span style="font-family:monospace; font-size:13px;"
|
||||||
|
:style="'color:' + level.color" x-text="'+' + level.reward + ' 金'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 入场费 --}}
|
||||||
|
<div style="font-size:10px; color:#999; display:flex; align-items:center; gap:3px;">
|
||||||
|
<template x-if="level.fee > 0">
|
||||||
|
<span>🎫 入场费 <span x-text="level.fee"></span></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="level.fee === 0">
|
||||||
|
<span
|
||||||
|
style="color:#16a34a; background:#dcfce7; padding:1px 6px; border-radius:10px;">免费</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 等待对手接受(PvP waiting) --}}
|
||||||
|
<div x-show="gameStatus === 'waiting'" style="text-align:center; padding:20px;">
|
||||||
|
<div style="font-size:40px; animation:spin 2s linear infinite; display:inline-block;">⏳</div>
|
||||||
|
<div style="font-size:14px; font-weight:bold; color:#225588; margin:12px 0 6px;">
|
||||||
|
等待对手接受挑战…
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#888; margin-bottom:16px;">
|
||||||
|
邀请将在 <span x-text="inviteTimeout" style="color:#336699; font-weight:bold;"></span> 秒后超时
|
||||||
|
</div>
|
||||||
|
<button @click="cancelInvite()"
|
||||||
|
style="padding:8px 24px; border:1.5px solid #d0e4f5; border-radius:20px;
|
||||||
|
background:#f0f6ff; color:#336699; font-size:12px; cursor:pointer;
|
||||||
|
font-family:inherit; transition:all .15s;"
|
||||||
|
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
|
||||||
|
取消邀请
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 棋盘(playing / finished 状态) --}}
|
||||||
|
<div x-show="gameStatus === 'playing' || gameStatus === 'finished'">
|
||||||
|
<canvas id="gomoku-canvas"
|
||||||
|
style="cursor:pointer; border-radius:4px; box-shadow:0 2px 12px rgba(0,0,0,.25);"
|
||||||
|
@click="handleCanvasClick($event)" @mousemove="handleCanvasHover($event)"
|
||||||
|
@mouseleave="hoverPos = null; redrawBoard()">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 对局结果 --}}
|
||||||
|
<div x-show="gameStatus === 'finished'"
|
||||||
|
style="background:#fff; border-radius:10px; padding:14px 20px; text-align:center;
|
||||||
|
box-shadow:0 2px 12px rgba(0,0,0,.1); min-width:220px;">
|
||||||
|
<div style="font-size:22px; margin-bottom:6px;" x-text="resultEmoji"></div>
|
||||||
|
<div style="font-size:14px; font-weight:bold; color:#225588; margin-bottom:4px;"
|
||||||
|
x-text="resultText"></div>
|
||||||
|
<div x-show="resultGold > 0" style="font-size:12px; color:#e6a800; font-weight:bold;"
|
||||||
|
x-text="'💰 获得 ' + resultGold + ' 金币'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── 底部按钮栏 ─── --}}
|
||||||
|
<div
|
||||||
|
style="padding:10px 14px; background:#fff; border-top:1px solid #d0e4f5;
|
||||||
|
display:flex; justify-content:center; gap:8px; flex-shrink:0;">
|
||||||
|
|
||||||
|
{{-- 认输按钮(对局中才显示) --}}
|
||||||
|
<button
|
||||||
|
x-show="gameStatus === 'playing' && isMyTurn === false || (gameStatus === 'playing' && mode === 'pvp')"
|
||||||
|
@click="resign()"
|
||||||
|
style="padding:8px 18px; border:1.5px solid #ffd0d0; border-radius:20px;
|
||||||
|
background:#fff5f5; color:#dc2626; font-size:12px; cursor:pointer;
|
||||||
|
font-family:inherit; transition:all .15s;"
|
||||||
|
onmouseover="this.style.background='#fee2e2'" onmouseout="this.style.background='#fff5f5'">
|
||||||
|
🏳️ 认输
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- 再来一局 --}}
|
||||||
|
<button x-show="gameStatus === 'finished'" @click="resetToIdle()"
|
||||||
|
style="padding:8px 18px; border:none; border-radius:20px;
|
||||||
|
background:linear-gradient(135deg,#2d6096,#336699); color:#fff;
|
||||||
|
font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s;
|
||||||
|
box-shadow:0 2px 8px rgba(51,102,153,.3);"
|
||||||
|
onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'">
|
||||||
|
🔄 再来一局
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- 关闭 --}}
|
||||||
|
<button @click="closePanel()"
|
||||||
|
style="padding:8px 18px; border:1.5px solid #e0e0e0; border-radius:20px;
|
||||||
|
background:#f9fafb; color:#666; font-size:12px; cursor:pointer;
|
||||||
|
font-family:inherit; transition:all .15s;"
|
||||||
|
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#f9fafb'">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 五子棋面板 Alpine.js 组件
|
||||||
|
*/
|
||||||
|
function gomokuPanel() {
|
||||||
|
return {
|
||||||
|
// ─── 基础状态 ───
|
||||||
|
show: false,
|
||||||
|
mode: 'pvp', // 'pvp' | 'pve'
|
||||||
|
gameStatus: 'idle', // 'idle' | 'waiting' | 'playing' | 'finished'
|
||||||
|
gameId: null,
|
||||||
|
myColor: null, // 1=黑 2=白
|
||||||
|
|
||||||
|
// ─── 玩家信息 ───
|
||||||
|
blackName: '',
|
||||||
|
whiteName: '',
|
||||||
|
currentTurn: 1, // 1=黑 2=白
|
||||||
|
stepCount: 0,
|
||||||
|
|
||||||
|
// ─── AI 配置 ───
|
||||||
|
aiLevels: [{
|
||||||
|
id: 1,
|
||||||
|
name: '简单',
|
||||||
|
icon: '🟢',
|
||||||
|
reward: 20,
|
||||||
|
fee: 0,
|
||||||
|
color: '#16a34a'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '普通',
|
||||||
|
icon: '🟡',
|
||||||
|
reward: 50,
|
||||||
|
fee: 10,
|
||||||
|
color: '#ca8a04'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '困难',
|
||||||
|
icon: '🔴',
|
||||||
|
reward: 120,
|
||||||
|
fee: 30,
|
||||||
|
color: '#dc2626'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '专家',
|
||||||
|
icon: '⚡',
|
||||||
|
reward: 300,
|
||||||
|
fee: 80,
|
||||||
|
color: '#7c3aed'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── 棋盘 ───
|
||||||
|
board: [], // 15×15 矩阵
|
||||||
|
hoverPos: null, // {row, col} 悬停位置
|
||||||
|
lastMove: null, // {row, col} 最后落子(高亮用)
|
||||||
|
CELL: 30, // 格子像素大小
|
||||||
|
PAD: 22, // 棋盘内边距
|
||||||
|
|
||||||
|
// ─── 对局结果 ───
|
||||||
|
rewardGold: 0,
|
||||||
|
resultEmoji: '',
|
||||||
|
resultText: '',
|
||||||
|
resultGold: 0,
|
||||||
|
|
||||||
|
// ─── 超时计时器 ───
|
||||||
|
inviteTimeout: 60,
|
||||||
|
moveTimeout: 0,
|
||||||
|
_inviteTimer: null,
|
||||||
|
_moveTimer: null,
|
||||||
|
|
||||||
|
// ─── WebSocket 监听器 ───
|
||||||
|
_echoChannel: null,
|
||||||
|
|
||||||
|
// PvP 胜利奖励(open() 后从接口更新)
|
||||||
|
_pvpReward: 80,
|
||||||
|
|
||||||
|
// ─── 计算属性 ───
|
||||||
|
get title() {
|
||||||
|
if (this.gameStatus === 'idle') return '五子棋';
|
||||||
|
if (this.gameStatus === 'waiting') return '等待对手…';
|
||||||
|
if (this.gameStatus === 'finished') return '对局结束';
|
||||||
|
return this.mode === 'pvp' ? '五子棋 PvP 对战' : '五子棋 AI 对战';
|
||||||
|
},
|
||||||
|
get subtitle() {
|
||||||
|
if (this.gameStatus === 'idle') return '选择游戏模式开始';
|
||||||
|
if (this.gameStatus === 'waiting') return '已发出对战邀请';
|
||||||
|
if (this.gameStatus === 'finished') return '对局已结束';
|
||||||
|
return this.isMyTurn ? '● 轮到您落子' : '○ 等待对方落子…';
|
||||||
|
},
|
||||||
|
get isMyTurn() {
|
||||||
|
if (this.mode === 'pve') return this.currentTurn === 1;
|
||||||
|
return this.currentTurn === this.myColor;
|
||||||
|
},
|
||||||
|
get turnText() {
|
||||||
|
if (this.gameStatus === 'finished') return '对局结束';
|
||||||
|
return this.currentTurn === 1 ? '● 黑棋回合' : '○ 白棋回合';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 打开/关闭 ───
|
||||||
|
|
||||||
|
/** 从外部打开面板 */
|
||||||
|
async open() {
|
||||||
|
this.show = true;
|
||||||
|
this.resetToIdle();
|
||||||
|
this.$nextTick(() => this.initCanvas());
|
||||||
|
|
||||||
|
// ① 并行加载后台配置 + 检查活跃对局
|
||||||
|
const [cfg, active] = await Promise.all([
|
||||||
|
fetch('/gomoku/config').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/gomoku/active').then(r => r.json()).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 更新 AI 难度列表(后台配置)
|
||||||
|
if (cfg?.ok && Array.isArray(cfg.pve_levels)) {
|
||||||
|
const icons = ['🟢', '🟡', '🔴', '⚡'];
|
||||||
|
const colors = ['#16a34a', '#ca8a04', '#dc2626', '#7c3aed'];
|
||||||
|
this.aiLevels = cfg.pve_levels.map((lv, i) => ({
|
||||||
|
id: lv.level,
|
||||||
|
name: lv.name,
|
||||||
|
icon: icons[i] ?? '♟️',
|
||||||
|
reward: lv.reward,
|
||||||
|
fee: lv.fee,
|
||||||
|
color: colors[i] ?? '#336699',
|
||||||
|
}));
|
||||||
|
this._pvpReward = cfg.pvp_reward ?? 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 如有进行中对局,弹出恢复选择(全局弹窗)
|
||||||
|
if (active?.has_active) {
|
||||||
|
const modeLabel = active.mode === 'pve' ?
|
||||||
|
`人机对战(${active.white_name})` :
|
||||||
|
`PvP 对战(${active.black_name} vs ${active.white_name})`;
|
||||||
|
|
||||||
|
const resume = await window.chatDialog.confirm(
|
||||||
|
`您有一局未完成的${modeLabel},确定继续吗?\n取消则直接认输并结束本局。`,
|
||||||
|
'♟️ 恢复对局',
|
||||||
|
'#1e3a5f'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resume) {
|
||||||
|
// 继续对局:恢复棋盘状态
|
||||||
|
this.gameId = active.game_id;
|
||||||
|
this.mode = active.mode;
|
||||||
|
this.myColor = active.your_color;
|
||||||
|
this.currentTurn = active.current_turn;
|
||||||
|
this.board = active.board;
|
||||||
|
this.blackName = active.black_name;
|
||||||
|
this.whiteName = active.white_name;
|
||||||
|
this.gameStatus = active.status === 'waiting' ? 'waiting' : 'playing';
|
||||||
|
this.rewardGold = active.mode === 'pvp' ? (this._pvpReward ?? 80) : 0;
|
||||||
|
this.$nextTick(() => this.redrawBoard());
|
||||||
|
// PvP 恢复后重新订阅私有频道
|
||||||
|
if (active.mode === 'pvp' && active.status === 'playing') {
|
||||||
|
this.subscribeToGame(active.game_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 认输:调接口后提示
|
||||||
|
await fetch(`/gomoku/${active.game_id}/resign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.content ?? '',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
await window.chatDialog.alert('已认输,对局结束。', '♟️ 对局结束', '#336699');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 初始化面板并直接加入对局(被邀请方) */
|
||||||
|
async openAndJoin(gameId) {
|
||||||
|
this.show = true;
|
||||||
|
this.$nextTick(async () => {
|
||||||
|
this.initCanvas();
|
||||||
|
await this.joinGame(gameId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 关闭面板 */
|
||||||
|
closePanel() {
|
||||||
|
// 对局中时不能直接关闭(防误操作)
|
||||||
|
if (this.gameStatus === 'playing') {
|
||||||
|
window.chatDialog?.confirm('对局进行中,关闭面板将不会认输,确定关闭?').then(ok => {
|
||||||
|
if (ok) {
|
||||||
|
this.cleanUp();
|
||||||
|
this.show = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cleanUp();
|
||||||
|
this.show = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 重置到选择模式界面 */
|
||||||
|
resetToIdle() {
|
||||||
|
this.cleanUp();
|
||||||
|
this.gameStatus = 'idle';
|
||||||
|
this.gameId = null;
|
||||||
|
this.myColor = null;
|
||||||
|
this.board = Array.from({
|
||||||
|
length: 15
|
||||||
|
}, () => Array(15).fill(0));
|
||||||
|
this.lastMove = null;
|
||||||
|
this.stepCount = 0;
|
||||||
|
this.rewardGold = 0;
|
||||||
|
this.resultEmoji = '';
|
||||||
|
this.resultText = '';
|
||||||
|
this.resultGold = 0;
|
||||||
|
this.$nextTick(() => this.redrawBoard());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清理定时器和 WebSocket 监听 */
|
||||||
|
cleanUp() {
|
||||||
|
clearInterval(this._inviteTimer);
|
||||||
|
clearInterval(this._moveTimer);
|
||||||
|
if (this._echoChannel && window.Echo) {
|
||||||
|
window.Echo.leave(`gomoku.${this.gameId}`);
|
||||||
|
this._echoChannel = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 游戏开始 ───
|
||||||
|
|
||||||
|
/** 发起 PvP 随机对战 */
|
||||||
|
async startPvP() {
|
||||||
|
const roomId = window.chatContext?.roomId;
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
const res = await this.post('/gomoku/create', {
|
||||||
|
mode: 'pvp',
|
||||||
|
room_id: roomId
|
||||||
|
});
|
||||||
|
if (!res?.ok) {
|
||||||
|
window.chatDialog?.alert(res?.message || '发起失败,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameId = res.game_id;
|
||||||
|
this.mode = 'pvp';
|
||||||
|
this.myColor = 1; // 发起方执黑
|
||||||
|
this.blackName = window.chatContext?.username || '我';
|
||||||
|
this.whiteName = '等待中…';
|
||||||
|
this.gameStatus = 'waiting';
|
||||||
|
this.inviteTimeout = 60;
|
||||||
|
|
||||||
|
// 启动邀请倒计时
|
||||||
|
this._inviteTimer = setInterval(() => {
|
||||||
|
this.inviteTimeout--;
|
||||||
|
if (this.inviteTimeout <= 0) {
|
||||||
|
clearInterval(this._inviteTimer);
|
||||||
|
if (this.gameStatus === 'waiting') {
|
||||||
|
this.gameStatus = 'idle';
|
||||||
|
window.chatDialog?.alert('邀请已超时,无人接受挑战。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 监听对手加入事件(通过房间频道广播触发)
|
||||||
|
this.waitForOpponent();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 发起 PvE 人机对战 */
|
||||||
|
async startPvE(level) {
|
||||||
|
const roomId = window.chatContext?.roomId;
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
const res = await this.post('/gomoku/create', {
|
||||||
|
mode: 'pve',
|
||||||
|
room_id: roomId,
|
||||||
|
ai_level: level.id,
|
||||||
|
});
|
||||||
|
if (!res?.ok) {
|
||||||
|
window.chatDialog?.alert(res?.message || '创建失败,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameId = res.game_id;
|
||||||
|
this.mode = 'pve';
|
||||||
|
this.myColor = 1;
|
||||||
|
this.blackName = window.chatContext?.username || '我';
|
||||||
|
this.whiteName = `AI(${level.name})`;
|
||||||
|
this.rewardGold = level.reward;
|
||||||
|
this.board = Array.from({
|
||||||
|
length: 15
|
||||||
|
}, () => Array(15).fill(0));
|
||||||
|
this.currentTurn = 1;
|
||||||
|
this.gameStatus = 'playing';
|
||||||
|
this.$nextTick(() => this.redrawBoard());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 加入 PvP 对战(接受邀请方) */
|
||||||
|
async joinGame(gameId) {
|
||||||
|
const res = await this.post(`/gomoku/${gameId}/join`, {});
|
||||||
|
if (!res?.ok) {
|
||||||
|
window.chatDialog?.alert(res?.message || '加入失败');
|
||||||
|
this.show = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 重新获取对局状态
|
||||||
|
await this.syncState(gameId);
|
||||||
|
this.subscribeToGame(gameId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 等待对手通过轮询或 WebSocket 通知加入 */
|
||||||
|
waitForOpponent() {
|
||||||
|
// 每 3 秒轮询一次对局状态
|
||||||
|
const maxAttempts = 20;
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
if (this.gameStatus !== 'waiting') {
|
||||||
|
clearInterval(poll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
clearInterval(poll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(`/gomoku/${this.gameId}/state`).then(r => r.json()).catch(() =>
|
||||||
|
null);
|
||||||
|
if (res?.status === 'playing') {
|
||||||
|
clearInterval(poll);
|
||||||
|
this.whiteName = res.opponent_name || '对手';
|
||||||
|
this.currentTurn = res.current_turn;
|
||||||
|
this.gameStatus = 'playing';
|
||||||
|
this.subscribeToGame(this.gameId);
|
||||||
|
this.$nextTick(() => this.redrawBoard());
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 同步对局状态(连接时)*/
|
||||||
|
async syncState(gameId) {
|
||||||
|
const res = await fetch(`/gomoku/${gameId}/state`).then(r => r.json()).catch(() => null);
|
||||||
|
if (!res?.ok) return;
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.mode = res.mode;
|
||||||
|
this.myColor = res.your_color;
|
||||||
|
this.currentTurn = res.current_turn;
|
||||||
|
this.board = res.board;
|
||||||
|
this.gameStatus = res.status;
|
||||||
|
this.blackName = res.black_name || '黑棋';
|
||||||
|
this.whiteName = res.white_name || '白棋';
|
||||||
|
this.rewardGold = res.mode === 'pvp' ? 80 : res.reward_gold ?? 0;
|
||||||
|
this.$nextTick(() => this.redrawBoard());
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── WebSocket 监听 ───
|
||||||
|
|
||||||
|
/** 订阅对局私有频道 */
|
||||||
|
subscribeToGame(gameId) {
|
||||||
|
if (!window.Echo) return;
|
||||||
|
this._echoChannel = window.Echo.private(`gomoku.${gameId}`)
|
||||||
|
.listen('.gomoku.moved', (e) => {
|
||||||
|
this.onRemoteMove(e);
|
||||||
|
})
|
||||||
|
.listen('.gomoku.finished', (e) => {
|
||||||
|
this.onGameFinished(e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 收到远端落子 */
|
||||||
|
onRemoteMove(e) {
|
||||||
|
// 仅 PvP 模式会通过 WebSocket 接收对方落子
|
||||||
|
if (this.mode !== 'pvp') return;
|
||||||
|
this.board[e.row][e.col] = e.color;
|
||||||
|
this.lastMove = {
|
||||||
|
row: e.row,
|
||||||
|
col: e.col
|
||||||
|
};
|
||||||
|
this.currentTurn = e.current_turn;
|
||||||
|
this.stepCount++;
|
||||||
|
this.redrawBoard();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 游戏结束通知(WebSocket)*/
|
||||||
|
onGameFinished(e) {
|
||||||
|
if (e.game_id !== this.gameId) return;
|
||||||
|
this.gameStatus = 'finished';
|
||||||
|
this.showResult(e.winner, e.winner_name, e.reason, e.reward_gold);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 落子逻辑 ───
|
||||||
|
|
||||||
|
/** Canvas 点击事件:计算坐标并落子 */
|
||||||
|
async handleCanvasClick(event) {
|
||||||
|
if (this.gameStatus !== 'playing' || !this.isMyTurn) return;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('gomoku-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const col = Math.round((x - this.PAD) / this.CELL);
|
||||||
|
const row = Math.round((y - this.PAD) / this.CELL);
|
||||||
|
|
||||||
|
if (row < 0 || row >= 15 || col < 0 || col >= 15) return;
|
||||||
|
if (this.board[row][col] !== 0) return;
|
||||||
|
|
||||||
|
// 乐观更新(先本地显示,等服务端响应)
|
||||||
|
this.board[row][col] = this.myColor;
|
||||||
|
this.lastMove = {
|
||||||
|
row,
|
||||||
|
col
|
||||||
|
};
|
||||||
|
this.stepCount++;
|
||||||
|
this.redrawBoard();
|
||||||
|
|
||||||
|
// 切换回合(PvP 时前端先切,等服务端广播校正)
|
||||||
|
if (this.mode === 'pvp') {
|
||||||
|
this.currentTurn = this.myColor === 1 ? 2 : 1;
|
||||||
|
} else {
|
||||||
|
// PvE 不立即切换,等 AI 落子返回
|
||||||
|
this.currentTurn = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.post(`/gomoku/${this.gameId}/move`, {
|
||||||
|
row,
|
||||||
|
col
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res?.ok) {
|
||||||
|
// 落子被拒:回滚
|
||||||
|
this.board[row][col] = 0;
|
||||||
|
this.stepCount--;
|
||||||
|
this.currentTurn = this.myColor;
|
||||||
|
this.redrawBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.finished) {
|
||||||
|
this.gameStatus = 'finished';
|
||||||
|
this.showResult(res.winner, '', res.reason, res.reward_gold);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PvE:处理 AI 落子
|
||||||
|
if (this.mode === 'pve' && res.ai_moved) {
|
||||||
|
// 延迟 600ms 展示 AI "思考"效果
|
||||||
|
setTimeout(() => {
|
||||||
|
const ai = res.ai_moved;
|
||||||
|
this.board[ai.row][ai.col] = 2;
|
||||||
|
this.lastMove = {
|
||||||
|
row: ai.row,
|
||||||
|
col: ai.col
|
||||||
|
};
|
||||||
|
this.stepCount++;
|
||||||
|
this.currentTurn = 1;
|
||||||
|
this.redrawBoard();
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Canvas 鼠标移动:悬停预览 */
|
||||||
|
handleCanvasHover(event) {
|
||||||
|
if (this.gameStatus !== 'playing' || !this.isMyTurn) return;
|
||||||
|
const canvas = document.getElementById('gomoku-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
const col = Math.round((x - this.PAD) / this.CELL);
|
||||||
|
const row = Math.round((y - this.PAD) / this.CELL);
|
||||||
|
if (row >= 0 && row < 15 && col >= 0 && col < 15 && this.board[row][col] === 0) {
|
||||||
|
this.hoverPos = {
|
||||||
|
row,
|
||||||
|
col
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.hoverPos = null;
|
||||||
|
}
|
||||||
|
this.redrawBoard();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 其他操作 ───
|
||||||
|
|
||||||
|
/** 认输 */
|
||||||
|
async resign() {
|
||||||
|
const ok = await window.chatDialog?.confirm('确定认输?').catch(() => false) ?? false;
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await this.post(`/gomoku/${this.gameId}/resign`, {});
|
||||||
|
if (res?.finished) {
|
||||||
|
this.gameStatus = 'finished';
|
||||||
|
this.showResult(res.winner, '', 'resign', 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 取消邀请 */
|
||||||
|
async cancelInvite() {
|
||||||
|
await this.post(`/gomoku/${this.gameId}/cancel`, {});
|
||||||
|
clearInterval(this._inviteTimer);
|
||||||
|
this.gameStatus = 'idle';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 显示对局结果 */
|
||||||
|
showResult(winner, winnerName, reason, gold) {
|
||||||
|
const isWinner = (winner === this.myColor);
|
||||||
|
const isDraw = (winner === 0);
|
||||||
|
this.resultGold = gold ?? 0;
|
||||||
|
if (isDraw) {
|
||||||
|
this.resultEmoji = '🤝';
|
||||||
|
this.resultText = '平局!势均力敌';
|
||||||
|
} else if (reason === 'resign') {
|
||||||
|
this.resultEmoji = isWinner ? '🏆' : '😔';
|
||||||
|
this.resultText = isWinner ? '对手认输,您获胜!' : '您认输了';
|
||||||
|
} else {
|
||||||
|
this.resultEmoji = isWinner ? '🏆' : '😔';
|
||||||
|
this.resultText = isWinner ?
|
||||||
|
(this.mode === 'pve' ? '恭喜!您击败了 AI!' : '恭喜!您获胜了!') :
|
||||||
|
(this.mode === 'pve' ? 'AI 获胜,再接再厉!' : '惜败,再来一局!');
|
||||||
|
}
|
||||||
|
if (isWinner && gold > 0 && window.chatContext) {
|
||||||
|
window.chatContext.userJjb = (window.chatContext.userJjb ?? 0) + gold;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Canvas 绘制 ───
|
||||||
|
|
||||||
|
/** 初始化 Canvas 尺寸 */
|
||||||
|
initCanvas() {
|
||||||
|
const canvas = document.getElementById('gomoku-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const size = this.CELL * 14 + this.PAD * 2;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
this.redrawBoard();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 重绘棋盘 */
|
||||||
|
redrawBoard() {
|
||||||
|
const canvas = document.getElementById('gomoku-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const {
|
||||||
|
CELL,
|
||||||
|
PAD
|
||||||
|
} = this;
|
||||||
|
const SIZE = CELL * 14 + PAD * 2;
|
||||||
|
|
||||||
|
// 棋盘背景
|
||||||
|
ctx.fillStyle = '#dcb866';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
// 棋盘格线
|
||||||
|
ctx.strokeStyle = '#a0783a';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const x = PAD + i * CELL;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, PAD);
|
||||||
|
ctx.lineTo(x, PAD + 14 * CELL);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD, x);
|
||||||
|
ctx.lineTo(PAD + 14 * CELL, x);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 星位(天元 + 四角星)
|
||||||
|
const starPoints = [
|
||||||
|
[3, 3],
|
||||||
|
[3, 11],
|
||||||
|
[11, 3],
|
||||||
|
[11, 11],
|
||||||
|
[7, 7]
|
||||||
|
];
|
||||||
|
ctx.fillStyle = '#7a5c28';
|
||||||
|
starPoints.forEach(([r, c]) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(PAD + c * CELL, PAD + r * CELL, 3.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 坐标标记
|
||||||
|
const cols = 'ABCDEFGHJKLMNOP';
|
||||||
|
ctx.fillStyle = '#7a5c28';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
ctx.fillText(cols[i], PAD + i * CELL, PAD - 12);
|
||||||
|
ctx.fillText(15 - i, PAD - 14, PAD + i * CELL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 棋子
|
||||||
|
this.board.forEach((row, r) => {
|
||||||
|
row.forEach((cell, c) => {
|
||||||
|
if (cell === 0) return;
|
||||||
|
const x = PAD + c * CELL;
|
||||||
|
const y = PAD + r * CELL;
|
||||||
|
const isLast = this.lastMove?.row === r && this.lastMove?.col === c;
|
||||||
|
|
||||||
|
// 棋子渐变(立体感)
|
||||||
|
const grad = ctx.createRadialGradient(x - 3, y - 3, 1, x, y, CELL * 0.45);
|
||||||
|
if (cell === 1) {
|
||||||
|
grad.addColorStop(0, '#888');
|
||||||
|
grad.addColorStop(1, '#111');
|
||||||
|
} else {
|
||||||
|
grad.addColorStop(0, '#fff');
|
||||||
|
grad.addColorStop(1, '#ccc');
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, CELL * 0.44, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = cell === 1 ? '#000' : '#aaa';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 最后落子标记
|
||||||
|
if (isLast) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = cell === 1 ? '#fff' : '#666';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 悬停预览棋子(半透明)
|
||||||
|
if (this.hoverPos && this.isMyTurn && this.gameStatus === 'playing') {
|
||||||
|
const {
|
||||||
|
row: hr,
|
||||||
|
col: hc
|
||||||
|
} = this.hoverPos;
|
||||||
|
if (this.board[hr][hc] === 0) {
|
||||||
|
const hx = PAD + hc * CELL;
|
||||||
|
const hy = PAD + hr * CELL;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx, hy, CELL * 0.44, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = this.myColor === 1 ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.55)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = this.myColor === 1 ? 'rgba(0,0,0,.5)' : 'rgba(150,150,150,.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 网络请求辅助 ───
|
||||||
|
|
||||||
|
/** 封装 POST 请求 */
|
||||||
|
async post(url, data) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从外部打开五子棋面板 */
|
||||||
|
window.openGomokuPanel = function() {
|
||||||
|
const panel = document.getElementById('gomoku-panel');
|
||||||
|
if (panel) Alpine.$data(panel).open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 接受 PvP 邀请并打开棋盘 */
|
||||||
|
window.acceptGomokuInvite = function(gameId) {
|
||||||
|
const panel = document.getElementById('gomoku-panel');
|
||||||
|
if (panel) Alpine.$data(panel).openAndJoin(gameId);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -735,6 +735,112 @@
|
|||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener);
|
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 ?
|
||||||
|
// 自己的邀请:只显示打开面板按钮,方便被关掉后重新进入
|
||||||
|
`<button onclick="document.querySelector('[x-data=\"gomokuPanel()\"]').__x.$data.open()"
|
||||||
|
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
|
||||||
|
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
|
||||||
|
cursor:pointer; font-family:inherit; transition:all .15s;"
|
||||||
|
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
|
||||||
|
⤴️ 打开面板
|
||||||
|
</button>` :
|
||||||
|
// 别人的邀请:显示接受挑战按钮
|
||||||
|
`<button onclick="acceptGomokuInvite(${e.game_id})" id="gomoku-accept-${e.game_id}"
|
||||||
|
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
|
||||||
|
border-radius:12px; background:#336699; color:#fff; font-size:12px;
|
||||||
|
cursor:pointer; font-family:inherit; transition:all .15s;"
|
||||||
|
onmouseover="this.style.opacity='.8'" onmouseout="this.style.opacity='1'">
|
||||||
|
⚔️ 接受挑战
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
|
||||||
|
♟️ 【五子棋】<b>${e.inviter_name}</b> 发起了随机对战!${isSelf ? '(等待中)' : ''}
|
||||||
|
</span>${acceptBtn}
|
||||||
|
<span class="msg-time">(${timeStr})</span>`;
|
||||||
|
|
||||||
|
// 追加到公聊窗口
|
||||||
|
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 = `♟️ 五子棋对局以<b>平局</b>结束!`;
|
||||||
|
} else {
|
||||||
|
text =
|
||||||
|
`♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>(${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML =
|
||||||
|
`<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
|
||||||
|
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) => {
|
window.addEventListener('chat:effect', (e) => {
|
||||||
const type = e.detail?.type;
|
const type = e.detail?.type;
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ Broadcast::channel('room.{roomId}', function ($user, $roomId) {
|
|||||||
Broadcast::channel('user.{id}', function ($user, $id) {
|
Broadcast::channel('user.{id}', function ($user, $id) {
|
||||||
return (int) $user->id === (int) $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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ Route::post('/login', [AuthController::class, 'login'])->name('login.post');
|
|||||||
// 处理退出登录
|
// 处理退出登录
|
||||||
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
||||||
|
|
||||||
|
|
||||||
// 聊天室系统内部路由 (需要鉴权)
|
// 聊天室系统内部路由 (需要鉴权)
|
||||||
Route::middleware(['chat.auth'])->group(function () {
|
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::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 () {
|
Route::get('/games/enabled', function () {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -187,6 +220,7 @@ Route::middleware(['chat.auth'])->group(function () {
|
|||||||
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
|
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
|
||||||
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
|
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
|
||||||
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
|
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
|
||||||
|
'gomoku' => \App\Models\GameConfig::isEnabled('gomoku'),
|
||||||
]);
|
]);
|
||||||
})->name('games.enabled');
|
})->name('games.enabled');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user