Files
chatroom/resources/views/chat/partials/games/gomoku-panel.blade.php

953 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:五子棋对战面板组件
支持两种模式:
- 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">&times;</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>