// 五子棋主面板 Alpine 组件,负责 PvP/PvE 对局、Canvas 棋盘绘制和实时同步。 const DEFAULT_GOMOKU_BASE_URL = "/gomoku"; const DEFAULT_GOMOKU_CONFIG_URL = "/gomoku/config"; const DEFAULT_GOMOKU_ACTIVE_URL = "/gomoku/active"; const DEFAULT_GOMOKU_CREATE_URL = "/gomoku/create"; const BOARD_SIZE = 15; const DEFAULT_CELL_SIZE = 30; const DEFAULT_BOARD_PADDING = 22; const DEFAULT_INVITE_TIMEOUT = 60; const DEFAULT_PVP_REWARD = 80; const DEFAULT_AI_ICONS = ["🟢", "🟡", "🔴", "⚡"]; const DEFAULT_AI_COLORS = ["#16a34a", "#ca8a04", "#dc2626", "#7c3aed"]; const DEFAULT_AI_LEVELS = [ { 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" }, ]; /** * 读取五子棋接口地址,优先使用 Blade 写入的命名路由。 * * @returns {{base: string, config: string, active: string, create: string}} */ function gomokuUrls() { const panel = document.getElementById("gomoku-panel"); return { base: panel?.dataset.gomokuBaseUrl || DEFAULT_GOMOKU_BASE_URL, config: panel?.dataset.gomokuConfigUrl || DEFAULT_GOMOKU_CONFIG_URL, active: panel?.dataset.gomokuActiveUrl || DEFAULT_GOMOKU_ACTIVE_URL, create: panel?.dataset.gomokuCreateUrl || DEFAULT_GOMOKU_CREATE_URL, }; } /** * 拼接五子棋对局动态接口路径。 * * @param {number|string} gameId 对局 ID * @param {string} action 动作名称 * @returns {string} */ function gomokuGameUrl(gameId, action) { return `${gomokuUrls().base}/${gameId}/${action}`; } /** * 读取 CSRF Token。 * * @returns {string} */ function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; } /** * 生成空棋盘矩阵。 * * @returns {number[][]} */ function emptyBoard() { return Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(0)); } /** * 请求 JSON,失败时返回 null,保持原组件的静默容错行为。 * * @param {string} url 请求地址 * @returns {Promise|null>} */ async function fetchJson(url) { return fetch(url).then((response) => response.json()).catch(() => null); } /** * 创建五子棋面板 Alpine 组件。 * * @returns {Record} */ export function gomokuPanel() { return { show: false, mode: "pvp", gameStatus: "idle", gameId: null, myColor: null, blackName: "", whiteName: "", currentTurn: 1, stepCount: 0, aiLevels: DEFAULT_AI_LEVELS.map((level) => ({ ...level })), board: [], hoverPos: null, lastMove: null, CELL: DEFAULT_CELL_SIZE, PAD: DEFAULT_BOARD_PADDING, rewardGold: 0, resultEmoji: "", resultText: "", resultGold: 0, inviteTimeout: DEFAULT_INVITE_TIMEOUT, moveTimeout: 0, _inviteTimer: null, _moveTimer: null, _opponentPollTimer: null, _echoChannel: null, _pvpReward: DEFAULT_PVP_REWARD, /** * 面板标题。 * * @returns {string} */ get title() { if (this.gameStatus === "idle") { return "五子棋"; } if (this.gameStatus === "waiting") { return "等待对手…"; } if (this.gameStatus === "finished") { return "对局结束"; } return this.mode === "pvp" ? "五子棋 PvP 对战" : "五子棋 AI 对战"; }, /** * 面板副标题。 * * @returns {string} */ get subtitle() { if (this.gameStatus === "idle") { return "选择游戏模式开始"; } if (this.gameStatus === "waiting") { return "已发出对战邀请"; } if (this.gameStatus === "finished") { return "对局已结束"; } return this.isMyTurn ? "● 轮到您落子" : "○ 等待对方落子…"; }, /** * 判断是否轮到当前用户落子。 * * @returns {boolean} */ get isMyTurn() { if (this.mode === "pve") { return this.currentTurn === 1; } return this.currentTurn === this.myColor; }, /** * 当前回合文案。 * * @returns {string} */ get turnText() { if (this.gameStatus === "finished") { return "对局结束"; } return this.currentTurn === 1 ? "● 黑棋回合" : "○ 白棋回合"; }, /** * 从外部打开面板,并尝试恢复未完成对局。 * * @returns {Promise} */ async open() { this.show = true; this.resetToIdle(); this.$nextTick(() => this.initCanvas()); const [config, active] = await Promise.all([ fetchJson(gomokuUrls().config), fetchJson(gomokuUrls().active), ]); this.applyConfig(config); if (active?.has_active) { await this.confirmActiveGame(active); } }, /** * 初始化面板并直接加入对局。 * * @param {number|string} gameId 对局 ID * @returns {Promise} */ async openAndJoin(gameId) { this.show = true; this.$nextTick(async () => { this.initCanvas(); await this.joinGame(gameId); }); }, /** * 关闭面板,对局中需要二次确认以避免误关。 * * @returns {void} */ closePanel() { if (this.gameStatus === "playing") { window.chatDialog?.confirm("对局进行中,关闭面板将不会认输,确定关闭?").then((ok) => { if (ok) { this.cleanUp(); this.show = false; } }); return; } this.cleanUp(); this.show = false; }, /** * 重置到选择模式界面。 * * @returns {void} */ resetToIdle() { this.cleanUp(); this.gameStatus = "idle"; this.gameId = null; this.myColor = null; this.board = emptyBoard(); this.lastMove = null; this.stepCount = 0; this.rewardGold = 0; this.resultEmoji = ""; this.resultText = ""; this.resultGold = 0; this.$nextTick(() => this.redrawBoard()); }, /** * 清理定时器和 WebSocket 监听。 * * @returns {void} */ cleanUp() { clearInterval(this._inviteTimer); clearInterval(this._moveTimer); clearInterval(this._opponentPollTimer); this._inviteTimer = null; this._moveTimer = null; this._opponentPollTimer = null; if (this._echoChannel && window.Echo) { window.Echo.leave(`gomoku.${this.gameId}`); this._echoChannel = null; } }, /** * 发起 PvP 随机对战。 * * @returns {Promise} */ async startPvP() { const roomId = window.chatContext?.roomId; if (!roomId) { return; } const response = await this.post(gomokuUrls().create, { mode: "pvp", room_id: roomId, }); if (!response?.ok) { window.chatDialog?.alert(response?.message || "发起失败,请稍后重试"); return; } this.gameId = response.game_id; this.mode = "pvp"; this.myColor = 1; this.blackName = window.chatContext?.username || "我"; this.whiteName = "等待中…"; this.gameStatus = "waiting"; this.inviteTimeout = DEFAULT_INVITE_TIMEOUT; this.startInviteCountdown(); this.waitForOpponent(); }, /** * 发起 PvE 人机对战。 * * @param {Record} level AI 难度配置 * @returns {Promise} */ async startPvE(level) { const roomId = window.chatContext?.roomId; if (!roomId) { return; } const response = await this.post(gomokuUrls().create, { mode: "pve", room_id: roomId, ai_level: level.id, }); if (!response?.ok) { window.chatDialog?.alert(response?.message || "创建失败,请稍后重试"); return; } this.gameId = response.game_id; this.mode = "pve"; this.myColor = 1; this.blackName = window.chatContext?.username || "我"; this.whiteName = `AI(${level.name})`; this.rewardGold = level.reward; this.board = emptyBoard(); this.currentTurn = 1; this.gameStatus = "playing"; this.$nextTick(() => this.redrawBoard()); }, /** * 加入 PvP 对战。 * * @param {number|string} gameId 对局 ID * @returns {Promise} */ async joinGame(gameId) { const response = await this.post(gomokuGameUrl(gameId, "join"), {}); if (!response?.ok) { window.chatDialog?.alert(response?.message || "加入失败"); this.show = false; return; } await this.syncState(gameId); this.subscribeToGame(gameId); }, /** * 等待对手加入,保留轮询兜底。 * * @returns {void} */ waitForOpponent() { const maxAttempts = 20; let attempts = 0; clearInterval(this._opponentPollTimer); this._opponentPollTimer = setInterval(async () => { if (this.gameStatus !== "waiting") { clearInterval(this._opponentPollTimer); this._opponentPollTimer = null; return; } attempts += 1; if (attempts > maxAttempts) { clearInterval(this._opponentPollTimer); this._opponentPollTimer = null; return; } const response = await fetchJson(gomokuGameUrl(this.gameId, "state")); if (response?.status === "playing") { clearInterval(this._opponentPollTimer); this._opponentPollTimer = null; this.whiteName = response.opponent_name || "对手"; this.currentTurn = response.current_turn; this.gameStatus = "playing"; this.subscribeToGame(this.gameId); this.$nextTick(() => this.redrawBoard()); } }, 3000); }, /** * 同步对局状态。 * * @param {number|string} gameId 对局 ID * @returns {Promise} */ async syncState(gameId) { const response = await fetchJson(gomokuGameUrl(gameId, "state")); if (!response?.ok) { return; } this.gameId = gameId; this.mode = response.mode; this.myColor = response.your_color; this.currentTurn = response.current_turn; this.board = response.board; this.gameStatus = response.status; this.blackName = response.black_name || "黑棋"; this.whiteName = response.white_name || "白棋"; this.rewardGold = response.mode === "pvp" ? DEFAULT_PVP_REWARD : response.reward_gold ?? 0; this.$nextTick(() => this.redrawBoard()); }, /** * 订阅对局私有频道。 * * @param {number|string} gameId 对局 ID * @returns {void} */ subscribeToGame(gameId) { if (!window.Echo) { return; } this._echoChannel = window.Echo.private(`gomoku.${gameId}`) .listen(".gomoku.moved", (event) => { this.onRemoteMove(event); }) .listen(".gomoku.finished", (event) => { this.onGameFinished(event); }); }, /** * 收到远端落子。 * * @param {Record} event 广播事件 * @returns {void} */ onRemoteMove(event) { if (this.mode !== "pvp") { return; } this.board[event.row][event.col] = event.color; this.lastMove = { row: event.row, col: event.col, }; this.currentTurn = event.current_turn; this.stepCount += 1; this.redrawBoard(); }, /** * 收到对局结束广播。 * * @param {Record} event 广播事件 * @returns {void} */ onGameFinished(event) { if (event.game_id !== this.gameId) { return; } this.gameStatus = "finished"; this.showResult(event.winner, event.winner_name, event.reason, event.reward_gold); }, /** * Canvas 点击事件:计算坐标并落子。 * * @param {MouseEvent} event 鼠标事件 * @returns {Promise} */ async handleCanvasClick(event) { if (this.gameStatus !== "playing" || !this.isMyTurn) { return; } const position = this.resolveBoardPosition(event); if (!position || this.board[position.row][position.col] !== 0) { return; } this.placeLocalStone(position.row, position.col, this.myColor); if (this.mode === "pvp") { this.currentTurn = this.myColor === 1 ? 2 : 1; } else { this.currentTurn = 2; } const response = await this.post(gomokuGameUrl(this.gameId, "move"), { row: position.row, col: position.col, }); if (!response?.ok) { this.rollbackLocalStone(position.row, position.col); return; } if (response.finished) { this.gameStatus = "finished"; this.showResult(response.winner, "", response.reason, response.reward_gold); return; } if (this.mode === "pve" && response.ai_moved) { this.applyDelayedAiMove(response.ai_moved); } }, /** * Canvas 鼠标移动:更新悬停预览。 * * @param {MouseEvent} event 鼠标事件 * @returns {void} */ handleCanvasHover(event) { if (this.gameStatus !== "playing" || !this.isMyTurn) { return; } const position = this.resolveBoardPosition(event); if (position && this.board[position.row][position.col] === 0) { this.hoverPos = position; } else { this.hoverPos = null; } this.redrawBoard(); }, /** * 认输。 * * @returns {Promise} */ async resign() { const ok = await window.chatDialog?.confirm("确定认输?").catch(() => false) ?? false; if (!ok) { return; } const response = await this.post(gomokuGameUrl(this.gameId, "resign"), {}); if (response?.finished) { this.gameStatus = "finished"; this.showResult(response.winner, "", "resign", 0); } }, /** * 取消邀请。 * * @returns {Promise} */ async cancelInvite() { await this.post(gomokuGameUrl(this.gameId, "cancel"), {}); clearInterval(this._inviteTimer); this._inviteTimer = null; this.gameStatus = "idle"; }, /** * 显示对局结果。 * * @param {number} winner 获胜颜色,0 表示平局 * @param {string} winnerName 获胜玩家名称 * @param {string} reason 结束原因 * @param {number} gold 奖励金币 * @returns {void} */ 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 尺寸。 * * @returns {void} */ initCanvas() { const canvas = document.getElementById("gomoku-canvas"); if (!canvas) { return; } const size = this.CELL * (BOARD_SIZE - 1) + this.PAD * 2; canvas.width = size; canvas.height = size; this.redrawBoard(); }, /** * 重绘棋盘、棋子、最后落子和悬停预览。 * * @returns {void} */ redrawBoard() { const canvas = document.getElementById("gomoku-canvas"); if (!canvas) { return; } const context = canvas.getContext("2d"); const { CELL, PAD } = this; const size = CELL * (BOARD_SIZE - 1) + PAD * 2; this.drawBoardBase(context, size, CELL, PAD); this.drawStones(context, CELL, PAD); this.drawHoverStone(context, CELL, PAD); }, /** * 封装 POST 请求。 * * @param {string} url 请求地址 * @param {Record} data 请求数据 * @returns {Promise|null>} */ async post(url, data) { try { const response = await fetch(url, { method: "POST", headers: { "X-CSRF-TOKEN": csrfToken(), "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(data), }); return await response.json(); } catch (error) { return null; } }, /** * 应用后台五子棋配置。 * * @param {Record|null} config 后台配置 * @returns {void} */ applyConfig(config) { if (config?.ok && Array.isArray(config.pve_levels)) { this.aiLevels = config.pve_levels.map((level, index) => ({ id: level.level, name: level.name, icon: DEFAULT_AI_ICONS[index] ?? "♟️", reward: level.reward, fee: level.fee, color: DEFAULT_AI_COLORS[index] ?? "#336699", })); this._pvpReward = config.pvp_reward ?? DEFAULT_PVP_REWARD; } }, /** * 有未完成对局时询问用户是否恢复。 * * @param {Record} active 活跃对局数据 * @returns {Promise} */ async confirmActiveGame(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.restoreActiveGame(active); return; } await fetch(gomokuGameUrl(active.game_id, "resign"), { method: "POST", headers: { "X-CSRF-TOKEN": csrfToken(), Accept: "application/json", }, }).catch(() => {}); await window.chatDialog.alert("已认输,对局结束。", "♟️ 对局结束", "#336699"); }, /** * 恢复未完成对局状态。 * * @param {Record} active 活跃对局数据 * @returns {void} */ restoreActiveGame(active) { 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 ?? DEFAULT_PVP_REWARD) : 0; this.$nextTick(() => this.redrawBoard()); if (active.mode === "pvp" && active.status === "playing") { this.subscribeToGame(active.game_id); } }, /** * 启动邀请倒计时。 * * @returns {void} */ startInviteCountdown() { clearInterval(this._inviteTimer); this._inviteTimer = setInterval(() => { this.inviteTimeout -= 1; if (this.inviteTimeout <= 0) { clearInterval(this._inviteTimer); this._inviteTimer = null; if (this.gameStatus === "waiting") { this.gameStatus = "idle"; window.chatDialog?.alert("邀请已超时,无人接受挑战。"); } } }, 1000); }, /** * 计算鼠标在棋盘上的行列。 * * @param {MouseEvent} event 鼠标事件 * @returns {{row: number, col: number}|null} */ resolveBoardPosition(event) { const canvas = document.getElementById("gomoku-canvas"); if (!canvas) { return null; } 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 >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) { return null; } return { row, col }; }, /** * 本地先行落子,等待服务端确认。 * * @param {number} row 行 * @param {number} col 列 * @param {number} color 棋子颜色 * @returns {void} */ placeLocalStone(row, col, color) { this.board[row][col] = color; this.lastMove = { row, col }; this.stepCount += 1; this.redrawBoard(); }, /** * 服务端拒绝落子时回滚本地乐观更新。 * * @param {number} row 行 * @param {number} col 列 * @returns {void} */ rollbackLocalStone(row, col) { this.board[row][col] = 0; this.stepCount -= 1; this.currentTurn = this.myColor; this.redrawBoard(); }, /** * 延迟展示 AI 落子,保留“思考”节奏。 * * @param {{row: number, col: number}} aiMove AI 落子 * @returns {void} */ applyDelayedAiMove(aiMove) { setTimeout(() => { this.board[aiMove.row][aiMove.col] = 2; this.lastMove = { row: aiMove.row, col: aiMove.col, }; this.stepCount += 1; this.currentTurn = 1; this.redrawBoard(); }, 600); }, /** * 绘制棋盘背景、网格、星位和坐标。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} size 棋盘尺寸 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawBoardBase(context, size, cell, pad) { context.fillStyle = "#dcb866"; context.fillRect(0, 0, size, size); context.strokeStyle = "#a0783a"; context.lineWidth = 0.8; for (let index = 0; index < BOARD_SIZE; index += 1) { const position = pad + index * cell; context.beginPath(); context.moveTo(position, pad); context.lineTo(position, pad + (BOARD_SIZE - 1) * cell); context.stroke(); context.beginPath(); context.moveTo(pad, position); context.lineTo(pad + (BOARD_SIZE - 1) * cell, position); context.stroke(); } this.drawStarPoints(context, cell, pad); this.drawCoordinates(context, cell, pad); }, /** * 绘制棋盘星位。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawStarPoints(context, cell, pad) { context.fillStyle = "#7a5c28"; [[3, 3], [3, 11], [11, 3], [11, 11], [7, 7]].forEach(([row, col]) => { context.beginPath(); context.arc(pad + col * cell, pad + row * cell, 3.5, 0, Math.PI * 2); context.fill(); }); }, /** * 绘制棋盘坐标。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawCoordinates(context, cell, pad) { const columns = "ABCDEFGHJKLMNOP"; context.fillStyle = "#7a5c28"; context.font = "9px monospace"; context.textAlign = "center"; context.textBaseline = "middle"; for (let index = 0; index < BOARD_SIZE; index += 1) { context.fillText(columns[index], pad + index * cell, pad - 12); context.fillText(BOARD_SIZE - index, pad - 14, pad + index * cell); } }, /** * 绘制所有棋子和最后落子标记。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawStones(context, cell, pad) { this.board.forEach((row, rowIndex) => { row.forEach((stone, colIndex) => { if (stone === 0) { return; } this.drawStone(context, rowIndex, colIndex, stone, cell, pad); }); }); }, /** * 绘制单枚棋子。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} row 行 * @param {number} col 列 * @param {number} stone 棋子颜色 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawStone(context, row, col, stone, cell, pad) { const x = pad + col * cell; const y = pad + row * cell; const isLast = this.lastMove?.row === row && this.lastMove?.col === col; const gradient = context.createRadialGradient(x - 3, y - 3, 1, x, y, cell * 0.45); if (stone === 1) { gradient.addColorStop(0, "#888"); gradient.addColorStop(1, "#111"); } else { gradient.addColorStop(0, "#fff"); gradient.addColorStop(1, "#ccc"); } context.beginPath(); context.arc(x, y, cell * 0.44, 0, Math.PI * 2); context.fillStyle = gradient; context.fill(); context.strokeStyle = stone === 1 ? "#000" : "#aaa"; context.lineWidth = 0.5; context.stroke(); if (isLast) { context.beginPath(); context.arc(x, y, 4, 0, Math.PI * 2); context.fillStyle = stone === 1 ? "#fff" : "#666"; context.fill(); } }, /** * 绘制悬停预览棋子。 * * @param {CanvasRenderingContext2D} context Canvas 上下文 * @param {number} cell 格子大小 * @param {number} pad 内边距 * @returns {void} */ drawHoverStone(context, cell, pad) { if (!this.hoverPos || !this.isMyTurn || this.gameStatus !== "playing") { return; } const { row, col } = this.hoverPos; if (this.board[row][col] !== 0) { return; } const x = pad + col * cell; const y = pad + row * cell; context.beginPath(); context.arc(x, y, cell * 0.44, 0, Math.PI * 2); context.fillStyle = this.myColor === 1 ? "rgba(0,0,0,0.35)" : "rgba(255,255,255,0.55)"; context.fill(); context.strokeStyle = this.myColor === 1 ? "rgba(0,0,0,.5)" : "rgba(150,150,150,.5)"; context.lineWidth = 1; context.stroke(); }, }; } /** * 挂载五子棋主面板全局组件名,兼容 Blade 的 x-data。 * * @returns {void} */ export function bindGomokuPanelControls() { if (typeof window === "undefined") { return; } window.gomokuPanel = gomokuPanel; }