From 7966c0f66297e4f4998cab5ce6f98b0a0abc772f Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 25 Apr 2026 18:50:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E4=BA=94=E5=AD=90=E6=A3=8B?= =?UTF-8?q?=E4=B8=BB=E9=9D=A2=E6=9D=BF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/chat-room.js | 7 + resources/js/chat-room/gomoku-panel.js | 1001 +++++++++++++++++ .../partials/games/gomoku-panel.blade.php | 676 +---------- 3 files changed, 1017 insertions(+), 667 deletions(-) create mode 100644 resources/js/chat-room/gomoku-panel.js diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index ea7aa81..5e799d1 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -31,6 +31,7 @@ * - game-hall.js:处理娱乐大厅弹窗和游戏入口卡片。 * - game-bootstrap.js:提供非关键游戏延迟初始化工具。 * - game-panels.js:处理通用游戏面板关闭事件。 + * - gomoku-panel.js:提供五子棋主面板 Alpine 组件和 Canvas 棋盘逻辑。 * - gomoku-controls.js:处理五子棋外部打开和接受邀请入口。 * - horse-race-panel.js:提供赛马竞猜主面板 Alpine 组件和下注流程。 * - horse-race-fab.js:处理赛马竞猜悬浮按钮拖动与打开面板。 @@ -116,6 +117,7 @@ export { export { bindGameHallControls, closeGameHall, openGameHall } from "./chat-room/game-hall.js"; export { bindGameBootstrapControls, deferChatGameBootstrap } from "./chat-room/game-bootstrap.js"; export { bindGamePanelControls } from "./chat-room/game-panels.js"; +export { bindGomokuPanelControls, gomokuPanel } from "./chat-room/gomoku-panel.js"; export { acceptGomokuInvite, bindGomokuControls, openGomokuPanel } from "./chat-room/gomoku-controls.js"; export { bindHorseRacePanelControls, horseRacePanel, requestHorseRaceJson } from "./chat-room/horse-race-panel.js"; export { bindHorseRaceFabControls, horseRaceFab } from "./chat-room/horse-race-fab.js"; @@ -285,6 +287,7 @@ import { import { bindGameHallControls, closeGameHall, openGameHall } from "./chat-room/game-hall.js"; import { bindGameBootstrapControls, deferChatGameBootstrap } from "./chat-room/game-bootstrap.js"; import { bindGamePanelControls } from "./chat-room/game-panels.js"; +import { bindGomokuPanelControls, gomokuPanel } from "./chat-room/gomoku-panel.js"; import { acceptGomokuInvite, bindGomokuControls, openGomokuPanel } from "./chat-room/gomoku-controls.js"; import { bindHorseRacePanelControls, horseRacePanel, requestHorseRaceJson } from "./chat-room/horse-race-panel.js"; import { bindHorseRaceFabControls, horseRaceFab } from "./chat-room/horse-race-fab.js"; @@ -462,6 +465,8 @@ if (typeof window !== "undefined") { bindGameBootstrapControls, deferChatGameBootstrap, bindGamePanelControls, + bindGomokuPanelControls, + gomokuPanel, acceptGomokuInvite, bindGomokuControls, openGomokuPanel, @@ -645,6 +650,7 @@ if (typeof window !== "undefined") { window.deferChatGameBootstrap = deferChatGameBootstrap; window.lotteryPanel = lotteryPanel; window.openGameHall = openGameHall; + window.gomokuPanel = gomokuPanel; window.acceptGomokuInvite = acceptGomokuInvite; window.openGomokuPanel = openGomokuPanel; window.horseRacePanel = horseRacePanel; @@ -735,6 +741,7 @@ if (typeof window !== "undefined") { bindGameHallControls(); bindGameBootstrapControls(); bindGamePanelControls(); + bindGomokuPanelControls(); bindGomokuControls(); bindHorseRacePanelControls(); bindHorseRaceFabControls(); diff --git a/resources/js/chat-room/gomoku-panel.js b/resources/js/chat-room/gomoku-panel.js new file mode 100644 index 0000000..c94a862 --- /dev/null +++ b/resources/js/chat-room/gomoku-panel.js @@ -0,0 +1,1001 @@ +// 五子棋主面板 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; +} diff --git a/resources/views/chat/partials/games/gomoku-panel.blade.php b/resources/views/chat/partials/games/gomoku-panel.blade.php index 691db3a..0b7682f 100644 --- a/resources/views/chat/partials/games/gomoku-panel.blade.php +++ b/resources/views/chat/partials/games/gomoku-panel.blade.php @@ -10,7 +10,14 @@ --}} {{-- ─── 五子棋面板遮罩 ─── --}} -
+
@@ -273,670 +280,5 @@ } - +{{-- 五子棋主面板脚本已迁移到 resources/js/chat-room/gomoku-panel.js --}}