前言
继扫雷、俄罗斯方块之后,本文介绍五子棋 AI 对战游戏的开发。相比其他游戏,五子棋的核心难点在于AI 算法的实现:如何在有限时间内找到最优落子位置,同时平衡进攻与防守。
五子棋是一个信息完全公开的零和博弈游戏,理论上可以通过搜索算法找到最优解。但实际应用中,我们需要考虑:
- 搜索空间巨大:15×15 棋盘有 225 个位置,搜索深度每增加一层,复杂度指数级增长
- 时间限制:玩家期望 AI 在几秒内做出决策,不能无限搜索
- 用户体验:AI 需要”合理”地犯错,给玩家获胜的机会
本文重点讲解两个核心问题:
- PJAX 兼容性处理:如何在静态博客中实现流畅的页面切换
- AI 算法设计:如何实现具有不同难度的棋局评估与搜索
项目结构
1 2 3 4 5 6 7 8 9 10 11 12
| source/ai-games/gomoku/ ├── index.md # 游戏页面入口(HTML + CSS) └── modules/ ├── gomoku-config.js # 配置模块(难度、分数常量) ├── gomoku-utils.js # 工具函数(深拷贝、存储) ├── gomoku-core.js # 棋盘核心(状态管理、胜负判定) ├── gomoku-ai.js # AI 引擎(搜索算法、评估函数) ├── gomoku-ui.js # UI 渲染(画布绘制、事件处理) └── gomoku-main.js # 游戏入口(整合模块、流程控制)
source/ai-games/gamejs/ └── game-manager.js # 游戏生命周期管理器
|
为什么需要模块化?
在开始之前,我们需要理解为什么五子棋需要模块化设计,而扫雷可以简单粗暴地写在一个文件里。
| 游戏 |
复杂度 |
AI需求 |
模块化必要性 |
| 扫雷 |
低 |
无 |
简单单文件即可 |
| 俄罗斯方块 |
中 |
无 |
可单文件 |
| 五子棋 |
高 |
复杂AI |
必须模块化 |
五子棋的 AI 代码本身就超过 800 行,加上棋盘逻辑、UI 渲染等,单文件会导致:
- 代码难以维护
- 难以调试 AI 算法
- 难以扩展新功能
模块化架构设计
游戏采用分层模块化设计,将界面、AI、逻辑分离:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ┌─────────────────────────────────────────────────────────┐ │ HTML 页面 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │ │ game-canvas │ │ 难度选择器 │ │ 结果显示区域 │ │ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ GomokuGame (游戏入口) │ │ 职责:整合所有模块、控制游戏流程、管理状态 │ │ ├─ GomokuBoard → 棋盘状态管理、胜负判定 │ │ ├─ GomokuAI → AI 搜索算法、棋型评估 │ │ └─ GomokuUI → 界面渲染、用户交互 │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ GameManager (生命周期管理) │ │ 职责:统一管理游戏注册、初始化、清理 │ │ ├─ register() → 注册游戏到管理器 │ │ ├─ initGame() → 初始化指定游戏 │ │ └─ cleanupGame() → 清理指定游戏资源 │ └─────────────────────────────────────────────────────────┘
|
各模块职责详解
GomokuConfig(配置中心)
- 集中管理所有常量配置
- 难度参数、分数体系、搜索配置
- 避免多个文件重复定义
GomokuCore(棋盘逻辑)
- 二维数组存储棋盘状态
- 落子、悔棋、胜负判定
- 候选位置生成
GomokuAI(智能核心)
- 威胁检测(进攻/防守)
- Minimax 搜索 + Alpha-Beta 剪枝
- 置换表、杀手启发、历史启发
- 棋型评估函数
GomokuUI(用户界面)
- Canvas 绘制棋盘和棋子
- 鼠标/触摸事件处理
- 响应式布局适配
GomokuGame(流程控制)
PJAX 兼容性处理
这是静态博客集成游戏的核心难点。Hexo 博客使用 PJAX 实现无刷新导航,点击链接时只更新页面主体部分,不刷新整个页面。
问题背景
传统网页游戏中,我们通常这样写代码:
1 2 3 4 5 6 7 8 9 10
| let score = 0; let gameState = 'playing';
document.getElementById('btn').addEventListener('click', handleClick);
function init() { ... } init();
|
这在普通网页中没问题,但在 PJAX 环境下会出问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ┌─────────────────────────────────────────────────────────────┐ │ 普通页面导航 │ ├─────────────────────────────────────────────────────────────┤ │ 访问页面A → 完全刷新 → JavaScript重新执行 → 游戏正常 │ │ 访问页面B → 完全刷新 → JavaScript重新执行 → 游戏正常 │ └─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ │ PJAX 页面导航 │ ├─────────────────────────────────────────────────────────────┤ │ 访问页面A → 完全刷新 → 游戏运行 → score=50 │ │ 点击链接 → PJAX替换内容 → 旧脚本还在 → score还是50! │ │ 新脚本执行 → 重复声明报错 → 游戏崩溃 │ └─────────────────────────────────────────────────────────────┘
|
问题场景分析
场景1:全局变量污染
1 2 3 4 5
| window.gomokuGame = new GomokuGame();
window.gomokuGame = new GomokuGame();
|
场景2:事件监听器累积
1 2 3 4 5 6
| button.addEventListener('click', handler);
button.addEventListener('click', handler);
|
场景3:定时器泄漏
1 2 3 4 5
| let timer = setInterval(updateTimer, 1000);
|
场景4:DOM 元素丢失
1 2 3 4 5 6
| const canvas = document.getElementById('game-canvas');
|
解决方案:防重复声明模式
所有模块采用以下模式,防止 PJAX 重载时重复声明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
|
if (!window.GomokuConfig) { window.GomokuConfig = { DIFFICULTY: { easy: { size: 9, depth: 3, timeLimit: 3000, skill: 1 }, normal: { ... }, hard: { ... } }, SCORE: { FIVE: 1000000, OPEN_FOUR: 100000, FOUR: 50000, OPEN_THREE: 10000, }, getDifficulty(level) { return this.DIFFICULTY[level] || this.DIFFICULTY.normal; } }; }
if (typeof window.GomokuAI === 'undefined') { window.GomokuAI = class GomokuAI { constructor(difficulty) { this.config = GomokuConfig.getDifficulty(difficulty); this.transpositionTable = new Map(); } findBestMove(board, player) { } }; }
if (typeof window.GameManager === 'undefined') { window.GameManager = { currentGame: null, games: {}, register(name, options) { ... }, initGame(name) { ... }, cleanupGame(name) { ... } }; }
|
关键点:
- 配置文件用
if (!window.GomokuConfig) 判断
- 类定义用
if (typeof window.GomokuAI === 'undefined') 判断
- 全局管理器同样使用类型判断
GameManager 生命周期管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
| const GameManager = { currentGame: null, games: {},
register(name, options) { this.games[name] = { init: options.init, cleanup: options.cleanup || (() => {}), timerKey: options.timerKey || `_${name}Timer` }; console.log(`[GameManager] 游戏 "${name}" 已注册`); },
initGame(name) { const game = this.games[name]; if (!game) { console.error(`[GameManager] 游戏 "${name}" 未注册`); return; }
if (this.currentGame && this.currentGame !== name) { this.cleanupGame(this.currentGame); }
this.currentGame = name; game.init(); console.log(`[GameManager] 游戏 "${name}" 已初始化`); },
cleanupGame(name) { const game = this.games[name]; if (!game) return;
console.log(`[GameManager] 清理游戏 "${name}"`);
game.cleanup();
if (window[game.timerKey]) { clearInterval(window[game.timerKey]); window[game.timerKey] = null; }
this.removeAllGameEvents(name); },
cleanupAll() { if (this.currentGame) { this.cleanupGame(this.currentGame); this.currentGame = null; } },
detectGame() { const path = window.location.pathname; const canvas = document.getElementById('game-canvas'); if (!canvas) return null; if (path.includes('/gomoku/')) return 'gomoku'; if (path.includes('/snake/')) return 'snake'; if (path.includes('/tetris/')) return 'tetris'; if (path.includes('/minesweeper/')) return 'minesweeper'; return null; },
init() { const self = this; function detectAndInit() { const gameName = self.detectGame(); if (!gameName) { return; } if (!self.games[gameName]) { console.log('[GameManager] 游戏未注册,等待脚本加载...'); setTimeout(detectAndInit, 50); return; } self.initGame(gameName); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(detectAndInit, 50)); } else { setTimeout(detectAndInit, 50); } } };
|
PJAX 事件监听
NexT 主题使用 Pjax 库实现无刷新导航,我们需要监听其事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
document.addEventListener('pjax:before', function() { console.log('[PJAX] 页面即将切换,清理游戏...'); GameManager.cleanupAll(); });
document.addEventListener('pjax:complete', function() { console.log('[PJAX] 页面切换完成,重新检测游戏...'); GameManager.initialized = false; setTimeout(detectAndInit, 100); });
GameManager.init();
|
Canvas 有效性检测
游戏实例需要检测 canvas 是否仍属于当前 DOM:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
|
function initGomokuGame() { console.log('[gomoku] 尝试初始化游戏...'); const requiredModules = [ 'GomokuConfig', 'GomokuUtils', 'GomokuBoard', 'GomokuAI', 'GomokuUI', 'GomokuGame' ]; for (const name of requiredModules) { if (typeof window[name] === 'undefined') { console.error(`[gomoku] 模块未加载: ${name}`); return null; } } console.log('[gomoku] 所有模块已加载');
if (window.gomokuGame) { const ui = window.gomokuGame.ui; if (ui && ui.canvas && document.contains(ui.canvas)) { console.log('[gomoku] Canvas 有效,重置游戏状态'); window.gomokuGame.init(); return window.gomokuGame; } console.log('[gomoku] Canvas 已失效,重建游戏实例'); window.gomokuGame = null; }
try { const activeBtn = document.querySelector('.diff-btn.active'); const difficulty = activeBtn ? activeBtn.dataset.diff : 'normal'; const game = new GomokuGame({ canvasId: 'game-canvas', difficulty: difficulty, playerColor: 1, enableUndo: true, enableHint: false, autoSave: false }); window.gomokuGame = game; bindGameButtons(game); console.log('[gomoku] 游戏创建成功'); return game; } catch (error) { console.error('[gomoku] 游戏创建失败:', error); return null; } }
function bindGameButtons(game) { document.querySelectorAll('.diff-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.diff-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); game.changeDifficulty(btn.dataset.diff); }); });
const restartBtn = document.getElementById('restart-btn'); if (restartBtn) { restartBtn.addEventListener('click', () => { const activeDiff = document.querySelector('.diff-btn.active'); game.restart(activeDiff ? activeDiff.dataset.diff : null); }); }
const undoBtn = document.getElementById('undo-btn'); if (undoBtn) { undoBtn.addEventListener('click', () => game.undo()); } }
function gomokuCleanup() { console.log('[gomoku] 开始清理游戏资源...'); if (window.gomokuGame) { window.gomokuGame.destroy(); window.gomokuGame = null; console.log('[gomoku] 游戏实例已销毁'); } try { localStorage.removeItem('gomoku_current_game'); } catch (e) { } console.log('[gomoku] 清理完成'); }
if (window.GameManager) { window.GameManager.register('gomoku', { init: initGomokuGame, cleanup: gomokuCleanup, timerKey: '_gomokuTimer' }); }
|
UI 状态重置
清理游戏时,必须重置所有 UI 状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
|
cleanup() { console.log('[UI] 开始清理...'); this.clickHandlers.forEach(handler => { this.canvas.removeEventListener('click', handler); this.canvas.removeEventListener('touchstart', handler); this.canvas.removeEventListener('touchend', handler); }); this.clickHandlers = []; this.resizeHandlers.forEach(handler => { window.removeEventListener('resize', handler); }); this.resizeHandlers = []; this.isInitialized = false; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); console.log('[UI] 清理完成'); }
destroy() { console.log('[Game] 开始销毁...'); this.ui.cleanup(); this.state.isPlaying = false; this.state.isThinking = false; this.state.gameResult = null; this.eventHandlers = { onMove: null, onGameStart: null, onGameEnd: null, onAIThink: null, onError: null }; console.log('[Game] 销毁完成'); }
|
标签页切换的 PJAX 兼容
页面上的帮助标签页(规则/操作/难度)也需要处理 PJAX:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
|
(function() { if (window._gomokuTabInitialized) return; window._gomokuTabInitialized = true;
function initTabs() { const tabs = document.querySelectorAll('.help-tab'); const panels = document.querySelectorAll('.help-panel'); if (tabs.length === 0) { console.log('[Tabs] 未找到标签页元素,跳过'); return; } tabs.forEach(tab => { const newTab = tab.cloneNode(true); tab.parentNode.replaceChild(newTab, tab); }); document.querySelectorAll('.help-tab').forEach(tab => { tab.addEventListener('click', function() { const targetId = this.dataset.tab; document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active')); this.classList.add('active'); document.querySelectorAll('.help-panel').forEach(p => p.classList.remove('active')); const targetPanel = document.getElementById('panel-' + targetId); if (targetPanel) { targetPanel.classList.add('active'); } }); }); console.log('[Tabs] 标签页初始化完成'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initTabs); } else { initTabs(); } document.addEventListener('pjax:complete', initTabs); })();
|
AI 算法实现
为什么五子棋需要复杂的 AI?
五子棋的搜索空间极大。以 15×15 棋盘为例:
- 第一步:225 种选择
- 第二步:224 种选择
- …
- 理论上可达 225! 种局面
即使只搜索 4 层,也需要评估 225³ ≈ 1100 万个节点,这还不算后续的剪枝优化。
所以我们需要:
- 减少搜索范围:只评估有意义的候选位置
- 剪枝:Alpha-Beta 剪枝去除无用的搜索分支
- 缓存:置换表避免重复计算
- 启发式:优先搜索可能好的走法
威胁检测(攻防优先级)
AI 的核心思路是:先处理紧急威胁,再进行深度搜索。
为什么需要威胁检测?因为很多局面有”必须走”的棋:
1 2 3 4 5 6 7 8 9
| 场景1:对手四连 . . . ○ ● . . 玩家必须堵住,否则对手下一步获胜 ↑ 这里对手已有4子
场景2:自己有活四 ● ● ● ● . 自己下一步就能赢 ↑ 直接走这里获胜
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
findBestMove(board, player) { this.clearCaches(); const ownWin = this.findWinningThreat(board, player); if (ownWin && ownWin.score >= this.SCORE.FIVE * 0.9) { console.log(`[AI] 发现必胜棋: (${ownWin.row}, ${ownWin.col})`); return ownWin; } const opponentWin = this.checkOpponentHasWinningMove(board, player); if (opponentWin) { console.log(`[AI] 发现对手必胜,强制堵截: (${opponentWin.row}, ${opponentWin.col})`); return opponentWin; } const attackThreat = this.findWinningThreat(board, player); if (attackThreat && attackThreat.score >= this.SCORE.FOUR) { console.log(`[AI] 发现进攻威胁: (${attackThreat.row}, ${attackThreat.col})`); return attackThreat; } const blockThreat = this.findBlockingMove(board, player); if (blockThreat) { console.log(`[AI] 发现需防守威胁: (${blockThreat.row}, ${blockThreat.col})`); return blockThreat; } console.log('[AI] 进入深度搜索...'); if (this.config.useIterativeDeepening) { return this.iterativeDeepening(board, player); } else { return this.useMinimaxSearch(board, player); } }
|
搜索优先级设计:
| 优先级 |
类型 |
分数阈值 |
说明 |
| 1 |
能赢 |
FIVE × 0.9 |
直接获胜,返回即可 |
| 2 |
必堵 |
FIVE |
对手五连,必须防守 |
| 3 |
进攻 |
FOUR |
自己活四/冲四 |
| 4 |
防守 |
OPEN_FOUR |
对手活四/冲四 |
| 5 |
搜索 |
- |
Minimax 深度评估 |
快速威胁评估
威胁检测需要快速判断某位置的价值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
|
quickEvaluateThreat(board, row, col, player) { const directions = [ [0, 1], [1, 0], [1, 1], [1, -1] ]; let maxScore = 0; for (const [dr, dc] of directions) { let count = 1; let openEnds = 0; for (let i = 1; i <= 4; i++) { const r = row + dr * i; const c = col + dc * i; if (r < 0 || r >= board.length || c < 0 || c >= board.length) break; if (board[r][c] === player) { count++; } else if (board[r][c] === 0) { openEnds++; break; } else { break; } } for (let i = 1; i <= 4; i++) { const r = row - dr * i; const c = col - dc * i; if (r < 0 || r >= board.length || c < 0 || c >= board.length) break; if (board[r][c] === player) { count++; } else if (board[r][c] === 0) { openEnds++; break; } else { break; } } if (count >= 5) { return this.SCORE.FIVE; } if (count === 4 && openEnds >= 1) { return this.SCORE.OPEN_FOUR; } if (count === 4) { return this.SCORE.FOUR; } if (count === 3 && openEnds >= 2) { return this.SCORE.OPEN_THREE; } if (count === 3 && openEnds >= 1) { maxScore = Math.max(maxScore, this.SCORE.SLEEP_THREE); } if (count === 2 && openEnds >= 2) { maxScore = Math.max(maxScore, this.SCORE.OPEN_TWO); } } return maxScore; }
|
棋型详解
五子棋的棋型分为以下几种:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ┌────────────────────────────────────────────────────────────────┐ │ 棋型示意图 │ ├────────────────────────────────────────────────────────────────┤ │ │ │ 五连 (FIVE): ★★★★★ - 必胜,五子连成一线 │ │ │ │ 活四 (OPEN_FOUR): ★★★★空 - 两端都空,下一步成五 │ │ │ │ 冲四 (FOUR): ★★★☆★ - 一端被堵,但仍能成五 │ │ ★★★★☆ - 特殊冲四 │ │ ★★★☆☆ - 跳冲四 │ │ │ │ 活三 (OPEN_THREE): ★★★空 - 两端都空,可发展为活四 │ │ │ │ 眠三 (SLEEP_THREE): ★★★☆ - 一端被堵 │ │ │ │ 活二 (OPEN_TWO): ★★空空 - 有发展潜力 │ │ │ └────────────────────────────────────────────────────────────────┘
|
评分为什么这样设计?
1 2 3 4 5 6
| FIVE = 1,000,000 → 必胜,优先选择 OPEN_FOUR = 100,000 → 几乎必胜,下一步就能赢 FOUR = 50,000 → 强攻,但也可能被堵 OPEN_THREE = 10,000 → 进攻性强,值得发展 SLEEP_THREE = 1,000 → 有潜力但较弱 OPEN_TWO = 500 → 布局阶段有用
|
分数差距足够大,确保 AI 做出正确选择。
评估函数
评估函数综合考虑进攻和防守:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
evaluatePoint(board, row, col, player) { if (board[row][col] !== 0) return -Infinity; const opponent = player === 1 ? 2 : 1; let attackScore = 0; let defendScore = 0; board[row][col] = player; if (this.checkWin(board, row, col, player)) { board[row][col] = 0; return this.SCORE.FIVE * 2.0; } const directions = [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of directions) { const line = this.getLine(board, row, col, dr, dc); attackScore += this.analyzePattern(line, player); } board[row][col] = opponent; if (this.checkWin(board, row, col, opponent)) { board[row][col] = 0; return this.SCORE.FIVE * 1.8; } for (const [dr, dc] of directions) { const line = this.getLine(board, row, col, dr, dc); defendScore += this.analyzePattern(line, opponent); } board[row][col] = 0; const attackMultiplier = this.getAttackMultiplier(); const defenseMultiplier = this.getDefenseMultiplier(); const centerDistance = this.getCenterDistance(row, col, board.length); const centerPenalty = centerDistance * this.SEARCH_CONFIG.CENTER_DISTANCE_PENALTY * 10; return attackScore * attackMultiplier + defendScore * defenseMultiplier - centerPenalty; }
getCenterDistance(row, col, boardSize) { const center = (boardSize - 1) / 2; const dx = col - center; const dy = row - center; return Math.sqrt(dx * dx + dy * dy); }
|
Minimax + Alpha-Beta 剪枝
Minimax 是博弈树搜索的基础算法:
- MAX 层:AI 选择分数最高的走法
- MIN 层:对手选择分数最低的走法(对 AI 最不利)
Alpha-Beta 剪枝在 Minimax 基础上剪去不可能产生更好结果的分支:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
|
minimax(board, depth, alpha, beta, isMaximizing, player, maxDepth, startTime) { this.nodesSearched++; if (Date.now() - startTime > this.config.timeLimit) { return this.evaluateBoard(board, player); } const hash = this.generateBoardHash(board, player, depth); const cached = this.transpositionTable.get(hash); if (cached && cached.depth >= depth) { return cached.score; } if (depth === 0) { return this.evaluateBoard(board, player); } const candidates = this.getCandidateMoves(board, this.config.maxCandidates); const ordered = this.orderMoves(board, candidates, player, depth); let bestScore = isMaximizing ? -Infinity : Infinity; let flag = 'EXACT'; for (const move of ordered) { board[move.row][move.col] = player; if (this.checkWin(board, move.row, move.col, player)) { board[move.row][move.col] = 0; const winScore = isMaximizing ? this.SCORE.FIVE * 10 : -this.SCORE.FIVE * 10; return winScore; } const score = this.minimax( board, depth - 1, alpha, beta, !isMaximizing, player === 1 ? 2 : 1, maxDepth, startTime ); board[move.row][move.col] = 0; if (isMaximizing) { if (score > bestScore) { bestScore = score; this.updateKillerMoves(move, depth); this.updateHistoryHeuristic(move, player, depth); } alpha = Math.max(alpha, score); if (beta <= alpha) { flag = 'LOWER'; break; } } else { if (score < bestScore) { bestScore = score; } beta = Math.min(beta, score); if (beta <= alpha) { flag = 'UPPER'; break; } } } this.transpositionTable.set(hash, { score: bestScore, depth: depth, flag: flag }); return bestScore; }
|
Alpha-Beta 剪枝原理图解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| A (MAX层) / | \ B C D /\ /\ /\ ...
假设搜索顺序是 B, C, D:
1. 搜索 B 后:alpha = 10, beta = +∞ A (MAX层) /| B(10) / ...
2. 搜索 C 的子节点: - C1 = 5, C2 = 3, ... - C 的最小值 = 3 - 因为 3 < alpha(10),发生 Alpha 剪枝 - 不需要搜索 C 的剩余节点 A (MAX层) /| \ B(10) C(X) /\ /|\ ... C1 C2 ...
剪枝效果:避免了搜索 C 的多余分支
|
启发式优化
1. 置换表(Transposition Table)
同一棋局可能通过不同顺序到达,置换表避免重复计算:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
generateBoardHash(board, player, depth) { let hash = depth * 31 + player; for (const row of board) { for (const cell of row) { hash = (hash * 31 + cell) & 0x7FFFFFFF; } } return hash % this.TRANSPOSITION_CONFIG.TABLE_SIZE; }
|
2. 杀手启发(Killer Heuristic)
在同一深度产生剪枝的走法,下一深度也可能是好棋:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
updateKillerMoves(move, depth) { if (!this.config.useKillerHeuristic) return; const index = Math.min(depth, this.killerMoves.length - 1); this.killerMoves[index] = { row: move.row, col: move.col }; }
orderMoves(board, moves, player, depth) { return moves.map(move => { let score = this.evaluatePoint(board, move.row, move.col, player); if (this.config.useKillerHeuristic) { for (let i = 0; i < depth && i < this.killerMoves.length; i++) { const killer = this.killerMoves[i]; if (killer && killer.row === move.row && killer.col === move.col) { score += 10000 * (depth - i); } } } if (this.config.useHistoryHeuristic) { const key = `${move.row},${move.col},${player}`; score += this.historyHeuristic.get(key) || 0; } return { move, score }; }).sort((a, b) => b.score - a.score); }
|
3. 历史启发(History Heuristic)
历史上产生好结果的走法,以后也更可能好:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
updateHistoryHeuristic(move, player, depth) { if (!this.config.useHistoryHeuristic) return; const key = `${move.row},${move.col},${player}`; const currentScore = this.historyHeuristic.get(key) || 0; this.historyHeuristic.set(key, currentScore + depth * depth * 10); }
|
候选位置生成
不需要评估所有 225 个位置,只需评估”有意义”的位置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
|
getCandidateMoves(board, maxCount) { const candidates = new Set(); const size = board.length; const skill = this.config.skill; const range = skill === 1 ? 2 : (skill === 2 ? 3 : 4); for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { if (board[r][c] !== 0) { for (let dr = -range; dr <= range; dr++) { for (let dc = -range; dc <= range; dc++) { const nr = r + dr; const nc = c + dc; if (nr >= 0 && nr < size && nc >= 0 && nc < size && board[nr][nc] === 0) { candidates.add(`${nr},${nc}`); } } } } } } if (candidates.size === 0) { const center = Math.floor(size / 2); return [{ row: center, col: center }]; } const moves = Array.from(candidates).map(key => { const [r, c] = key.split(',').map(Number); return { row: r, col: c }; }); return moves.slice(0, maxCount); }
|
提前终止优化
当评估分数足够高时,跳过深度搜索直接返回:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
|
useMinimaxSearch(board, player) { const startTime = Date.now(); const emptyCount = this.countEmptyCells(board); if (emptyCount >= board.length * board.length - 1) { return this.getOpeningMove(board); } const candidates = this.getCandidateMoves(board, this.config.maxCandidates); const scored = candidates.map(m => ({ move: m, score: this.evaluatePoint(board, m.row, m.col, player), threatLevel: this.getMoveThreatLevel(board, m.row, m.col, player) })); scored.sort((a, b) => b.score - a.score); if (scored[0] && scored[0].threatLevel >= this.SCORE.OPEN_FOUR) { console.log('[AI] 发现高威胁,直接返回'); return scored[0].move; } if (scored[0] && scored[1] && scored[0].score >= this.SCORE.OPEN_THREE * 2 && scored[0].score > scored[1].score * 1.5) { console.log('[AI] 最佳分数明显领先,跳过深度搜索'); return scored[0].move; } const topMoves = scored.slice(0, 8); let bestMove = topMoves[0] ? topMoves[0].move : this.getFallbackMove(board); let bestScore = -Infinity; const depth = this.config.depth; for (const { move } of topMoves) { if (Date.now() - startTime > this.config.timeLimit) { console.log('[AI] 超时,停止搜索'); break; } const boardCopy = this.cloneBoard(board); boardCopy[move.row][move.col] = player; if (this.checkWin(boardCopy, move.row, move.col, player)) { return move; } const score = this.minimax( boardCopy, depth - 1, -Infinity, Infinity, false, player === 1 ? 2 : 1, depth, startTime ); if (score > bestScore) { bestScore = score; bestMove = move; } } return bestMove; }
|
难度配置详解
配置参数说明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| DIFFICULTY: { easy: { size: 9, depth: 3, timeLimit: 3000, skill: 1, useIterativeDeepening: false, useKillerHeuristic: false, useHistoryHeuristic: false }, normal: { size: 13, depth: 6, timeLimit: 4000, skill: 2, useIterativeDeepening: false, useKillerHeuristic: true, useHistoryHeuristic: true }, hard: { size: 15, depth: 12, timeLimit: 8000, skill: 3, useIterativeDeepening: true, useKillerHeuristic: true, useHistoryHeuristic: true } }
|
难度对比表
| 参数 |
简单 |
普通 |
困难 |
| 棋盘大小 |
9×9 |
13×13 |
15×15 |
| 搜索深度 |
3层 |
6层 |
12层 |
| 时间限制 |
3秒 |
4秒 |
8秒 |
| 迭代加深 |
✗ |
✗ |
✓ |
| 杀手启发 |
✗ |
✓ |
✓ |
| 历史启发 |
✗ |
✓ |
✓ |
| 邻居范围 |
2格 |
3格 |
4格 |
| 候选数量 |
15 |
25 |
35 |
攻防平衡配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| SEARCH_CONFIG: { ATTACK_MULTIPLIERS: { EASY: 1.2, NORMAL: 1.2, HARD: 1.1 }, DEFENSE_MULTIPLIERS: { EASY: 1.0, NORMAL: 0.7, HARD: 0.6 }, NEIGHBOR_RANGE: { EASY: 2, NORMAL: 3, HARD: 4 } }
|
设计思路:
- 简单模式:防守权重高,AI 更会堵,玩家容易赢
- 普通模式:进攻权重稍高,有挑战性
- 困难模式:进攻权重略低,更重视防守反击
游戏流程图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| ┌─────────────────────────────────────────────────────────────────┐ │ 用户访问页面 │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ GameManager 检测游戏 │ │ document.contains(canvas) → 检测到 gomoku 页面 │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 加载所有模块脚本 │ │ gomoku-config.js → gomoku-utils.js → gomoku-core.js → ... │ │ register('gomoku', { init, cleanup }) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ initGomokuGame() │ │ new GomokuGame() → new GomokuBoard() │ │ → new GomokuAI() │ │ → new GomokuUI() → canvas.init() │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 游戏主循环 │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 玩家落子 │ → │ AI 计算 │ → │ 落子动画 │ │ │ │ makeMove() │ │ findBestMove│ │ drawBoard() │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ │ └───────────────────┴───────────────────┘ │ │ 检查胜负 │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 用户点击"重开"/切换页面 │ │ restart() → board.reset() → clearMessage() │ │ OR │ │ pjax:before → cleanupGame() → destroy() │ └─────────────────────────────────────────────────────────────────┘
|
调试技巧
1. 控制台日志
游戏使用带前缀的日志便于过滤:
1 2 3 4 5 6 7
| console.log('[gomoku] 游戏初始化'); console.log('[gomoku] 难度:', difficulty); console.log('[AI] 搜索节点数:', this.nodesSearched); console.log('[AI] 最佳走法:', bestMove); console.warn('[AI] 搜索超时'); console.error('[AI] 评估出错:', error);
|
2. 调试对象
在控制台可以直接访问:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| window.gomokuGame
window.gomokuGame.board window.gomokuGame.ai window.gomokuGame.ui
window.gomokuGame.ai.getSearchStats()
window.gomokuGame.makeAIMove()
|
3. AI 调试
1 2 3 4 5 6 7 8 9 10
| const boardSnapshot = JSON.stringify(board);
console.log('[AI] 搜索了', this.nodesSearched, '个节点'); console.log('[AI] 置换表命中率:', this.transpositionTable.size / this.nodesSearched * 100, '%');
console.table(scored.slice(0, 5));
|
4. 常见问题排查
| 问题 |
可能原因 |
解决方案 |
| AI 不响应 |
isThinking=true |
检查状态管理 |
| 落子无效 |
位置已有棋子 |
检查 makeMove 返回值 |
| 页面切换后报错 |
事件重复绑定 |
检查防重复模式 |
| AI 太慢 |
搜索太深 |
降低 depth |
总结
五子棋游戏的技术要点:
| 功能 |
实现方式 |
| PJAX 兼容 |
防重复声明 + GameManager 生命周期 + Canvas 有效性检测 |
| 威胁检测 |
五级优先级:必胜→必堵→进攻→防守→搜索 |
| 评估函数 |
棋型评分 + 攻防权重 + 中心距离惩罚 |
| 搜索算法 |
Minimax + Alpha-Beta 剪枝 |
| 性能优化 |
置换表 + 杀手启发 + 历史启发 + 提前终止 |
| 难度区分 |
搜索深度 + 时间限制 + 启发式开关 + 棋盘大小 |
完整代码结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| gomoku/ ├── index.md │ ├── HTML 结构(canvas、按钮) │ ├── CSS 样式(难度选择、状态栏、帮助面板) │ └── 脚本加载(data-pjax) │ └── modules/ ├── gomoku-config.js (~170行) │ ├── DIFFICULTY │ ├── SCORE │ ├── SEARCH_CONFIG │ └── COLORS │ ├── gomoku-utils.js (~150行) │ ├── deepClone() │ ├── debounce/throttle() │ └── localStorage 操作 │ ├── gomoku-core.js (~200行) │ ├── GomokuBoard 类 │ ├── makeMove() │ ├── checkWin() │ └── undoMove() │ ├── gomoku-ai.js (~850行) │ ├── GomokuAI 类 │ ├── findBestMove() │ ├── minimax() │ ├── evaluatePoint() │ └── 启发式优化 │ ├── gomoku-ui.js (~400行) │ ├── GomokuUI 类 │ ├── drawBoard() │ ├── event handlers │ └── cleanup() │ └── gomoku-main.js (~660行) ├── GomokuGame 类 ├── initGomokuGame() ├── gomokuCleanup() └── register()
|
相关链接: