最近搭了个纯 HTML 五子棋AI 对手不是那种只会随机落子的“人工智障”而是带评分策略、会防守也会进攻的“高智商”棋手。整个过程基本是让 Kimi-2.5 帮我写的代码我只负责提需求和定位问题亲测下来完全可用浏览器一开就能玩。这篇东西就把这套从 0 到 1 的实现思路、关键代码逻辑、踩坑记录和最后调试经验全部拆出来希望能给想快速做一个小游戏、或者想尝试让大模型辅助写代码的朋友一点参考。先说结论单文件 HTML CSS JavaScript 就能搞定一个像模像样的五子棋人机对战核心难点不在界面而在 AI 的评分体系和棋盘状态管理上。1. 项目概述与核心需求解析1.1 这个项目到底想做什么我的目标很明确做一个直接在浏览器打开就能玩的人机五子棋游戏。棋盘用标准的 15×15玩家执黑、电脑执白电脑落子要有一定策略——能看出来你在做“冲三”就去堵你同时自己也会积蓄连子而不是随便找一个空位下下去。听起来不难但真写起来有几个地方是绕不开的棋盘怎么画、鼠标点击怎么换算成棋盘坐标、落子后如何快速判断胜负、AI 怎么在短时间内选出一个“看起来有点智商”的位置。这些模块拆开看都不复杂关键是怎么组织得干净、不容易出 bug。1.2 技术选型为什么是纯 HTML为什么用 Kimi 辅助大把现成的五子棋代码网上随便抄但我这次故意不用任何框架、不用后端也不引入图片素材就靠一个.html文件搞定全部。原因很简单单文件意味着双击即可运行省去本地服务器、依赖安装这些事特别适合快速验证一个想法顺手发给朋友也能直接打开玩。至于让 Kimi-2.5 写代码我的核心理由是“快速出原型”。你给 AI 一个足够具体、带约束的需求它能在几十秒内给你一版能跑的代码。你再去读代码、测逻辑、反过来追问 AI 为什么这么写、让它改哪块效率比自己一个个函数敲出来高不少。但前提是——你自己得能看懂它的代码否则 bug 都不知道从哪找。1.3 功能清单拆解在动手前我先列了个清单这个习惯帮我省了不少事15×15 棋盘渲染画网格、画棋子、做棋盘背景鼠标点击位置换算成行列坐标玩家落黑子AI 落白子交替执行五子连珠胜负判定AI 评分算法包含进攻和防守双维度下完一局后自动判胜或平局重置按钮和一局结束后的提示功能不多但每个都值得单独打磨。下面我按实现顺序拆开讲。2. 棋盘与渲染层实现细节2.1 棋盘的数据模型二维数组才是核心不管界面怎么画棋盘在内存里的样子必须是一个二维数组const BOARD_SIZE 15; // 0 表示空位1 表示黑子2 表示白子 let board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(0));为什么强调数据模型优先因为没有这个数组你后面判断胜负、写 AI 评分都会非常别扭。界面上的棋盘坐标、Canvas 上的像素坐标、数组下标这三者必须能互相转换。数组下标[row][col]对应逻辑坐标第 row 行第 col 列而 Canvas 像素坐标要根据棋盘边距和格子尺寸换算。这里有一个最容易踩的坑人的直觉是“横第几格、竖第几格”而数组习惯写成board[row][col]如果 canvas 绘制时先画 col 再画 row顺序反了一眼很难看出来但落子偏差会逐渐累积。我的建议是统一约定——“第一个下标是行第二个下标是列”所有地方都遵守别变来变去。2.2 Canvas 渲染 vs DOM 渲染Canvas 更合适五子棋这种每局要画大量棋子和网格的场景我选了canvas。DOM 方案也能做比如用一堆 div 拼格子但画圆、画线、处理重绘都麻烦而且棋子多起来 DOM 节点数量会很可观。Canvas 的初始化代码很直接const canvas document.getElementById(board); const ctx canvas.getContext(2d); const MARGIN 30; const CELL_SIZE 36; const radius CELL_SIZE * 0.42; canvas.width MARGIN * 2 (BOARD_SIZE - 1) * CELL_SIZE; canvas.height MARGIN * 2 (BOARD_SIZE - 1) * CELL_SIZE;MARGIN是棋盘四周留白目的是给边缘棋子和网格线留出呼吸空间否则在最边线上落子时棋子会“贴边”很丑。CELL_SIZE就是相邻两条网格线的像素距离我取 36 像素主要是手感适中棋盘总宽约 564 像素在普通屏幕上能完整展示。绘制网格时注意不是画BOARD_SIZE条线而是画BOARD_SIZE条横线和BOARD_SIZE条竖线刚好围出14×14个格子。每个交叉点就是落子位置。中心点和四个天元星位可以用稍大的点做标记这是五子棋棋盘的传统审美加上会显得更“像那么回事”。2.3 棋子绘制与坐标换算棋子的绘制就是一个填充圆。黑子填充深色白子填充浅色再描一圈稍深的边视觉上会立体一点。点棋子时有一个细节容易忽略点击事件返回的offsetX是整个 Canvas 左上角算起的像素值你需要先减去MARGIN再除以CELL_SIZE然后四舍五入取到最近的交叉点索引canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left - MARGIN; const y e.clientY - rect.top - MARGIN; const col Math.round(x / CELL_SIZE); const row Math.round(y / CELL_SIZE); // 越界检查 if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; // 该位置已有棋子则忽略 if (board[row][col] ! 0) return; placePiece(row, col, 1); // 玩家下完轮到 AI setTimeout(() aiMove(), 100); });第二个坑就在这Math.round乘以CELL_SIZE后像素值有可能小于MARGIN或超过棋盘右边界所以必须做越界判断。另外如果 Canvas 的 CSS 尺寸和实际绘图尺寸不一致比如用百分比拉伸getBoundingClientRect拿到的坐标还要比例换算。最简单的方法是让 Canvas 的实际宽高和 CSS 宽高一致或者干脆固定尺寸别自找麻烦。2.4 设备像素比问题如果你在手机上打开高 DPI 屏上 Canvas 会发虚。这是老生常谈的问题屏幕物理像素和逻辑像素不一致。处理办法是让 Canvas 的物理分辨率乘以window.devicePixelRatio同时用ctx.scale(dpr, dpr)保证绘图坐标不受影响const dpr window.devicePixelRatio || 1; canvas.width logicalWidth * dpr; canvas.height logicalHeight * dpr; ctx.scale(dpr, dpr);别在项目一开始就做这个优化先把基础逻辑跑通最后打磨阶段再补。初期追求的是代码容易读、逻辑清晰而不是像素级完美。3. 胜负判定算法3.1 四方向连续棋子计数五子棋的胜负判定是所有逻辑里“最好写但最容易写错”的部分——说它容易写错是因为很多人只检查一个方向忘了五子棋是横、竖、两条斜线四个方向都能赢。我的做法是以刚落下的棋子为中心朝四个方向分别数“连续同色棋子数”。方向数组是四组坐标增量const DIRS [ { dx: 1, dy: 0 }, // 横向 { dx: 0, dy: 1 }, // 纵向 { dx: 1, dy: 1 }, // 主对角线 { dx: 1, dy: -1 } // 副对角线 ]; function checkWin(row, col, player) { for (const dir of DIRS) { let count 1; // 正方向数 for (let step 1; step 5; step) { const r row dir.dy * step; const c col dir.dx * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (board[r][c] ! player) break; count; } // 反方向数 for (let step 1; step 5; step) { const r row - dir.dy * step; const c col - dir.dx * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (board[r][c] ! player) break; count; } if (count 5) return true; } return false; }这个函数最坏情况下要遍历4 方向 × (44) 步常数很小性能没问题。我之所以让每次只查step 5而不是一路查到边界是因为只要连续 5 个就赢了没必要数到更远但要注意正方向和反方向计数时会把左右两边的连子数加总如果两头是“断”的count 一样能达到 5这是符合规则的。3.2 一个容易漏掉的优先级问题棋盘下满、却没人连成五子就是平局。看起来简单但注意优先级必须“先判断有没有连五再判断棋盘满没满”。如果最后一步棋玩家恰好下成五子棋盘也正好满了此时应该判玩家赢而不是平局。if (checkWin(row, col, player)) { showResult(player); } else if (board.every(row row.every(cell cell ! 0))) { showResult(0); // 平局 }有些 AI 生成的代码会把平局判断写在前面导致最后一手连五被误判成平局这种逻辑 bug 很隐蔽测两三次都不一定撞上。4. “高智商” AI 的评分逻辑4.1 关键让 AI 有“评估局面”的能力如果只是让电脑在空位里随机选一个那这个五子棋毫无吸引力。五子棋 AI 最简单的入门方案是“启发式评分”——对每个空位打分最高分的位置就是落子点。这个方案不算最强但已经能让 AI 显得“有点会玩”足够撑起一个休闲小游戏。打分思路分两步第一步进攻分。假设这个位置是我方棋子检查它在四个方向上能形成什么棋型连五、活四、冲四、活三、眠三、活二。每种棋型给一个权重分。第二步防守分。假设这个位置是对方棋子也做同样检查得到防守分。第三步该位置最终分 进攻分 防守分 × 防守系数。这里防守系数很关键。如果 AI 只盯着自己进攻完全不看对手那玩家很容易做出“四三绝杀”它都反应不过来。反过来如果防守系数太高AI 就会一直被动堵路自己永远赢不了。我实测调到防守系数 0.9左右AI 整体感觉是“偏防守但会找机会进攻”比较适合普通人类玩家。4.2 棋型识别怎么知道这个位置是好还是坏棋型识别是评分系统的地基。给定一个空位和一个方向你实际上要看这条线上向两边延伸时出现了什么模式。我一开始用的做法比较暴力——以空位为中心分别向正方向和反方向扫描连续同色棋子数同时检查两端的开放情况function evaluateLine(board, row, col, player, dir) { // 分别统计正方向和反方向的连续棋子数、端点是否开放 // 返回一个棋型标识比如 { count: 4, openEnds: 2 } 表示活四 }核心是最终把扫描结果归纳成棋型枚举count 5连五无上至尊分最高count 4 openEnds 2活四两端的空位都是开放的对方无法阻挡下一次落子必须抢count 4 openEnds 1冲四一端被封但有一次冲刺机会count 3 openEnds 2活三可以发展成活四很危险count 3 openEnds 1眠三潜力小一些count 2 openEnds 2活二潜力种子其他零散棋子分数很低建议把这些权重做成一个可配置对象方便调整const SCORE { WIN: 100000, LIVE_FOUR: 50000, RUSH_FOUR: 8000, LIVE_THREE: 6000, SLEEP_THREE: 500, LIVE_TWO: 300, SLEEP_TWO: 80 };权重怎么定直接决定 AI 的性格。如果你把RUSH_FOUR调得比LIVE_THREE还低AI 可能会放着冲四不去杀反而去堵一个活三。这些数值没有标准答案完全是调试调出来的。4.3 缩小候选空位范围理论上 15×15 棋盘有 225 个空位AI 每一步都要对每个空位做 4 个方向、8 个步长的扫描运算量并不大。但为了让 AI 的“思考”更聚焦我加了一条经验规则——只考虑距已有棋子不超过 2 格的空位。真正的高价值落点几乎都出现在已有棋子附近。function getCandidatePositions() { const candidates []; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) continue; if (hasNeighborWithinDistance(r, c, 2)) { candidates.push({ row: r, col: c }); } } } return candidates; }这一步能砍掉大约一半甚至更多的无效计算AI 速度肉眼可见提升而且棋力几乎不下降。4.4 加入极小化极大搜索让 AI 更有攻击性纯评分 AI 有一个致命弱点它只能看到“当前这一步”。我下了一个活三AI 会立刻去堵这没问题但如果我下了一步“看似没用、实际在酝酿活三”的远距离埋伏纯评分 AI 就看不见了因为它只看当前局面不对未来几步做推演。如果想明显提升“智商感”最快的方法是引入一层极浅的搜索——下两步棋也就是“我下一子你下一子我再下一子”然后评估局面。这个能处理“我看见你要攻击了”的防守也能让 AI 看出“这个位置我下一子下一步就能活三”的铺垫。但这块有几个坑搜索深度一旦超过 4 层且候选集不做裁剪浏览器会直接被算到卡死。每一层都要临时复制棋盘JS 里的数组深拷贝有一定开销。需要限制每层只搜索分数最高的前 N 个候选点比如 8 个否则分支爆炸。我在项目里只做了“1 层前瞻”的优化——AI 先枚举自己所有可能的落子点然后在每个点位上假设已经落子再用评分函数计算这个局面会导致下一步多大的威胁。这样既不会卡又能让 AI 产生一定的“预见性”。如果你有兴趣继续深入可以从标准的 alpha-beta 剪枝 minimal 算法开始把深度控制到 4 层以内配合每层只取 Top-N 候选点就已经能碾压大多数休闲玩家了。5. 实操过程用 Kimi-2.5 生成与迭代优化5.1 第一轮提示词怎么写才不跑偏我不会一上来就让 Kimi“给我一个五子棋”这样出来的代码大概率是玩具级别没有 AI 或者 AI 傻得离谱。我会把需求拆成几个具体模块分次提问。第一轮我给的提示大概是写一个 HTML 五子棋人机对战游戏用 Canvas 渲染 15×15 棋盘。玩家执黑子电脑执白子。电脑不能随机下必须用启发式评分函数来选择落点。请把全部代码放在一个 HTML 文件里代码注释用中文便于我阅读和修改。把这个提示交给 Kimi-2.5它给出的第一版基本能跑棋盘能画、棋子能下、电脑也会根据一个基础的评分函数选择位置。但那个第一版 AI 很蠢——它没有防守系数的概念只盯着自己的进攻点下同时它对棋型的识别也只是简单判断“连了几子”没有区分活三和眠三。这意味着玩家只需要做一个“斜活三”它就看不出来。AI 工具的价值就在这里它已经帮你搭好了一个骨架清晰、可运行的雏形你只需要在这个雏形上做针对性的修改比从零开始写省太多时间。5.2 让 AI 理解“为什么要加防守分”第二轮的提示我需要讲清楚需求原因不能只丢一句“AI 太蠢了”。我给的补充是电脑 AI 目前只考虑自己的进攻不会防守。我需要加一个防守分对每个空位分别计算如果我方棋子和对方棋子落在这里各自会形成多强的棋型然后把进攻分和防守分加权合并。请把 SCORE 权重做成单独对象方便我调参。这一步 Kimi 处理得比较好它直接把评分函数从“单视角”改成了“双视角”。我拿到后做的第一件事不是跑而是通读代码里evaluatePoint这个函数确认它对“假设我方落子”和“假设对方落子”两种情形确实做了对称处理而且权重配置独立出来了。有一点值得强调AI 生成的代码你花 5 分钟精读一遍远远比直接运行看效果更有价值。很多逻辑 bug 是运行看不出来的——比如评分函数里忘了把当前假设的棋子加进棋盘导致棋型识别永远少算一个子这种错误只在比较特定局面时才暴露。5.3 从“能跑”到“好玩”的调参实录第一版手感很糟糕AI 经常在一个已经没希望的地方继续堆积棋子有时还会忽略玩家的“冲三”。我花了一个晚上调参记录下了一些关键变化初始权重中LIVE_THREE: 6000、RUSH_FOUR: 8000AI 对活三的重视程度低于冲四这没问题但 AI 在进攻和防守之间切换不够积极我发现是防守系数只有 0.6调高到 0.9 后立刻好很多。纯评分有一个通病AI 只知道哪个位置分高但不知道“这个位置分高是不是因为已经在一条死线上堆了太多棋子”。我加了“如果某个空位周围 2 格内完全没有棋子直接排除”效果明显。玩家落子后AI 立刻落子没有人类下棋的节奏感。我加了setTimeout(() aiMove(), 300)让电脑“思考”一下体验上好非常多。5.4 验证“亲测可用”必测的几条路径亲测不是打开页面随便点两下就完了。我给自己列了一份手测清单玩家横着连五子能否正确判胜玩家竖着连五、两条斜线连五是否都能识别玩家下到一半棋盘满了但没人连五是否平局最后一步刚好连五且棋盘满是否优先判胜AI 是否会在玩家形成活三时去堵住两端之一切换浏览器标签页再回来棋盘状态是否保持JS 变量在不用担心快速连点会不会导致落子混乱第五条的测试方法很简单你先自己摆出“活三”的局面然后让电脑下看它是不是去堵你。如果 AI 每次都去堵了一口活三说明评分函数里对活三的防守权重已经生效。6. 调试与避坑实录6.1 落子错位少算了一个“棋子半径”最早一版明明点的是第一行第三列这根交叉线棋子却画到了格子中间一行上方。排查半天发现问题出在坐标换算Math.round(x / CELL_SIZE)拿到的下标是对的但我在绘制时用的是MARGIN col * CELL_SIZE来定位圆心这没问题问题在于点击时offsetX把棋子的视觉半径也算了进去导致偏差约半格。解决办法是把坐标换算统一封装到一个函数里所有落子、绘制都走同一个入口function getCellFromPixel(px, py) { const col Math.round((px - MARGIN) / CELL_SIZE); const row Math.round((py - MARGIN) / CELL_SIZE); return { row, col }; }然后绘制时也用同一套公式反推圆心。任何情况下都不要再写第二套换算逻辑。6.2 AI 卡顿候选集没有裁剪第一版 AI 在每一步都对全盘 225 个空位做 4 方向扫描听上去还行但实际在低配电脑上如果又加了一层搜索每次落子会感觉明显停顿半秒以上。人机对战的体验标准是要让人感觉 AI 是在“思考”而不是“卡住”。性能优化我做了两件事缩小候选集到有邻居的位置把 AI 计算放进setTimeout里避免阻塞页面渲染。手动体验下来AI 落子几乎无感延迟。6.3 快速连点导致双落子玩家手速快在 AI 还没落子时连续点击棋盘同一个空位可能被点两次甚至 AI 还没动人类又下了一步。我用一个简单的锁解决let isPlayerTurn true; let isAnimating false; function onCanvasClick(e) { if (!isPlayerTurn || isAnimating) return; // 落子并切换回合 isPlayerTurn false; // AI 落完后 set isPlayerTurn true }这个锁同时也是状态机的基础。状态机的好处是玩家的所有输入动作都只能在合法阶段执行很多边际 bug 会自然消失。6.4 调试时最烦的问题AI 评分函数烧脑评分函数是最容易出隐蔽 bug 的地方。我踩过最离谱的一次是AI 明明检测到“自己连五”的局面却不去下那一步反而跑去堵对方一个闲子。为什么因为我传给评分函数的棋盘状态里没有包含“如果这个空位已经被我方占据”的临时状态导致棋型扫描时count从 0 开始数自然认为这个点“无棋”。这种问题靠肉眼看很难抓。我提供两个排查手段在 AI 决策前把当前局面、候选点、每个候选点的进攻分和防守分都打印到控制台看它是不是真的算错了。写一个极小的“单步单局面”测试手动初始化一个只有几颗棋子的局面调 AI 决策函数验证选点是否符合直觉。6.5 设备像素比与 CSS 尺寸最后打磨阶段我在手机浏览器上发现棋盘发虚。原因是 Canvas 的物理像素和 CSS 像素不一致。上面提到的dpr方案解决后字、线都清晰多了。这里有一个额外注意点如果用了ctx.scale(dpr, dpr)那么 clearRect 时也要乘回去否则清屏范围不对会留下残影。我建议不管要不要支持高清屏从一开始就封装一个resizeCanvas函数统一处理尺寸。7. 常见问题速查表现象可能原因解决思路点击棋盘没反应addEventListener没绑成功或者 Canvas 被其他元素覆盖检查控制台报错用document.querySelector确认拿到正确 canvas 元素检查 CSS 是否存在覆盖棋子画到格子中间偏上点击坐标换算时漏减了MARGIN或误用了圆心偏移统一调用getCellFromPixel做换算AI 下棋很慢候选集太大、评分函数过于复杂、搜索深度过高限制候选点只保留已有棋子周围 2 格内的空位每层搜索只取 Top-N 候选玩家连了五子却不判胜胜负判定没有检查四个方向或检查方向时dx/dy写错用方向数组代替手工写 4 段 if优先保证横、竖、两条对角线都覆盖平局条件误判为某一方胜利平局判断和胜负判断的先后顺序不对先执行checkWin再执行“是否满盘”判断快速连点出现两个黑子缺少回合状态锁加isPlayerTurn和isAnimating标志非本回合无法落子手机端棋子发虚未处理设备像素比用window.devicePixelRatio调整 Canvas 物理尺寸并ctx.scale(dpr, dpr)AI 无视玩家的活三防守权重的系数太低或棋型识别没有区分“活三”和“眠三”把LIVE_THREE权重设为明显高于SLEEP_THREE防守系数调到 0.9 左右每一步 AI 都下同一个位置权重配置不当导致某一类棋型权重严重异常或者触碰了同步 bug打印候选分检查评分函数是否有某个分支返回常量分数8. 在搞明白核心逻辑后还能做哪些扩展到这里一个“能玩、有点智商”的 HTML 五子棋已经算是完成了。如果你愿意再多花一点时间有几个方向可以继续让它升级。一个方向是人机难度分级。现在 AI 的评分权重、搜索深度、候选集大小都是固定值。把它抽出为难度参数比如“入门”只做纯评分“进阶”加 1 层前瞻“困难”把搜索深度提高到 4 层再加 alpha-beta 剪枝。这能让同一个游戏覆盖不同水平的玩家比“只有一个难度”耐玩得多。另一个方向是试试“让两个 AI 互下”。把原来的aiMove()改成可以独立调用的函数然后在页面里加一个“观战模式”看 AI 自己和自己下棋。这其实是调试评分函数的好办法——如果 AI 互下时经常出现“明明能连五却不去连”的怪招问题大概率在权重设置上。再往后就是“复盘”把每一步落子记录成一个数组实现悔棋、重放功能。数据结构上其实只需要一个历史栈每次落子时push悔棋时pop并重绘。配合localStorage还能把对局存到本地刷新页面后可以接着看。我个人对纯前端小游戏的一个建议是不要一上来就追求最强 AI 或者最复杂的功能先把“能稳定跑、不出 bug、体验顺畅”这个底线守住再考虑加法。一个会崩溃或者有隐藏 bug 的高级功能远不如一个稳定流畅的基础版本让人舒服。9. 写在最后的一些实际体会这次用 Kimi-2.5 辅助开发我最大的体会是大模型写代码的能力确实能让你省下大量“敲键盘”的时间但它替代不了你理解和调试的时间。第一版生成后我依然花了好几个小时去读它的评分函数、跑手测、调防守权重、修边界 bug。如果完全不理解逻辑连“AI 为什么不堵我的活三”这种问题都问不出来。还有一个经验是AI 生成的代码需要你自己重建一套“可维护性”。它的变量命名、函数拆分可能很方便但不一定符合你的思维习惯。我会花 10 分钟把关键变量改成自己顺手的命名把坐标换算、棋型识别、评分函数这三个核心模块拆成独立函数再往后修改就极其顺手。如果你也打算做一个类似的小游戏我建议你从“最简可玩版本”开始不要一开始就塞入搜索算法和花哨的界面。先把棋盘、落子、判胜跑通再逐步给 AI 加血加技能。你踩过的坑越多你对这套代码的控制力就越强。最后分享一个小技巧给 AI 调参的时候不要满盘乱点而是用“摆固定棋型”的方式来验证——手动在棋盘上摆一个活三或冲四然后让 AI 决策看它是不是会做出符合预期的反应。这比你随机下几盘后凭感觉判断“好像变聪明了”要科学得多。这套验证思路放到任何需要“启发式算法”的小游戏里都适用。