写一个网页版五子棋,我前前后后写过五六个版本。最早是刚学前端时照着教程敲的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 class="game-wrapper"> <h1>五子棋</h1> <div id="status">轮到:黑方</div> <div id="board"></div> <button id="restartBtn">重新开始</button> </div>重新开始的逻辑比较简单:把二维数组全部重置为EMPTY,清空棋盘容器里的所有棋子节点,gameOver设为false,currentPlayer恢复为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, col+1);"往左移动一格"就是(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)是B,count=2;(5, 6)是.,循环break。 - 反向:
(5, 3)是B,count=3;(5, 2)是B,count=4;(5, 1)是.,break。 - 返回count=4,横向上只有4连,还没赢。
如果落子位置是(5, 3),同样的过程会数出(5, 3)、(5, 4)、(5, 5)三个B,然后反向数出(5, 2)这个B,count=5,判定胜利。
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.2,AI会变得特别保守,只堵不攻,下起来非常无聊;设为0.5,AI又会像个莽夫,只顾自己冲刺不管对手,很容易被偷家。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循环要处理。我代码里正反方向都从i=1开始,所以当前落子点的临时棋子只初始计入count一次,不会被重复数,这个细节不处理好会多算一个。
4.4 找出当前最优落子点
有了evaluatePoint,AI选点逻辑就很清晰了。遍历所有空位,每个空位计算"假设我下这"的进攻分,和"假设对手下这"的防守分,求和,选最高分。
但这里可以做优化:不用遍历全部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内部又调用placePiece,placePiece里又调用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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 class="game-wrapper"> <div id="status">轮到:黑方</div> <div id="board"></div> <div class="controls"> <select id="modeSelect"> <option value="pvp">双人对战</option> <option value="pve">人机对战</option> </select> <button id="restartBtn">重新开始</button> </div> </div> <script> // 把前面小节里的代码按顺序填进来就可以 </script> </body> </html>完整代码细节在前面的小节的代码块里,把createBoard、placePiece、checkWin、aiMove等函数按顺序拼进script标签即可。我在实际传给别人使用的时候,还会顺手加一个局面信息提示(比如"黑方获胜"改成"黑方获胜,用时2分15秒"),这个看个人需求。
再补一个小技巧:如果你想给这个项目做二次扩展,最简单的方向是加"复盘回放"。不需要额外存储棋谱,只要在placePiece里把每次落子的[row, col, player]push到一个历史数组,重新开局时按顺序重放,大概十几行代码就能实现。我个人一直觉得,五子棋这个项目最好的地方就在于——它永远有一个"下一步还能加什么"的清单,从悔棋到AI到联网对战,每个扩展都对应一个新的前端知识点。先把基础版跑通,剩下的路越走越开阔。