1002 lines
31 KiB
JavaScript
1002 lines
31 KiB
JavaScript
// 五子棋主面板 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;
|
||
}
|