Hexo 博客实战:五子棋小游戏开发指南

前言

继扫雷、俄罗斯方块之后,本文介绍五子棋 AI 对战游戏的开发。相比其他游戏,五子棋的核心难点在于AI 算法的实现:如何在有限时间内找到最优落子位置,同时平衡进攻与防守。

五子棋是一个信息完全公开的零和博弈游戏,理论上可以通过搜索算法找到最优解。但实际应用中,我们需要考虑:

  1. 搜索空间巨大:15×15 棋盘有 225 个位置,搜索深度每增加一层,复杂度指数级增长
  2. 时间限制:玩家期望 AI 在几秒内做出决策,不能无限搜索
  3. 用户体验:AI 需要”合理”地犯错,给玩家获胜的机会

本文重点讲解两个核心问题:

  1. PJAX 兼容性处理:如何在静态博客中实现流畅的页面切换
  2. 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(); // gomokuGame 被创建

// PJAX 切换后再次加载
window.gomokuGame = new GomokuGame(); // 报错:Identifier 'gomokuGame' already declared

场景2:事件监听器累积

1
2
3
4
5
6
// 第一次加载
button.addEventListener('click', handler);

// PJAX 切换后再次加载
button.addEventListener('click', handler); // 事件被添加两次
// 用户点击一次 → handler 执行两次

场景3:定时器泄漏

1
2
3
4
5
// 开始游戏
let timer = setInterval(updateTimer, 1000);

// PJAX 切换时没有清理
// timer 还在运行,消耗资源

场景4:DOM 元素丢失

1
2
3
4
5
6
// 获取 canvas
const canvas = document.getElementById('game-canvas');

// PJAX 切换后
// canvas 元素被替换成新的 DOM
// 旧的 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
// ============================================
// gomoku-config.js - 配置文件
// ============================================
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;
}
};
}

// ============================================
// gomoku-ai.js - 类定义
// ============================================
if (typeof window.GomokuAI === 'undefined') {
window.GomokuAI = class GomokuAI {
constructor(difficulty) {
this.config = GomokuConfig.getDifficulty(difficulty);
this.transpositionTable = new Map();
// ...
}

findBestMove(board, player) {
// AI 算法实现
}
};
}

// ============================================
// game-manager.js - 全局管理器
// ============================================
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: {}, // 已注册的游戏列表

/**
* 注册游戏到管理器
* @param {string} name - 游戏名称,如 'gomoku'
* @param {object} options - 游戏选项
* @param {function} options.init - 初始化函数
* @param {function} options.cleanup - 清理函数
* @param {string} options.timerKey - 定时器变量名
*/
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}" 已初始化`);
},

/**
* 清理指定游戏的资源
* 调用游戏的 cleanup 函数,清理定时器,移除事件监听
*/
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;
},

/**
* 初始化管理器
* 在页面加载和 PJAX 切换时调用
*/
init() {
const self = this;

function detectAndInit() {
const gameName = self.detectGame();
if (!gameName) {
return; // 不是游戏页面,不做处理
}

// 检查游戏是否已注册
if (!self.games[gameName]) {
console.log('[GameManager] 游戏未注册,等待脚本加载...');
setTimeout(detectAndInit, 50); // 等待 50ms 后重试
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
// ============================================
// pjax:before - 页面切换前触发
// 在这个事件中清理游戏资源
// ============================================
document.addEventListener('pjax:before', function() {
console.log('[PJAX] 页面即将切换,清理游戏...');
GameManager.cleanupAll();
});

// ============================================
// pjax:complete - PJAX 替换完成后触发
// 在这个事件中重新初始化游戏
// ============================================
document.addEventListener('pjax:complete', function() {
console.log('[PJAX] 页面切换完成,重新检测游戏...');
// 重置初始化标志
GameManager.initialized = false;
// 延迟一点执行,确保 DOM 已更新
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
/**
* 五子棋游戏初始化入口
* 供 GameManager 调用
*/
function initGomokuGame() {
console.log('[gomoku] 尝试初始化游戏...');

// 1. 检查所有必需模块是否已加载
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] 所有模块已加载');

// 2. 检查是否已有游戏实例
if (window.gomokuGame) {
const ui = window.gomokuGame.ui;

// 3. 检查 canvas 是否仍然有效
if (ui && ui.canvas && document.contains(ui.canvas)) {
// canvas 有效,可能是同页面导航
// 只需要重置游戏状态
console.log('[gomoku] Canvas 有效,重置游戏状态');
window.gomokuGame.init();
return window.gomokuGame;
}

// canvas 已失效(旧 DOM 元素),需要重建
console.log('[gomoku] Canvas 已失效,重建游戏实例');
window.gomokuGame = null;
}

// 4. 创建新游戏实例
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());
}
}

/**
* 清理游戏资源
* 供 GameManager 在页面切换时调用
*/
function gomokuCleanup() {
console.log('[gomoku] 开始清理游戏资源...');

if (window.gomokuGame) {
// 销毁游戏实例
window.gomokuGame.destroy();
window.gomokuGame = null;
console.log('[gomoku] 游戏实例已销毁');
}

// 清理 localStorage(如果有保存的游戏)
try {
localStorage.removeItem('gomoku_current_game');
} catch (e) {
// localStorage 可能被禁用,忽略错误
}

console.log('[gomoku] 清理完成');
}

// ============================================
// 向 GameManager 注册游戏
// ============================================
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
// ============================================
// GomokuUI.cleanup() - 清理 UI 资源
// ============================================
cleanup() {
console.log('[UI] 开始清理...');

// 1. 移除所有事件监听器
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 = [];

// 2. 重置初始化标志
this.isInitialized = false;

// 3. 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

console.log('[UI] 清理完成');
}

// ============================================
// GomokuGame.destroy() - 销毁游戏实例
// ============================================
destroy() {
console.log('[Game] 开始销毁...');

// 1. 清理 UI
this.ui.cleanup();

// 2. 重置游戏状态
this.state.isPlaying = false;
this.state.isThinking = false;
this.state.gameResult = null;

// 3. 清空事件处理器
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
// ============================================
// 使用 IIFE + 标志位防止重复绑定
// ============================================
(function() {
// 防止重复初始化
if (window._gomokuTabInitialized) return;
window._gomokuTabInitialized = true;

/**
* 初始化标签页
* 会在首次加载和 PJAX 完成后调用
*/
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();
}

// PJAX 页面切换完成
document.addEventListener('pjax:complete', initTabs);
})();

AI 算法实现

为什么五子棋需要复杂的 AI?

五子棋的搜索空间极大。以 15×15 棋盘为例:

  • 第一步:225 种选择
  • 第二步:224 种选择
  • 理论上可达 225! 种局面

即使只搜索 4 层,也需要评估 225³ ≈ 1100 万个节点,这还不算后续的剪枝优化。

所以我们需要:

  1. 减少搜索范围:只评估有意义的候选位置
  2. 剪枝:Alpha-Beta 剪枝去除无用的搜索分支
  3. 缓存:置换表避免重复计算
  4. 启发式:优先搜索可能好的走法

威胁检测(攻防优先级)

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
/**
* 寻找最佳落子位置
* 这是 AI 的主入口,被 GomokuGame 调用
*/
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;
}

// ========== 第五优先级:深度搜索 ==========
// 没有紧急威胁,进入 Minimax 搜索
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
/**
* 快速评估某位置的威胁等级
* 只检查当前落子位置能形成的棋型,不深入搜索
*
* @param {number[][]} board - 棋盘状态
* @param {number} row - 行索引
* @param {number} col - 列索引
* @param {number} player - 棋子颜色 (1: 黑, 2: 白)
* @returns {number} 威胁分数
*/
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; // 两端的空位数量

// 正向延伸(最多4格)
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
/**
* 评估某位置的综合价值
* 这是 Minimax 搜索的叶子节点评估函数
*/
evaluatePoint(board, row, col, player) {
// 跳过已有棋子的位置
if (board[row][col] !== 0) return -Infinity;

const opponent = player === 1 ? 2 : 1;
let attackScore = 0; // 进攻分数
let defendScore = 0; // 防守分数

// ========== 模拟落子,评估效果 ==========

// 1. 检查落子后是否能赢
board[row][col] = player;
if (this.checkWin(board, row, col, player)) {
board[row][col] = 0;
return this.SCORE.FIVE * 2.0; // 能赢,返回极高分数
}

// 2. 计算进攻分数(沿四个方向)
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);
}

// 3. 检查对手是否能在这里落子后赢(防守必要性)
board[row][col] = opponent;
if (this.checkWin(board, row, col, opponent)) {
board[row][col] = 0;
return this.SCORE.FIVE * 1.8; // 必须堵截
}

// 4. 计算防守分数
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 搜索 + Alpha-Beta 剪枝
*
* @param {number[][]} board - 棋盘状态
* @param {number} depth - 剩余搜索深度
* @param {number} alpha - MAX 层的下限
* @param {number} beta - MIN 层的上限
* @param {boolean} isMaximizing - 是否是 MAX 层
* @param {number} player - 当前落子方
* @param {number} maxDepth - 最大搜索深度(用于置换表)
* @param {number} startTime - 开始时间(用于超时控制)
*/
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'; // Beta 剪枝
break;
}
} else {
if (score < bestScore) {
bestScore = score;
}
beta = Math.min(beta, score);
if (beta <= alpha) {
flag = 'UPPER'; // Alpha 剪枝
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) {
// 使用 & 0x7FFFFFFF 防止整数溢出
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
/**
* 更新历史启发表
* 每次产生 Beta 剪枝(失败)时调用
*/
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) {
// 收集周围 range 格内的空位
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
/**
* Minimax 搜索入口
*/
useMinimaxSearch(board, player) {
const startTime = Date.now();

// 开局检测
const emptyCount = this.countEmptyCells(board);
if (emptyCount >= board.length * board.length - 1) {
return this.getOpeningMove(board);
}

// 前置威胁检测(已在 findBestMove 中处理)

// 生成候选走法
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); // 只搜索前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;
}

// Minimax 搜索
const score = this.minimax(
boardCopy,
depth - 1,
-Infinity,
Infinity,
false, // 对方是 MIN 层
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, // 9×9 小棋盘,搜索空间小
depth: 3, // 搜索深度3层
timeLimit: 3000, // 3秒超时
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, // 只看周围2格
NORMAL: 3, // 看周围3格
HARD: 4 // 看周围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 // AI 实例
window.gomokuGame.ui // UI 实例

// AI 统计
window.gomokuGame.ai.getSearchStats()
// 返回: { nodesSearched: 12345, transpositionTableSize: 5000, ... }

// 手动触发 AI
window.gomokuGame.makeAIMove()

3. AI 调试

1
2
3
4
5
6
7
8
9
10
// 在 AI 搜索前记录棋盘
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()

相关链接: