953 lines
42 KiB
PHP
953 lines
42 KiB
PHP
{{--
|
||
文件功能:五子棋对战面板组件
|
||
|
||
支持两种模式:
|
||
- 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>
|