迁移五子棋主面板脚本

This commit is contained in:
2026-04-25 18:50:05 +08:00
parent 1f1c329085
commit 7966c0f662
3 changed files with 1017 additions and 667 deletions
+7
View File
@@ -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();
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,14 @@
--}}
{{-- ─── 五子棋面板遮罩 ─── --}}
<div id="gomoku-panel" x-data="gomokuPanel()" x-show="show" x-cloak>
<div id="gomoku-panel"
x-data="gomokuPanel()"
x-show="show"
x-cloak
data-gomoku-base-url="/gomoku"
data-gomoku-config-url="{{ route('gomoku.config') }}"
data-gomoku-active-url="{{ route('gomoku.active') }}"
data-gomoku-create-url="{{ route('gomoku.create') }}">
<div
style="position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:9940;
display:flex; align-items:center; justify-content:center;">
@@ -273,670 +280,5 @@
}
</style>
<script>
/**
* 五子棋面板 Alpine.js 组件
*/
function gomokuPanel() {
return {
// ─── 基础状态 ───
show: false,
mode: 'pvp', // 'pvp' | 'pve'
gameStatus: 'idle', // 'idle' | 'waiting' | 'playing' | 'finished'
gameId: null,
myColor: null, // 1=黑 2=白
// ─── 玩家信息 ───
blackName: '',
whiteName: '',
currentTurn: 1, // 1=黑 2=白
stepCount: 0,
// ─── AI 配置 ───
aiLevels: [{
id: 1,
name: '简单',
icon: '🟢',
reward: 20,
fee: 0,
color: '#16a34a'
},
{
id: 2,
name: '普通',
icon: '🟡',
reward: 50,
fee: 10,
color: '#ca8a04'
},
{
id: 3,
name: '困难',
icon: '🔴',
reward: 120,
fee: 30,
color: '#dc2626'
},
{
id: 4,
name: '专家',
icon: '⚡',
reward: 300,
fee: 80,
color: '#7c3aed'
},
],
// ─── 棋盘 ───
board: [], // 15×15 矩阵
hoverPos: null, // {row, col} 悬停位置
lastMove: null, // {row, col} 最后落子(高亮用)
CELL: 30, // 格子像素大小
PAD: 22, // 棋盘内边距
// ─── 对局结果 ───
rewardGold: 0,
resultEmoji: '',
resultText: '',
resultGold: 0,
// ─── 超时计时器 ───
inviteTimeout: 60,
moveTimeout: 0,
_inviteTimer: null,
_moveTimer: null,
// ─── WebSocket 监听器 ───
_echoChannel: null,
// PvP 胜利奖励(open() 后从接口更新)
_pvpReward: 80,
// ─── 计算属性 ───
get title() {
if (this.gameStatus === 'idle') return '五子棋';
if (this.gameStatus === 'waiting') return '等待对手…';
if (this.gameStatus === 'finished') return '对局结束';
return this.mode === 'pvp' ? '五子棋 PvP 对战' : '五子棋 AI 对战';
},
get subtitle() {
if (this.gameStatus === 'idle') return '选择游戏模式开始';
if (this.gameStatus === 'waiting') return '已发出对战邀请';
if (this.gameStatus === 'finished') return '对局已结束';
return this.isMyTurn ? '● 轮到您落子' : '○ 等待对方落子…';
},
get isMyTurn() {
if (this.mode === 'pve') return this.currentTurn === 1;
return this.currentTurn === this.myColor;
},
get turnText() {
if (this.gameStatus === 'finished') return '对局结束';
return this.currentTurn === 1 ? '● 黑棋回合' : '○ 白棋回合';
},
// ─── 打开/关闭 ───
/** 从外部打开面板 */
async open() {
this.show = true;
this.resetToIdle();
this.$nextTick(() => this.initCanvas());
// ① 并行加载后台配置 + 检查活跃对局
const [cfg, active] = await Promise.all([
fetch('/gomoku/config').then(r => r.json()).catch(() => null),
fetch('/gomoku/active').then(r => r.json()).catch(() => null),
]);
// 更新 AI 难度列表(后台配置)
if (cfg?.ok && Array.isArray(cfg.pve_levels)) {
const icons = ['🟢', '🟡', '🔴', '⚡'];
const colors = ['#16a34a', '#ca8a04', '#dc2626', '#7c3aed'];
this.aiLevels = cfg.pve_levels.map((lv, i) => ({
id: lv.level,
name: lv.name,
icon: icons[i] ?? '♟️',
reward: lv.reward,
fee: lv.fee,
color: colors[i] ?? '#336699',
}));
this._pvpReward = cfg.pvp_reward ?? 80;
}
// ② 如有进行中对局,弹出恢复选择(全局弹窗)
if (active?.has_active) {
const modeLabel = active.mode === 'pve' ?
`人机对战(${active.white_name}` :
`PvP 对战(${active.black_name} vs ${active.white_name}`;
const resume = await window.chatDialog.confirm(
`您有一局未完成的${modeLabel},确定继续吗?\n取消则直接认输并结束本局。`,
'♟️ 恢复对局',
'#1e3a5f'
);
if (resume) {
// 继续对局:恢复棋盘状态
this.gameId = active.game_id;
this.mode = active.mode;
this.myColor = active.your_color;
this.currentTurn = active.current_turn;
this.board = active.board;
this.blackName = active.black_name;
this.whiteName = active.white_name;
this.gameStatus = active.status === 'waiting' ? 'waiting' : 'playing';
this.rewardGold = active.mode === 'pvp' ? (this._pvpReward ?? 80) : 0;
this.$nextTick(() => this.redrawBoard());
// PvP 恢复后重新订阅私有频道
if (active.mode === 'pvp' && active.status === 'playing') {
this.subscribeToGame(active.game_id);
}
} else {
// 认输:调接口后提示
await fetch(`/gomoku/${active.game_id}/resign`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
?.content ?? '',
'Accept': 'application/json',
},
}).catch(() => {});
await window.chatDialog.alert('已认输,对局结束。', '♟️ 对局结束', '#336699');
}
}
},
/** 初始化面板并直接加入对局(被邀请方) */
async openAndJoin(gameId) {
this.show = true;
this.$nextTick(async () => {
this.initCanvas();
await this.joinGame(gameId);
});
},
/** 关闭面板 */
closePanel() {
// 对局中时不能直接关闭(防误操作)
if (this.gameStatus === 'playing') {
window.chatDialog?.confirm('对局进行中,关闭面板将不会认输,确定关闭?').then(ok => {
if (ok) {
this.cleanUp();
this.show = false;
}
});
return;
}
this.cleanUp();
this.show = false;
},
/** 重置到选择模式界面 */
resetToIdle() {
this.cleanUp();
this.gameStatus = 'idle';
this.gameId = null;
this.myColor = null;
this.board = Array.from({
length: 15
}, () => Array(15).fill(0));
this.lastMove = null;
this.stepCount = 0;
this.rewardGold = 0;
this.resultEmoji = '';
this.resultText = '';
this.resultGold = 0;
this.$nextTick(() => this.redrawBoard());
},
/** 清理定时器和 WebSocket 监听 */
cleanUp() {
clearInterval(this._inviteTimer);
clearInterval(this._moveTimer);
if (this._echoChannel && window.Echo) {
window.Echo.leave(`gomoku.${this.gameId}`);
this._echoChannel = null;
}
},
// ─── 游戏开始 ───
/** 发起 PvP 随机对战 */
async startPvP() {
const roomId = window.chatContext?.roomId;
if (!roomId) return;
const res = await this.post('/gomoku/create', {
mode: 'pvp',
room_id: roomId
});
if (!res?.ok) {
window.chatDialog?.alert(res?.message || '发起失败,请稍后重试');
return;
}
this.gameId = res.game_id;
this.mode = 'pvp';
this.myColor = 1; // 发起方执黑
this.blackName = window.chatContext?.username || '我';
this.whiteName = '等待中…';
this.gameStatus = 'waiting';
this.inviteTimeout = 60;
// 启动邀请倒计时
this._inviteTimer = setInterval(() => {
this.inviteTimeout--;
if (this.inviteTimeout <= 0) {
clearInterval(this._inviteTimer);
if (this.gameStatus === 'waiting') {
this.gameStatus = 'idle';
window.chatDialog?.alert('邀请已超时,无人接受挑战。');
}
}
}, 1000);
// 监听对手加入事件(通过房间频道广播触发)
this.waitForOpponent();
},
/** 发起 PvE 人机对战 */
async startPvE(level) {
const roomId = window.chatContext?.roomId;
if (!roomId) return;
const res = await this.post('/gomoku/create', {
mode: 'pve',
room_id: roomId,
ai_level: level.id,
});
if (!res?.ok) {
window.chatDialog?.alert(res?.message || '创建失败,请稍后重试');
return;
}
this.gameId = res.game_id;
this.mode = 'pve';
this.myColor = 1;
this.blackName = window.chatContext?.username || '我';
this.whiteName = `AI${level.name}`;
this.rewardGold = level.reward;
this.board = Array.from({
length: 15
}, () => Array(15).fill(0));
this.currentTurn = 1;
this.gameStatus = 'playing';
this.$nextTick(() => this.redrawBoard());
},
/** 加入 PvP 对战(接受邀请方) */
async joinGame(gameId) {
const res = await this.post(`/gomoku/${gameId}/join`, {});
if (!res?.ok) {
window.chatDialog?.alert(res?.message || '加入失败');
this.show = false;
return;
}
// 重新获取对局状态
await this.syncState(gameId);
this.subscribeToGame(gameId);
},
/** 等待对手通过轮询或 WebSocket 通知加入 */
waitForOpponent() {
// 每 3 秒轮询一次对局状态
const maxAttempts = 20;
let attempts = 0;
const poll = setInterval(async () => {
if (this.gameStatus !== 'waiting') {
clearInterval(poll);
return;
}
attempts++;
if (attempts > maxAttempts) {
clearInterval(poll);
return;
}
const res = await fetch(`/gomoku/${this.gameId}/state`).then(r => r.json()).catch(() =>
null);
if (res?.status === 'playing') {
clearInterval(poll);
this.whiteName = res.opponent_name || '对手';
this.currentTurn = res.current_turn;
this.gameStatus = 'playing';
this.subscribeToGame(this.gameId);
this.$nextTick(() => this.redrawBoard());
}
}, 3000);
},
/** 同步对局状态(连接时)*/
async syncState(gameId) {
const res = await fetch(`/gomoku/${gameId}/state`).then(r => r.json()).catch(() => null);
if (!res?.ok) return;
this.gameId = gameId;
this.mode = res.mode;
this.myColor = res.your_color;
this.currentTurn = res.current_turn;
this.board = res.board;
this.gameStatus = res.status;
this.blackName = res.black_name || '黑棋';
this.whiteName = res.white_name || '白棋';
this.rewardGold = res.mode === 'pvp' ? 80 : res.reward_gold ?? 0;
this.$nextTick(() => this.redrawBoard());
},
// ─── WebSocket 监听 ───
/** 订阅对局私有频道 */
subscribeToGame(gameId) {
if (!window.Echo) return;
this._echoChannel = window.Echo.private(`gomoku.${gameId}`)
.listen('.gomoku.moved', (e) => {
this.onRemoteMove(e);
})
.listen('.gomoku.finished', (e) => {
this.onGameFinished(e);
});
},
/** 收到远端落子 */
onRemoteMove(e) {
// 仅 PvP 模式会通过 WebSocket 接收对方落子
if (this.mode !== 'pvp') return;
this.board[e.row][e.col] = e.color;
this.lastMove = {
row: e.row,
col: e.col
};
this.currentTurn = e.current_turn;
this.stepCount++;
this.redrawBoard();
},
/** 游戏结束通知(WebSocket*/
onGameFinished(e) {
if (e.game_id !== this.gameId) return;
this.gameStatus = 'finished';
this.showResult(e.winner, e.winner_name, e.reason, e.reward_gold);
},
// ─── 落子逻辑 ───
/** Canvas 点击事件:计算坐标并落子 */
async handleCanvasClick(event) {
if (this.gameStatus !== 'playing' || !this.isMyTurn) return;
const canvas = document.getElementById('gomoku-canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.round((x - this.PAD) / this.CELL);
const row = Math.round((y - this.PAD) / this.CELL);
if (row < 0 || row >= 15 || col < 0 || col >= 15) return;
if (this.board[row][col] !== 0) return;
// 乐观更新(先本地显示,等服务端响应)
this.board[row][col] = this.myColor;
this.lastMove = {
row,
col
};
this.stepCount++;
this.redrawBoard();
// 切换回合(PvP 时前端先切,等服务端广播校正)
if (this.mode === 'pvp') {
this.currentTurn = this.myColor === 1 ? 2 : 1;
} else {
// PvE 不立即切换,等 AI 落子返回
this.currentTurn = 2;
}
const res = await this.post(`/gomoku/${this.gameId}/move`, {
row,
col
});
if (!res?.ok) {
// 落子被拒:回滚
this.board[row][col] = 0;
this.stepCount--;
this.currentTurn = this.myColor;
this.redrawBoard();
return;
}
if (res.finished) {
this.gameStatus = 'finished';
this.showResult(res.winner, '', res.reason, res.reward_gold);
return;
}
// PvE:处理 AI 落子
if (this.mode === 'pve' && res.ai_moved) {
// 延迟 600ms 展示 AI "思考"效果
setTimeout(() => {
const ai = res.ai_moved;
this.board[ai.row][ai.col] = 2;
this.lastMove = {
row: ai.row,
col: ai.col
};
this.stepCount++;
this.currentTurn = 1;
this.redrawBoard();
}, 600);
}
},
/** Canvas 鼠标移动:悬停预览 */
handleCanvasHover(event) {
if (this.gameStatus !== 'playing' || !this.isMyTurn) return;
const canvas = document.getElementById('gomoku-canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.round((x - this.PAD) / this.CELL);
const row = Math.round((y - this.PAD) / this.CELL);
if (row >= 0 && row < 15 && col >= 0 && col < 15 && this.board[row][col] === 0) {
this.hoverPos = {
row,
col
};
} else {
this.hoverPos = null;
}
this.redrawBoard();
},
// ─── 其他操作 ───
/** 认输 */
async resign() {
const ok = await window.chatDialog?.confirm('确定认输?').catch(() => false) ?? false;
if (!ok) return;
const res = await this.post(`/gomoku/${this.gameId}/resign`, {});
if (res?.finished) {
this.gameStatus = 'finished';
this.showResult(res.winner, '', 'resign', 0);
}
},
/** 取消邀请 */
async cancelInvite() {
await this.post(`/gomoku/${this.gameId}/cancel`, {});
clearInterval(this._inviteTimer);
this.gameStatus = 'idle';
},
/** 显示对局结果 */
showResult(winner, winnerName, reason, gold) {
const isWinner = (winner === this.myColor);
const isDraw = (winner === 0);
this.resultGold = gold ?? 0;
if (isDraw) {
this.resultEmoji = '🤝';
this.resultText = '平局!势均力敌';
} else if (reason === 'resign') {
this.resultEmoji = isWinner ? '🏆' : '😔';
this.resultText = isWinner ? '对手认输,您获胜!' : '您认输了';
} else {
this.resultEmoji = isWinner ? '🏆' : '😔';
this.resultText = isWinner ?
(this.mode === 'pve' ? '恭喜!您击败了 AI' : '恭喜!您获胜了!') :
(this.mode === 'pve' ? 'AI 获胜,再接再厉!' : '惜败,再来一局!');
}
if (isWinner && gold > 0 && window.chatContext) {
window.chatContext.userJjb = (window.chatContext.userJjb ?? 0) + gold;
}
},
// ─── Canvas 绘制 ───
/** 初始化 Canvas 尺寸 */
initCanvas() {
const canvas = document.getElementById('gomoku-canvas');
if (!canvas) return;
const size = this.CELL * 14 + this.PAD * 2;
canvas.width = size;
canvas.height = size;
this.redrawBoard();
},
/** 重绘棋盘 */
redrawBoard() {
const canvas = document.getElementById('gomoku-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const {
CELL,
PAD
} = this;
const SIZE = CELL * 14 + PAD * 2;
// 棋盘背景
ctx.fillStyle = '#dcb866';
ctx.fillRect(0, 0, SIZE, SIZE);
// 棋盘格线
ctx.strokeStyle = '#a0783a';
ctx.lineWidth = 0.8;
for (let i = 0; i < 15; i++) {
const x = PAD + i * CELL;
ctx.beginPath();
ctx.moveTo(x, PAD);
ctx.lineTo(x, PAD + 14 * CELL);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(PAD, x);
ctx.lineTo(PAD + 14 * CELL, x);
ctx.stroke();
}
// 星位(天元 + 四角星)
const starPoints = [
[3, 3],
[3, 11],
[11, 3],
[11, 11],
[7, 7]
];
ctx.fillStyle = '#7a5c28';
starPoints.forEach(([r, c]) => {
ctx.beginPath();
ctx.arc(PAD + c * CELL, PAD + r * CELL, 3.5, 0, Math.PI * 2);
ctx.fill();
});
// 坐标标记
const cols = 'ABCDEFGHJKLMNOP';
ctx.fillStyle = '#7a5c28';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < 15; i++) {
ctx.fillText(cols[i], PAD + i * CELL, PAD - 12);
ctx.fillText(15 - i, PAD - 14, PAD + i * CELL);
}
// 棋子
this.board.forEach((row, r) => {
row.forEach((cell, c) => {
if (cell === 0) return;
const x = PAD + c * CELL;
const y = PAD + r * CELL;
const isLast = this.lastMove?.row === r && this.lastMove?.col === c;
// 棋子渐变(立体感)
const grad = ctx.createRadialGradient(x - 3, y - 3, 1, x, y, CELL * 0.45);
if (cell === 1) {
grad.addColorStop(0, '#888');
grad.addColorStop(1, '#111');
} else {
grad.addColorStop(0, '#fff');
grad.addColorStop(1, '#ccc');
}
ctx.beginPath();
ctx.arc(x, y, CELL * 0.44, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = cell === 1 ? '#000' : '#aaa';
ctx.lineWidth = 0.5;
ctx.stroke();
// 最后落子标记
if (isLast) {
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = cell === 1 ? '#fff' : '#666';
ctx.fill();
}
});
});
// 悬停预览棋子(半透明)
if (this.hoverPos && this.isMyTurn && this.gameStatus === 'playing') {
const {
row: hr,
col: hc
} = this.hoverPos;
if (this.board[hr][hc] === 0) {
const hx = PAD + hc * CELL;
const hy = PAD + hr * CELL;
ctx.beginPath();
ctx.arc(hx, hy, CELL * 0.44, 0, Math.PI * 2);
ctx.fillStyle = this.myColor === 1 ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.55)';
ctx.fill();
ctx.strokeStyle = this.myColor === 1 ? 'rgba(0,0,0,.5)' : 'rgba(150,150,150,.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
},
// ─── 网络请求辅助 ───
/** 封装 POST 请求 */
async post(url, data) {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(data),
});
return await res.json();
} catch {
return null;
}
},
};
}
{{-- 五子棋外部打开入口已迁移到 resources/js/chat-room/gomoku-controls.js --}}
</script>
{{-- 五子棋主面板脚本已迁移到 resources/js/chat-room/gomoku-panel.js --}}