Files
chatroom/resources/js/chat-room/gomoku-panel.js
T
2026-04-25 18:50:05 +08:00

1002 lines
31 KiB
JavaScript
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.
// 五子棋主面板 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<Record<string, any>|null>}
*/
async function fetchJson(url) {
return fetch(url).then((response) => response.json()).catch(() => null);
}
/**
* 创建五子棋面板 Alpine 组件。
*
* @returns {Record<string, any>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<string, any>} level AI 难度配置
* @returns {Promise<void>}
*/
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<void>}
*/
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<void>}
*/
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<string, any>} 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<string, any>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<string, any>} data 请求数据
* @returns {Promise<Record<string, any>|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<string, any>|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<string, any>} active 活跃对局数据
* @returns {Promise<void>}
*/
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<string, any>} 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;
}