写一个网页版五子棋我前前后后写过五六个版本。最早是刚学前端时照着教程敲的Canvas版后来给内部工具做过一个带人机AI的版本再后来帮朋友做毕业设计重构成单文件版。每次写这个小东西都挺上头因为它规模刚刚好——不算大项目但棋盘渲染、事件交互、状态管理、胜负判断、简单AI策略一个不少。这篇就按照我个人最顺手的一条路线来纯 HTML CSS JavaScript单文件实现不需要任何框架也不需要构建工具浏览器打开就能下。无论你是准备做期末作业、写博客练手项目还是想搞清楚AI下棋的基本套路这个方案都值得跟着敲一遍。1. 动手前先想清楚五子棋的核心拆解很多人一上来就写代码写到一半开始乱套。五子棋看起来简单但你要是不先把逻辑拆开到后面加人机对战的时候会非常痛苦。我个人习惯是先画一张脑内结构图这个游戏到底需要哪些模块每个模块负责什么。1.1 三个绕不开的模块从用户视角看五子棋就三件事能看到棋盘、能落子、能告诉我谁赢了。对应到代码里就是三个模块棋盘渲染。负责把15x15的棋盘线和棋子画到页面上。这里有一个很关键的原则——数据与视图分离。不要一落子就去改DOM而是先把棋谱记录在JavaScript的数据结构里渲染只是把数据结构翻译成界面。我见过不少人直接把DOM当数据用比如用document.querySelector获取某个格子判断有没有棋子短期能跑通一旦要加悔棋、重开、AI代码马上失控。交互输入。监听鼠标点击把点击的像素坐标换算成棋盘上的行列坐标然后决定这个位置能不能落子。这个模块最容易被低估其实坑很多坐标换算的偏移量、边界判定、误触拦截都在这里处理。规则判定。每落一子后检查横、竖、两条斜线方向是否形成了五连同时还要处理棋盘下满平局的情况。这部分是算法核心后面我会单独用一整节讲清楚。1.2 技术选型为什么我坚持用DOM而不是Canvas做HTML五子棋棋盘渲染通常有两条路线Canvas画线和纯DOM。网上教程里用Canvas的很多因为画线条和圆形棋子确实方便而且画面精细。但我个人的建议是如果你想让代码更好懂、更好维护首选DOM方案。Canvas的核心缺点是坐标系统完全靠你手动管理。你要自己计算每个交叉点在画布上的像素位置点击事件也要自己换算命中位置一旦棋盘尺寸变了所有坐标全部重算。而用DOM每个棋子就是一个div天然支持CSS样式、动画、事件代理调试的时候直接打开开发者工具就能看到每个节点的结构非常直观。我做了一个简单对比对比项Canvas方案DOM方案性能好大量棋子时优势明显15x15棋盘最多225个棋子完全无压力坐标换算手动计算偏移容易出错事件自带坐标配合CSS定位即可调试难度锅在画布上报错信息不友好DevTools直查DOM树排查非常方便代码复杂度需要额外封装绘图API直接用document.createElement零依赖自定义棋子样式需要代码绘制一个CSS类搞定阴影、渐变实际测试中五子棋单局最多落225子15x15下满DOM节点量级撑死就是棋盘上的交叉点 棋子。你用performance.now()测一下创建225个div的时间在毫秒级人眼根本感知不到。所以性能问题在五子棋这个场景里不是瓶颈代码可读性和可维护性才是。2. 棋盘渲染与落子交互代码是怎么一步步跑起来的现在进入实操阶段。我先把核心数据结构定了然后讲棋盘怎么画、坐标怎么换算、落子主流程怎么写。你跟着一步步来最后会得到一个能正常下棋的完整页面。2.1 先定义数据结构一局棋的内存档案我习惯用二维数组来存储棋盘状态。每个位置有三种状态空、黑子、白子。用数字表示null代表空1代表黑子2代表白子。const SIZE 15; // 棋盘15x15 const EMPTY 0, BLACK 1, WHITE 2; // 创建二维数组全部初始化为0 const board Array.from({ length: SIZE }, () new Array(SIZE).fill(EMPTY)); // 当前轮到谁 let currentPlayer BLACK; // 游戏是否结束防止结束后继续落子 let gameOver false;为什么这么设计关键在于数据驱动视图。以后无论是做AI还是做悔棋都只操作这个数组改完数组再触发重新渲染。比如悔棋就是从历史栈里弹出上一次的坐标把数组对应位置改回EMPTY再移除对应的DOM节点。如果当初没有数组光靠删DOM节点很快就不知道当前棋局到底是什么状态了。2.2 棋盘怎么画背景网格线 交叉点定位这一步我踩过不少坑。常见的画法有三种table表格法、CSS grid网格法、背景渐变法。前两种的问题是每个格子是一个div但你落子需要对准的是交叉点而不是格子计算逻辑绕来绕去。我最推荐的是背景渐变画线 绝对定位棋子。棋盘容器我用一个div尺寸算好然后用CSS的两个linear-gradient分别画横线和竖线。#board { position: relative; width: 660px; height: 660px; margin: 20px auto; background-color: #d9a45b; background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 44px 44px; background-position: 21px 21px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); cursor: pointer; }这里的数值都是有讲究的。棋盘15条线相邻线间距44px那14个间隔一共是44 * 14 616px。为了棋盘线不完全贴死容器边缘我留了22px的边距所以容器总宽度是22 616 22 660px。background-position: 21px 21px是为了让第一条线从22px位置开始1px的线自身占了一半所以是21.5px取整用21px即可。为什么选44px这个尺寸适合鼠标点击。太小了比如20px玩家容易点歪太大了棋盘整体过宽可能超出小屏幕。44px配合棋子直径40px视觉和命中率都比较均衡。2.3 坐标换算从像素到行列的关键一步当用户点击棋盘时拿到的是像素坐标。我们需要把它换算成行列坐标。换算公式其实很直观先减去左边距22px得到相对于第一条线的偏移距离再除以44px取整。boardEl.addEventListener(click, function(e) { if (gameOver) return; const rect boardEl.getBoundingClientRect(); const x e.clientX - rect.left - 22; // 减去边距 const y e.clientY - rect.top - 22; const col Math.round(x / 44); const row Math.round(y / 44); // 边界检查点到了棋盘外面就忽略 if (row 0 || row SIZE || col 0 || col SIZE) return; // 这个交叉点已经有棋子了 if (board[row][col] ! EMPTY) return; placePiece(row, col, currentPlayer); });这里有一个非常容易被忽略的细节Math.round。因为玩家点击不可能每次都准确命中交叉点中心往往会有几个像素的偏差。除以44后再四舍五入就能正确归到最近的交叉点。比如点击在第3行第5列的交叉点中心偏右10px的位置Math.round会正确归到第5列。但如果歪得太离谱比如正好在两个交叉点的中间归到哪边都可能玩家手抖连点的时候需要一定的容错。我后来加了一步距离校验如果点击位置离最近交叉点的偏差超过半个格距22px就认为是无效点击直接忽略。这样能避免误触特别是手机上。const dx x - col * 44; const dy y - row * 44; if (Math.abs(dx) 22 || Math.abs(dy) 22) return;2.4 placePiece与棋子渲染核心落子函数它负责做四件事更新数组、创建DOM节点、判断胜负、切换玩家。function placePiece(row, col, player) { board[row][col] player; // 创建棋子节点 const piece document.createElement(div); piece.className piece (player BLACK ? black : white); piece.style.left (22 col * 44 - 20) px; piece.style.top (22 row * 44 - 20) px; boardEl.appendChild(piece); // 胜负判断 if (checkWin(row, col)) { gameOver true; statusEl.textContent (player BLACK ? 黑方 : 白方) 获胜; return; } // 平局判断 if (isBoardFull()) { gameOver true; statusEl.textContent 平局棋盘已满。; return; } currentPlayer player BLACK ? WHITE : BLACK; updateStatus(); }棋子定位的公式我解释一下交叉点的像素位置本来是22 col * 44但棋子div是个40px宽的方块它需要以交叉点为中心。所以左偏移 交叉点x - 20px即22 col * 44 - 20。这样棋子的中心正好落在交叉点上。如果你不加这20px的偏移棋子会全部偏向右下角看起来特别别扭。棋子样式我用了两个类.piece { position: absolute; width: 40px; height: 40px; border-radius: 50%; pointer-events: none; /* 让棋子不拦截点击事件 */ z-index: 2; } .piece.black { background: radial-gradient(circle at 35% 35%, #555, #111); box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); } .piece.white { background: radial-gradient(circle at 35% 35%, #fff, #ddd); box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); border: 1px solid #aaa; }pointer-events: none非常重要。它的作用是哪怕玩家点击在棋子正上方事件也不会被子元素拦截而是直接落到棋盘容器上这样后续的坐标换算不受干扰。我第一次写的时候没加这行结果玩家点击已有棋子的位置事件被棋子div捕获clientX还是正确的但如果不小心在棋子上加了事件监听就会出问题为了省心直接加上。2.5 页面整体布局除了棋盘还需要一个信息栏和几个操作按钮。我的布局是一个外层容器棋盘和侧栏并列或者上下排列。实战中我个人喜欢棋盘居中上面有状态提示下面有重新开始按钮。div classgame-wrapper h1五子棋/h1 div idstatus轮到黑方/div div idboard/div button idrestartBtn重新开始/button /div重新开始的逻辑比较简单把二维数组全部重置为EMPTY清空棋盘容器里的所有棋子节点gameOver设为falsecurrentPlayer恢复为BLACK。restartBtn.addEventListener(click, function() { for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) board[r][c] EMPTY; } boardEl.innerHTML ; // 清掉所有棋子 gameOver false; currentPlayer BLACK; statusEl.textContent 轮到黑方; });注意不要用window.location.reload()来重置那样页面会闪烁体验很差。直接清理DOM节点就好。3. 胜负判定算法五子棋的灵魂这个函数是整个项目里最值得细细琢磨的部分。我第一次实现时用了非常笨的办法遍历全部225个位置逐个检查以该位置为起点是否有五连。这样当然能跑但思路不清晰还容易在边界条件上出错。后来我把逻辑重构为以当前落子点为中心向四个方向分别数代码量少一半逻辑也更符合直觉。3.1 为什么是四方向而不是八方向五连的方向只有四种横、竖、撇左下到右上、捺左上到右下。但你往两端数的时候比如横向上既要往左数也要往右数所以看起来像八个方向实际核心方向是四个。我定义一个方向数组每个方向用[dx, dy]表示坐标偏移const DIRECTIONS [ [0, 1], // 水平方向行不变列1 [1, 0], // 垂直方向行1列不变 [1, 1], // 撇方向行1列1 [1, -1] // 捺方向行1列-1 ];为什么每个方向的规则这么简单因为在二维数组里往右移动一格就是列索引加1、行索引不变即(row, col1)往左移动一格就是(row, col-1)。用[dx, dy]表示就是[0, 1]和[0, -1]。注意我们只需要保存一个方向的偏移往反方向数的时候直接对偏移取负即可。3.2 countDirection核心计数函数我写了一个核心函数负责从某个交叉点出发沿某个方向数连续同色棋子的数量。它会同时往正反两个方向数最后相加再减掉自身重复计算的那一次。function countDirection(row, col, dx, dy) { const player board[row][col]; if (player EMPTY) return 0; let count 1; // 当前位置的棋子本身就占一个 // 正向数 for (let i 1; i 5; i) { const nr row dx * i; const nc col dy * i; if (nr 0 || nr SIZE || nc 0 || nc SIZE) break; if (board[nr][nc] ! player) break; count; } // 反向数 for (let i 1; i 5; i) { const nr row - dx * i; const nc col - dy * i; if (nr 0 || nr SIZE || nc 0 || nc SIZE) break; if (board[nr][nc] ! player) break; count; } return count; }这里有两个细节值得说。第一循环上限为什么是i 5因为五子棋只需要判断是否达到5连。比如往一个方向数了4个同色棋子再加上当前位置的1个已经有5个了就没必要再往下数。这样循环最多执行4次整个函数的时间复杂度是常数级的。第二为什么边界和异色要一起判断因为这两个情况都表示这个方向的连续序列到此结束是一个意思。如果不做边界判断比如在棋盘边缘的位置board[-1][col]会访问数组的最后一个元素JavaScript数组负索引不会报错返回undefined导致逻辑混乱。所以每取一次新坐标先检查是否越界。3.3 checkWin一句话串联四个方向有了countDirection胜负判断就非常简单把当前落子点作为中心对四个方向逐一检查只要有任意一个方向的连续数量 5就判定胜利。function checkWin(row, col) { for (const [dx, dy] of DIRECTIONS) { if (countDirection(row, col, dx, dy) 5) { return true; } } return false; }之前我写过一个版本用 5结果发现一个bug如果在一条线上出现了6连比如玩家在意外情况下形成了长连 5判断不出来。五子棋规则里其实长连不算赢正规规则是禁手但我们做休闲游戏长连也应该算赢所以用 5更合理。3.4 验算一下用具体例子走一遍拿一局棋来手动验算。假设棋盘状态如下B为黑子W为白子.为空黑方刚在中间位置落子row 5: . . B B B B .如果落子位置是(5, 4)checkWin(5, 4)会被调用。DIRECTIONS遍历到水平方向[0, 1]时count初值为1。正向(5, 5)是Bcount2(5, 6)是.循环break。反向(5, 3)是Bcount3(5, 2)是Bcount4(5, 1)是.break。返回count4横向上只有4连还没赢。如果落子位置是(5, 3)同样的过程会数出(5, 3)、(5, 4)、(5, 5)三个B然后反向数出(5, 2)这个Bcount5判定胜利。3.5 平局判断棋盘会下满这种情况要提前处理。我写了一个简单函数function isBoardFull() { for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] EMPTY) return false; } } return true; }在placePiece里每落一子都先判断胜利胜利优先没有胜利再判断平局。因为一局棋如果下到第225手才分出胜负两个条件可能同时成立第225手落下后既形成五连又填满棋盘按常理胜利优先所以我先查checkWin再查isBoardFull。4. 进阶玩法给游戏加一个会下棋的AI到这一步你已经有一个能双人对战的五子棋了。但不少人是冲着人机对战来的。这里我分享一下我的人机AI实现思路从最笨的随机落子到会打分的AI一步步升级。4.1 第一版AI随机落子最无脑但能跑的AI就是随机选一个空位落子。代码很简单function aiMoveRandom() { const emptyCells []; for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] EMPTY) emptyCells.push([r, c]); } } if (emptyCells.length 0) return; const [r, c] emptyCells[Math.floor(Math.random() * emptyCells.length)]; placePiece(r, c, WHITE); }这个AI就是个沙包玩家随便下都能赢。但它是理解AI流程的第一步——它证明了AI能通过同样的placePiece流程落子、更新棋盘、触发胜负判断。4.2 第二版AI基于打分的防守型AI真正实用的AI核心思想是给每个空位打分选择分数最高的位置落子。打分要同时考虑进攻我下在这里对我有多有利和防守对手下在这里对对手有多有利。我用了模式打分表连子模式分值说明五连成型1000000直接赢最高优先级活四500000两头开放的四连下一步必赢冲四200000一头开放的四连能逼对方堵活三50000两头开放的三连眠三5000一头开放的三连活二1000两头开放的二连眠二200一头开放的二连活一100单子两头开放眠一20单子一头开放打分公式score attackScore(自己视角下这个位置的价值) defendScore(对手视角下这个位置的价值) * 0.9权重0.9是防守折扣意思是防守时认为自己这一步的价值略低于进攻但差距不大。你可以调这个参数来改变AI的攻击性。我试过权重设为1.2AI会变得特别保守只堵不攻下起来非常无聊设为0.5AI又会像个莽夫只顾自己冲刺不管对手很容易被偷家。0.9是我调出来攻守比较均衡的值。4.3 evaluatePoint评估一个位置在某视角下的价值在打分函数里对某个位置我把你指定的玩家颜色临时放上去然后检查它的四个方向看形成的模式。function evaluatePoint(row, col, player) { // 临时落子 board[row][col] player; let totalScore 0; for (const [dx, dy] of DIRECTIONS) { let count 1; let openEnds 0; // 正方向和反方向分别数 for (let dir 1; dir -1; dir - 2) { for (let i 1; i 5; i) { const nr row dx * i * dir; const nc col dy * i * dir; if (nr 0 || nr SIZE || nc 0 || nc SIZE) break; if (board[nr][nc] player) count; else if (board[nr][nc] EMPTY) { openEnds; break; } else break; } } // 根据count和openEnds给该方向打分 if (count 5) totalScore 1000000; else if (count 4) totalScore openEnds 2 ? 500000 : 200000; else if (count 3) totalScore openEnds 2 ? 50000 : 5000; else if (count 2) totalScore openEnds 2 ? 1000 : 200; else if (count 1) totalScore openEnds 2 ? 100 : 20; } // 恢复原状 board[row][col] EMPTY; return totalScore; }核心机制就是假设这个位置已经下了某方的棋子然后从它出发数四个方向每个方向数出连续棋子数量和开放端数量有几个方向是空的对照模式表给分。等等这里有个细节board[row][col]可能原本是空位也可能在数方向时把临时棋子算进去之后等到数正反方向时dir循环要处理。我代码里正反方向都从i1开始所以当前落子点的临时棋子只初始计入count一次不会被重复数这个细节不处理好会多算一个。4.4 找出当前最优落子点有了evaluatePointAI选点逻辑就很清晰了。遍历所有空位每个空位计算假设我下这的进攻分和假设对手下这的防守分求和选最高分。但这里可以做优化不用遍历全部225个位置。如果棋盘上只剩零星几个棋子那些远离棋子的空位不可能成为有效着法。所以只评估已有棋子附近2格范围内的空位能大幅减少计算量。function getCandidateMoves() { const candidates []; for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] ! EMPTY) continue; // 看周围2格内有没有棋子 let near false; for (let dr -2; dr 2 !near; dr) { for (let dc -2; dc 2; dc) { const nr r dr, nc c dc; if (nr 0 nr SIZE nc 0 nc SIZE board[nr][nc] ! EMPTY) { near true; break; } } } if (near) { const attack evaluatePoint(r, c, WHITE); // AI视角白方 const defend evaluatePoint(r, c, BLACK); // 玩家视角黑方 candidates.push({ row: r, col: c, score: attack defend * 0.9 }); } } } return candidates; }注意一个特殊情况棋盘刚开局时全空near判断会让候选集为空。此时应该让AI优先抢占天元位置棋盘正中心(7,7)那里是五子棋的传统开局要地。所以我在AI主函数里一开始就检测如果所有空位的候选集为空直接落中心。4.5 完整的AI决策流程AI的完整决策流程我放在一个函数里分三步走赢了优先、被威胁时防守、否则攻防评分。function aiMove() { if (gameOver) return; // 第一步自己能一次成五吗能就直接赢 for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] EMPTY) { board[r][c] WHITE; if (checkWin(r, c)) { board[r][c] EMPTY; setTimeout(() placePiece(r, c, WHITE), 300); return; } board[r][c] EMPTY; } } } // 第二步对手能一步成五吗能就堵住 for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] EMPTY) { board[r][c] BLACK; if (checkWin(r, c)) { board[r][c] EMPTY; setTimeout(() placePiece(r, c, WHITE), 300); return; } board[r][c] EMPTY; } } } // 第三步攻防评分 const candidates getCandidateMoves(); if (candidates.length 0) { setTimeout(() placePiece(7, 7, WHITE), 300); return; } candidates.sort((a, b) b.score - a.score); const best candidates[0]; setTimeout(() placePiece(best.row, best.col, WHITE), 300); }第一步和第二步会扫描全部空位做尝试性落子检查虽然每次都调用checkWin但checkWin本身是常数复杂度整个扫描也就225 * 4次操作毫秒级完成没性能压力。为什么用setTimeout包一层因为要给玩家AI在思考的感觉。如果AI瞬间落子游戏显得仓促延迟300ms配合状态栏显示AI思考中...体验会好很多。这是免费的用户体验提升强烈建议保留。4.6 这个AI的水平如何说实话这个AI强度大概能击败完全不会玩的新手跟有点经验的棋手对弈会被压制。原因是它的模式识别只看单个落子点周围的四个方向不考虑多个棋子组合形成的复杂威胁比如双三甚至四三。但它作为一个练手项目的AI已经完全够格了——它能看懂活三、冲四、五连也会主动防守玩家如果没有一定棋力很容易被它偷鸡。如果你之后还想提升AI强度方向有两个增强模式识别。在evaluatePoint的基础上检查一个位置是否同时形成两个活三双三必胜或一个活三一个冲四四三必胜给这些复合棋形额外加分。这部分代码会更复杂我先不展开。引入搜索算法。比如极小化极大加Alpha-Beta剪枝让AI往前多推演两三步。这个方案是五子棋AI的主流但计算量会指数级上升需要控制搜索深度和剪枝策略大概要写两三百行。4.7 人机模式与双人模式的切换界面里我加了一个模式切换按钮。双人模式就是前面写的流程人机模式下当currentPlayer变为AI的一方时自动调用aiMove。function updateStatus() { if (mode pvp) { statusEl.textContent 轮到 (currentPlayer BLACK ? 黑方 : 白方); } else { if (currentPlayer WHITE) { statusEl.textContent AI思考中...; aiMove(); } else { statusEl.textContent 轮到你了黑方; } } }注意updateStatus里触发aiMove后aiMove内部又调用placePieceplacePiece里又调用checkWin和updateStatus形成了一层递归。但因为AI落子在setTimeout里时钟上已经是下一轮事件循环不会造成同步栈溢出。这个细节如果直接调用aiMove()不加setTimeout会导致递归太深时性能问题甚至栈溢出。5. 调试途中踩过的坑实战问题排查实录这一节我把自己实际开发过程中遇到过的典型问题整理成速查表。每一条都是我真实调试经历中搜过、断点打过、改过好几遍才解决的对新手来说价值不低。5.1 棋子位置整体偏了怎么办症状点击后棋子没有落在交叉点中心而是统一往右下角偏移了半个棋子。排查思路这个问题90%出在placePiece里棋子left/top的计算公式。检查是不是忘了减去棋子半径我的代码里是20px。如果觉得公式绕干脆写一个辅助函数function getPiecePosition(row, col) { return { x: MARGIN col * CELL_SIZE - PIECE_SIZE / 2, y: MARGIN row * CELL_SIZE - PIECE_SIZE / 2 }; }MARGIN 22,CELL_SIZE 44,PIECE_SIZE 40。三个常量集中定义比散落在各处硬编码数字好维护得多。5.2 点击不灵敏明明点在线交叉点上却没反应可能的原因有两个。一是坐标换算里忘了减去容器的getBoundingClientRect()位置。如果页面有滚动条e.clientX是相对视口的坐标而棋盘div的style.left是相对页面文档的坐标两者基准不同减去rect.left才能统一。这个坑在页面有滚动或不是从页面顶部开始时特别容易出现。二是我前面提到的距离校验太严格。Math.abs(dx) 22这个阈值如果写成了Math.abs(dx) 0那几乎所有点击都会被拦截因为玩家几乎不可能点得绝对精准。这里阈值设置成半个格距是比较合理的。5.3 五连了却不提示胜利这个问题我排查时发现好几种cause边界条件写错。循环里用i 5而不是i 5导致在边缘位置会访问到越界坐标。虽然JavaScript不报错但board[-1][col]访问到的是最后一个元素负索引会去访问数组的length-1的位置可能意外地等于当前棋子颜色导致判断错误。方向数组漏了一个方向。如果只写[[0,1], [1,0], [1,1]]斜向上的捺方向永远检测不到。检查DIRECTIONS最后一项是不是[1, -1]。count初始值错了。如果把count初值设为0而不是1会把当前棋子漏算结果4连被判断成3连。排查建议写一个辅助测试函数直接手动构造一个已知的五连局面调用checkWin验证。比如function testWinDetect() { board[7][3] BLACK; board[7][4] BLACK; board[7][5] BLACK; board[7][6] BLACK; board[7][7] BLACK; console.log(checkWin(7, 5)); // 期望输出 true }这种测试代码我平时会留在文件末尾改完代码直接按F5看控制台结果。5.4 AI第一步下到了角落而不是中心症状人机模式下AI开局第一手落在了(0,0)或者(14,14)这种边角位置。原因getCandidateMoves里如果没有任何棋子near判断为false候选集为空最终排序取不到任何值可能返回了undefined。我在4.5的aiMove里加了特判候选空时落中心。但如果你的代码里没有加这个特判建议加上这能显著提升AI的开局观感。5.5 重新开始后残留棋子症状点击重新开始后棋盘上还飘着上一个棋局的棋子。原因board数组清零了但DOM节点的清理没做干净。我推荐在restartBtn的事件里直接boardEl.innerHTML 一次性清空所有子节点比遍历删除快得多也更可靠。5.6 本地打开页面时JS不生效如果你直接把HTML文件拖进浏览器打开有时候会遇到脚本不执行的怪问题。这通常是浏览器本地文件的安全策略避免file://协议下的页面执行本地脚本。解决办法有个最省事的用VS Code的Live Server插件或者用Python起一个本地服务python -m http.server 8000然后浏览器访问http://localhost:8000。养成本地开发一律用localhost的习惯以后接触模块化、fetch请求、Canvas字体加载等API时都不会再踩这个坑。6. 完整的单文件代码骨架最后放一个完整可运行的骨架。以我上面的逻辑为基础拼成一个HTML文件你要用时直接保存成gobang.html双击打开即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title网页版五子棋/title style #board { position: relative; width: 660px; height: 660px; margin: 20px auto; background-color: #d9a45b; background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 44px 44px; background-position: 21px 21px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); cursor: pointer; } .piece { position: absolute; width: 40px; height: 40px; border-radius: 50%; pointer-events: none; z-index: 2; } .piece.black { background: radial-gradient(circle at 35% 35%, #555, #111); box-shadow: 2px 2px 4px rgba(0,0,0,0.5); } .piece.white { background: radial-gradient(circle at 35% 35%, #fff, #ddd); border: 1px solid #aaa; box-shadow: 2px 2px 4px rgba(0,0,0,0.3); } #status { text-align: center; font-size: 18px; margin-top: 10px; } .controls { text-align: center; margin: 15px auto; } .controls button, .controls select { padding: 8px 16px; font-size: 15px; margin: 0 6px; cursor: pointer; } /style /head body div classgame-wrapper div idstatus轮到黑方/div div idboard/div div classcontrols select idmodeSelect option valuepvp双人对战/option option valuepve人机对战/option /select button idrestartBtn重新开始/button /div /div script // 把前面小节里的代码按顺序填进来就可以 /script /body /html完整代码细节在前面的小节的代码块里把createBoard、placePiece、checkWin、aiMove等函数按顺序拼进script标签即可。我在实际传给别人使用的时候还会顺手加一个局面信息提示比如黑方获胜改成黑方获胜用时2分15秒这个看个人需求。再补一个小技巧如果你想给这个项目做二次扩展最简单的方向是加复盘回放。不需要额外存储棋谱只要在placePiece里把每次落子的[row, col, player]push到一个历史数组重新开局时按顺序重放大概十几行代码就能实现。我个人一直觉得五子棋这个项目最好的地方就在于——它永远有一个下一步还能加什么的清单从悔棋到AI到联网对战每个扩展都对应一个新的前端知识点。先把基础版跑通剩下的路越走越开阔。