简介:网页版黑白棋AI项目以蒙特卡洛树搜索为核心算法,实现人机对弈的完整网页应用,适合计算机、数学、电子信息等专业学生作为课程设计、期末大作业或毕业设计参考,也适合对棋类AI感兴趣的开发者阅读源码、理解MCTS的落子策略与胜率评估机制。压缩包内共七百四十六个文件,大小约四点一三MB,主要类型包括JavaScript、Markdown、JSON、LICENSE等:JavaScript负责前端交互与蒙特卡洛树搜索逻辑,Markdown提供项目说明文档,JSON存放配置信息,LICENSE及辅助脚本用于完善工程结构;包内还含少量演示图片,目录组织清晰,便于按模块查看源码与文档。已有558人在CSDN浏览或下载过该项目,说明该项目对相关学习者具有一定参考价值。下载后可直接运行完整网页应用,适合快速搭建黑白棋对弈环境;通过阅读项目说明与源码,读者可以掌握蒙特卡洛树搜索的选点、扩展、模拟与回溯流程,并借鉴其前端界面设计和模块划分方式,用于自己的课程设计或毕业设计扩展。
1. 網頁版黑白棋 AI 的双栈拆解:当 C++ 引擎遇上 Node 渲染层
这个 zip 拆开看,最值钱的文件不是 index.ejs,而是那个不起眼的 main.cpp。網頁版黑白棋 AI 的完整链路是:C++ 写蒙特卡洛树搜索核心,Node/Express 当页面层,EJS 渲染棋盘和源码说明页。与其说是一个网页小游戏,不如说是一个「把搜索算法包装成网页服务」的典型工程样板。对想弄懂 MCTS 但从没在真实项目里跑过它的人来说,这份源码的价值在中间层:节点怎么管理、模拟次数怎么给、C++ 和 Node 之间用什么样的协议来回传棋盘,每一步都有明确的取舍。下面按规则引擎、MCTS 主循环、桥接层、调参验证四个层面拆。
2. 黑白棋规则引擎:合法着法枚举与翻转检测的 C++ 实现
2.1 棋盘编码:char board[64] 的双玩家约定
黑白棋棋盘是 8×8,最直观的表示就是一个 64 字节的数组,比两个 uint64_t 位棋盘好读得多。课程设计性质的项目里,我一般先用char board[64]把逻辑跑通,等 perf 说 rollout 是热点再换位棋盘。编码约定如下:黑棋为 1,白棋为 -1,空位为 0。坐标统一用r * 8 + c,r 是行(0~7),c 是列(0~7),这个行优先约定在前后端各出现一次,最容易不一致。
| 编码值 | 含义 | 棋盘字符 |
|---|---|---|
| 1 | 黑棋 | b |
| -1 | 白棋 | w |
| 0 | 空位 | . |
合法着法检测的核心是「从落子点出发,沿 8 个方向找被己方棋子夹住的连续对方棋子」。方向表是独立的二维数组,这样一维索引和二维坐标的换算留在循环里,代码会更直白:
const int DIRS[8][2] = { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; bool isLegal(const char board[64], int pos, int player) { if (board[pos] != 0) return false; // 已落子 int opp = -player; // 对手编码 for (int d = 0; d < 8; ++d) { int r = pos / 8 + DIRS[d][0]; int c = pos % 8 + DIRS[d][1]; if (r < 0 || r >= 8 || c < 0 || c >= 8) continue; if (board[r * 8 + c] != opp) continue; // 紧邻格必须是对方棋子 r += DIRS[d][0]; c += DIRS[d][1]; while (r >= 0 && r < 8 && c >= 0 && c < 8) { if (board[r * 8 + c] == player) return true; // 夹住了 if (board[r * 8 + c] == 0) break; // 碰到空位,终止 r += DIRS[d][0]; c += DIRS[d][1]; } } return false; }这里把 player 定为 +1/-1 而不是常见的 1/2,是因为opp = -player一行就能拿到对手编码,后续翻转检测和终局判定都用这个约定。MCTS 里 isLegal 会被调用百万次量级,所以这函数里不要出现std::vector、std::function或异常分支,裸数组和方向表是主流实现方式。需要说明的是,这个检测函数只判断「落子是否合法」,不负责翻转,翻转逻辑独立放在 makeMove 里。
2.2 makeMove 的函数式落子与翻转扩散
翻转的逻辑是:从一个方向冒出第一步吃子后,若后续能碰到己方棋子,则中间所有对方棋子全部翻转。我习惯写成函数式风格——传入源棋盘,返回新棋盘,不在原棋盘上做撤销操作。原因很实际:MCTS 扩展节点时每个子节点需要独立局面,若是 in-place 修改还得记录翻转列表再回滚,状态管理容易出错;每节点多付 64 字节拷贝,十万节点也就 6MB 量级,换来的是访问任意节点时棋盘立即可用。
void applyMove(char dest[64], const char src[64], int pos, int player) { std::memcpy(dest, src, 64); // 拷贝当前局面 dest[pos] = player; int opp = -player; for (int d = 0; d < 8; ++d) { int r = pos / 8 + DIRS[d][0]; int c = pos % 8 + DIRS[d][1]; int flipped[30], nf = 0; while (r >= 0 && r < 8 && c >= 0 && c < 8) { if (board[r * 8 + c] != opp) break; flipped[nf++] = r * 8 + c; // 记录这条线上疑似被夹的棋子 r += DIRS[d][0]; c += DIRS[d][1]; } if (r >= 0 && r < 8 && c >= 0 && c < 8 && dest[r * 8 + c] == player) { for (int i = 0; i < nf; ++i) dest[flipped[i]] = player; } } }这段代码的注意点在于「先累积后翻转」:一条方向上可能连续多个对手棋子,但如果走到底碰到的是空位或棋盘边界,说明这条线夹不住,已经记录的 flipped 要整条丢弃。先收集全部疑似棋子、确认末尾是己方棋子后再统一翻转,比边遍历边翻要少写一半边界判断。函数式拷贝带来的memcpy开销在 rollout 里占比不高,但能显著简化节点的状态管理,属于典型的以空间换正确性。
2.3 终局判定与 pass 逻辑的坑
黑白棋结束条件不是「棋盘满了」,而是「双方都没有合法着法」。这带来两个容易写错的点:一是单方无着法时要 pass 而不是结束;二是平局存在,32:32 很常见。终局判定要把这两个分支都覆盖:
std::vector<int> enumerateMoves(const char board[64], int player) { std::vector<int> moves; for (int p = 0; p < 64; ++p) { if (isLegal(board, p, player)) moves.push_back(p); } return moves; } int gameOver(const char board[64], int player) { if (!enumerateMoves(board, player).empty()) return 0; // 还能走 if (!enumerateMoves(board, -player).empty()) return 0; // 对手还能走 int black = 0, white = 0; for (int i = 0; i < 64; ++i) { if (board[i] == 1) ++black; else if (board[i] == -1) ++white; } if (black > white) return 1; // 黑胜 if (black < white) return -1; // 白胜 return 2; // 平局,用 2 区分「未结束」 }这里返回值 0 表示游戏未结束,2 单独表示平局,避免与「未结束」混淆。rollout 里调用的结束判定需要区分的是「还能不能继续走」而不是「谁赢了」,所以我把结束检测和胜负判定拆开。注意:黑白棋强制翻转规则意味着有合法着法就必须走,不允许主动弃权;rollout 里若漏掉 pass 分支,会在轮到自己却无子可下、对手还能走时提前终止,模拟结果会系统性偏向某方。gameOver 的返回与 MCTS 的胜率统计直接挂钩,这个函数错了,后面所有搜索结论都是错的。
提示:pass 分支的正确写法是「当前玩家无合法着法时,切换到对手;只有双方都无合法着法才进入终局计分」。在 MCTS 的 rollout 里,这个切换通常通过函数递归参数交换 player 实现,不要在主循环里用状态机硬推。
3. 蒙特卡洛树搜索主循环:UCB1、节点池与 rollout 的工程取舍
3.1 为什么黑白棋 AI 选 MCTS 而不是 Alpha-Beta
黑白棋的 Minimax 必须先有一个局面评估函数,把行动力、稳定子、边角占据按权重加权求和。权重怎么定?经验值拍脑袋,调错一点棋风就变得诡异——比如前期疯狂翻子导致丢角。MCTS 彻底绕开评估函数,只回答一个问题:这个局面下随机推演 N 局,哪个落子的胜率最高。它是「无评估函数」博弈 AI 的经典范式,也是很多 ai agent 决策框架的雏形:采样、评估、按置信度选择,和策略梯度类方法 share 同一套「采样—评优」思想。对大模型 RLHF 里的奖励建模而言,MCTS 的 rollout 胜率就是最朴素的奖励信号来源。
| 对比维度 | Minimax + Alpha-Beta | MCTS |
|---|---|---|
| 评估函数 | 必须有,权重难调 | 不需要 |
| 单步耗时 | 深度固定,波动小 | 模拟次数可控 |
| 实现难度 | 剪枝排序置换表一整套 | 四阶段主循环 |
| 适合场景 | 局面可精估的棋类 | 规则复杂难评估 |
对课程设计和毕设来说,MCTS 的调试面小得多:alpha-beta 权重给偏了很难定位,MCTS 权重给偏顶多是搜索方向有倾向性,不会出现「突然送角」这种硬伤。
3.2 节点结构与固定容量节点池
黑白棋一手最多 60 个合法着法(棋盘全空时),子节点数组直接定死 60 就不用 vector 扩容。节点里直接存棋盘副本,而不是存「父节点走法序列」——后者看着省内存,实际每次评估都要重放历史,热路径上代价极高。
struct MCTSNode { MCTSNode* parent; char board[64]; // 当前局面副本 int player; // 当前轮到的走子方 int move; // 从父节点到达本节点所走的着法 int visits; // 访问次数 double wins; // 以根玩家视角累计的胜利次数 int childCount; // 已扩展子节点数 int legalMoves[60]; // 缓存合法着法,避免反复枚举 int legalCount; MCTSNode* children[60]; }; constexpr int POOL_SIZE = 2000000; MCTSNode pool[POOL_SIZE]; // 固定容量节点池 int poolTop = 0;为什么要自建节点池而不是直接new MCTSNode?一局模拟大概 60 步,10000 次模拟会产生几十万次节点分配,每次 new 都要走 malloc,热路径上分配器开销能占到 15% 以上。预分配 200 万个节点,每个约 100 字节,内存占用在 200MB 以内,单局搜索实际用不到这个上限。poolTop在两次搜索之间重置为零,相当于整棵树的节点复用,不需要逐节点释放。
3.3 UCB1 与四阶段主循环
UCB1 解决的是「在胜率和探索之间找平衡」。未访问子节点返回一个极大值保证至少被探索一次,已访问节点用wins/visits做利用项、sqrt(log(totalVisits)/visits)做探索项。符号方向是新手最容易翻车的地方:wins 一律以根玩家视角累计,rollout 结束时根玩家赢就返回 1,否则 0,回溯路径上每个节点加同一个值,不用管节点自己的 player 是谁。如果 wins 按节点自己的视角算,回溯时要根据深度翻转符号,中盘就会开始乱走。
double ucb1(const MCTSNode* n, double totalVisits) { if (n->visits == 0) return 1e18; // 未访问优先 double exploit = n->wins / n->visits; double explore = C_UCB * std::sqrt(std::log(totalVisits + 1.0) / n->visits); return exploit + explore; } int mctsSearch(MCTSNode* root, int iterations) { for (int i = 0; i < iterations; ++i) { MCTSNode* node = root; while (node->childCount > 0) { // 选择 if (node->childCount < node->legalCount) { node = expand(node); // 扩展一名子节点 break; } node = selectByUCB1(node); // 沿 UCB1 最大路径下行 } double result = rollout(node->board, node->player, root->player); while (node) { // 回溯 node->visits += 1; node->wins += result; node = node->parent; } } return selectBestMove(root); // 取访问次数最大的子节点 }expand的要点是:从legalMoves里取一个还没被扩展的着法,调用applyMove生成子局面,从节点池分配新节点挂到children数组。这里有个性能细节——legalMoves只在节点第一次被访问时枚举一次并缓存,后续所有选择阶段直接读缓存,避免每次评估都重算一遍 64 格的合法性检测。selectByUCB1遍历 children 数组,以父节点的 visits 作为totalVisits,取 UCB1 值最大的子节点返回。主循环里「先扩展再 rollout」的顺序也有讲究:刚展开的节点没有统计量,直接进入选择会让父节点对它反复偏好,先 rollout 一次给个初始胜率会更稳。C_UCB 的理论值是 sqrt(2),但在黑白棋这种单局方差大的场景,我一般取 0.7~1.4 之间,具体影响放在第 5 章压测。
3.4 rollout 加权随机与模拟预算
纯均匀随机 rollout 在黑白棋里棋力很弱,因为黑白棋有强烈的「位置价值」结构:角一旦被占,夺回的几率极低;角周围的 x-square 是陷阱,落子会送角。加权随机让模拟阶段更接近真实对局的着法分布,树搜索的质量会显著提升。这段代码是典型的「常识规则融入采样」,AI 编程工具能生成模板,但很难替你想到角权重 6、边权重 3、邻角降权这组数字:
int weightedRolloutPick(const char board[64], int player) { int moves[60], weight[60], n = 0, total = 0; for (int p = 0; p < 64; ++p) { if (!isLegal(board, p, player)) continue; int r = p / 8, c = p % 8; int w = (r == 0 || r == 7) && (c == 0 || c == 7) ? 6 : (r == 0 || r == 7 || c == 0 || c == 7) ? 3 : 1; int cr = r < 4 ? 0 : 7, cc = c < 4 ? 0 : 7; if (std::abs(r - cr) <= 1 && std::abs(c - cc) <= 1) w /= 2; moves[n] = p; weight[n] = w; total += w; ++n; } int pick = rand() % total; for (int i = 0; i < n; ++i) { pick -= weight[i]; if (pick < 0) return moves[i]; } return moves[0]; }权重不需要精确,因为树搜索本身会修正 rollout 的系统性偏差——rollout 只是让选择阶段有倾向性,最终决策靠的是访问次数分布,而不是 rollout 的单次结果。单步搜索预算上,1000 次模拟是比较低的门槛,2000 次在网页交互里大约几百毫秒,8000 次以上棋力提升就开始变缓。这个参数最终要在吞吐时间和棋力之间取平衡,具体压测放到第 5 章。
4. Node/Express 桥接层:child_process 调用 C++ AI 与 EJS 渲染
4.1 编译参数与命令行协议
main.cpp 在这个项目里不只是算法库,它同时承担命令行入口:从 stdin 读一行局面描述,从 stdout 输出一个着法。这样 Node 侧只需要 spawn 一个子进程,不需要引入任何 C++ 绑定库。编译时-O2必须开,rollout 里的热循环在 O0 下慢 5~10 倍,直接决定网页端的等待时间是否可接受:
g++ -O2 -std=c++17 main.cpp -o bin/othello_ai echo "...........................ox......xo........................... o 2000" | ./bin/othello_ai命令行协议约定为:第一段是 64 字符棋盘(.空、b黑、w白),第二段是当前走子方,第三段是模拟次数,stdout 输出一个d3形式的代数坐标。为什么用 stdin/stdout 而不是让 Node 直接把参数拼到命令行?因为 64 字符棋盘里可能混入特殊字符,流式输入更稳,而且 spawn 的 stdio 管道天然支持双向通信,后续要扩展「AI 返回思考统计」也不用改接口。main.cpp 里解析 argv 时--iter走第一分支,没传就用默认值,这样命令行手工对弈和 Node 调用共用同一套代码。
4.2 Express 路由与 child_process 封装
Node 侧的核心是spawn而不是exec:spawn 的 stdin/stdout 是流式的,边写边读;exec 默认要等进程结束才拿全量输出,虽然这里输出很短也能跑,但 spawn 的进程控制和超时回收更干净。AI 是一个独立的 C++ 进程,它如果卡死,Node 的事件循环不会自动回收它,必须主动加超时并 kill:
const { spawn } = require('child_process'); const path = require('path'); const AI_PATH = path.join(__dirname, 'bin', 'othello_ai'); function askAI(board64, player, iterations = 2000, timeoutMs = 5000) { return new Promise((resolve, reject) => { const child = spawn(AI_PATH, [String(iterations)], { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { child.kill('SIGKILL'); // 超时强杀,防止孤儿进程 reject(new Error('ai timeout')); }, timeoutMs); let out = ''; child.stdout.on('data', d => { out += d.toString(); }); child.on('close', code => { clearTimeout(timer); if (code !== 0) return reject(new Error('ai exit ' + code)); resolve(out.trim()); }); child.stdin.write(board64 + ' ' + player + '\n'); child.stdin.end(); }); } app.post('/api/move', async (req, res) => { const { board, player, iterations } = req.body; const board64 = board.flat() .map(v => v === 1 ? 'b' : v === -1 ? 'w' : '.').join(''); try { const move = await askAI(board64, player, iterations); res.json({ move }); } catch (e) { res.status(504).json({ error: e.message }); } });board.flat()把前端传来的 8×8 二维数组压平,再按 C++ 侧同样的约定映射成 64 字符。这串映射是前后端契约的核心,黑白棋的 1/-1/0 编码在前后端各出现一次,改了一边忘了另一边,AI 就会走出完全不合法的子。req.body依赖app.use(express.json())中间件,这个容易漏配,漏掉后 body 会是 undefined,请求直接 500。超时杀掉子进程后,下一次请求会重新 spawn,不会留下半死的僵尸进程,这是用独立进程做 AI 服务最舒服的地方——状态被你用「进程生命周期」天然隔离了。
4.3 三个 EJS 模板的分工与源码高亮页
文件清单里的 index.ejs、introduce.ejs、cpp.ejs 不是随意拆的,对应课程设计答辩的三个需求:能玩游戏、能讲原理、能展示源码。prism.css 的存在说明 cpp.ejs 是个代码高亮页,服务端把 main.cpp 读出来塞进模板,而不是把源码硬编码在页面里:
| 模板 | 页面职责 | 关键依赖 |
|---|---|---|
| index.ejs | 主棋盘界面,点击落子、AI 状态显示 | 内联 JS 调 /api/move |
| introduce.ejs | MCTS 原理、项目背景说明 | 纯静态图文 |
| cpp.ejs | 内嵌 main.cpp 源码并高亮 | prism.css + language-cpp |
const fs = require('fs'); app.get('/source', (req, res) => { const code = fs.readFileSync(path.join(__dirname, '..', 'main.cpp'), 'utf8'); res.render('cpp', { code }); });cpp.ejs 里要用<pre><code class="language-cpp">包住代码,prism 的高亮脚本才会生效;只引 css 不引对应语言包,页面会保持纯文本。首屏棋盘用res.render('index', { board })服务端渲染,之后走子全部走 /api/move 局部刷新,不要每走一步都重绘整个页面。另外,mime.cmd、ejs.cmd、jake.cmd 这三个文件是 Windows 下 npm 安装 ejs 相关包时自动生成的命令包装 shim,不是项目源码入口,排查问题时别被它们带偏。真正入口只有两个:C++ 侧的 main.cpp 和 Node 侧的 server.js。
提示:Windows 下编译通过、spawn 却报 ENOENT,通常是 bin 目录没加进 path 或路径分隔符问题。用
path.join(__dirname, 'bin', 'othello_ai.exe')显式拼路径,比依赖系统 PATH 更稳。
5. 参数自对弈调优:模拟次数、UCB 常数与超时预算的验证方法
5.1 自对弈批量压测脚本
MCTS 的棋力评估不能靠单局手测,随机性会让结果完全不可信。最常见做法是让同一份可执行文件带不同参数自对弈,固定随机种子、交替先后手,跑多局取胜率。下面这段 Python 脚本暴露了引擎的--iter参数,10 局一轮就能看出明显差异:
import subprocess, random def run_one(black_cmd, white_cmd, seed): random.seed(seed) board = [''] * 64 # 初始四子:d5 w、e4 w、d4 b、e5 b state = '.' * 27 + 'wb' + '.' + 'bw' + '.' * 27 turn = 'b' for _ in range(120): # 一步不超过 60 手 cmd = black_cmd if turn == 'b' else white_cmd p = subprocess.run(cmd.split(), input=f'{state} {turn} 800\n', capture_output=True, text=True, timeout=10) if p.returncode != 0: return 'W' if turn == 'b' else 'B' # 引擎崩溃判负 move = p.stdout.strip() # 这里解析 move 并更新 state,代码略 turn = 'w' if turn == 'b' else 'b' if not any_moves(state, turn): turn = 'w' if turn == 'b' else 'b' return score(state) for it in [500, 2000, 8000]: wins = sum( run_one(f'./bin/othello_ai --iter {it}', './bin/othello_ai --iter 2000', s) == 'B' for s in range(10) ) print(it, wins / 10)这里冒号处省略的 state 更新逻辑,用第 2 章的applyMove语义在 Python 里重写一份即可。固定 seed 的意义在于让同一参数组合的每局走法完全可复现,调参时改一个变量就能对照差异。注意黑棋有先行优势,压测两组参数时必须轮流当黑棋,否则胜率差会被先后手干扰。timeout 设 10 秒是兜底引擎死循环的情况,正常单步 800 次模拟远用不了这么久。
5.2 三个调参结论与热点定位
几张经验表在课程设计里够用:C_UCB 取 0.7~1.4 之间,太小容易让根节点过早只盯一个候选点,太大则搜索过度分散;单步模拟次数低于 500 时棋力断崖式下降,乱走概率显著增加;网页交互的等待时间最好压在 2 秒以内,对应 2000~4000 次模拟。调试时先看根节点子访问分布:核心候选点的 visits 占比应超过 60%,如果分布非常平均,优先怀疑 C 太大或 rollout 加权失效。
| 参数 | 经验档位 | 表现特征 |
|---|---|---|
| C_UCB | 0.7~1.4 | 过小收敛过快,过大到处试探 |
| 模拟次数 | 1000~8000 | 低于 500 明显乱走 |
| 单步超时 | 0.5~3 秒 | 网页交互上限约 2 秒 |
热点定位用 perf 直接跑一次高迭代数搜索,看 rollout 函数的 CPU 占比:
perf record ./bin/othello_ai --iter 20000 perf report如果 rollout 函数占比超过 85%,说明主要瓶颈在规则引擎而不是 UCB1 和节点池——此时把 char board[64] 换成两个 uint64_t 位棋盘(黑棋一个、白棋一个),翻转检测用预生成的方向掩码做位运算,同样模拟次数通常还能再快 3~5 倍,这就是这份 main.cpp 下一步最值得动的重构。
本文还有配套的精品资源,点击获取