☰
用Canvas和JavaScript从零实现五子棋人机对战:核心算法与工程实践解析
2026/10/14 5:30:26 网站建设 项目流程

最近把之前写的五子棋网页版小游戏翻出来重构了一遍,踩了不少当年的坑,也顺手补上了人机对战、悔棋这类功能。趁着这次重构,把整个实现思路一次性梳理清楚。

作为web前端领域非常经典的小型实战项目,五子棋麻雀虽小五脏俱全:Canvas绘图、事件绑定、状态管理、算法判定,再到简单的AI决策,几乎把前端基础能力都串了一遍。这篇内容适合刚学完HTML/CSS/JavaScript、想找一个完整项目练手的人,也适合做了段时间业务开发、想补一补前端基础的同学。照着代码能抄出一个能玩的五子棋网页版小游戏,更重要的是知道每一步为什么这么设计,遇到问题去哪里排查。

1. 为什么拿五子棋当练手项目

1.1 一个项目串起前端基础能力

五子棋这类小游戏,在web前端项目里属于“中等难度”的练手对象。它不像静态页面那样只靠CSS就能堆出来,也不像大型后台系统那样需要提前设计复杂的数据流。它的边界非常清晰——你就围绕一个棋盘做事情。

但正是这个看起来简单的棋盘,牵涉到前端基础里最常遇到的几类问题:Canvas的绘制与重绘、浏览器事件的处理与防抖、二维数组和坐标换算、状态流转与UI同步,最后还有一点算法在里面。做完一个完整的五子棋,等于把原生JavaScript的基础又过了一遍。

而且五子棋和其他前端小游戏的区别在于:它的“不可见逻辑”比“可见画面”更复杂。棋盘上画了几条线并不是难点,真正的难点是数据层怎么设计、AI怎么评分、悔棋怎么回退。这些恰好是实际项目中天天要面对的东西。

1.2 先拆需求,再动手写代码

很多同学拿到项目直接开写,写到一半才发现漏了需求。我在动手前先列了一个功能清单,再根据清单决定模块划分。

功能模块说明是否必须
双人对战两人轮流在浏览器上落子是
人机对战玩家执黑先行,AI执白应战,也可切换先后手是
落子校验已落子的交叉点不能重复落子是
五连判定横、竖、两条斜线任一方向连成五个即获胜是
悔棋双人模式悔一步,人机模式回退玩家与AI各一步建议有
重新开局清空棋盘并重置状态是
胜负提示对局结束后锁定棋盘,并提示获胜方是

这个清单看起来简单,但它决定了代码结构。比如悔棋功能如果一开始不加,那数据结构就不需要历史栈;如果后面再补,就要改棋盘数据层和绘制层。我更建议第一次做的时候就按完整需求来,哪怕功能简单,结构也可以是完整的。

2. 棋盘绘制:Canvas选型和坐标体系的坑

2.1 为什么不用DOM画棋盘

画棋盘有两个常见的方案:一个是用DOM网格,一个是Canvas绘制。

DOM方案就是循环生成15×15的div,每个格子用一个元素表示。优点是开发简单、方便调试,点击事件直接绑在每个格子上;缺点也很明显——249个节点(15×15)加上棋盘线,页面节点数量多,性能很差,而且每次落子都要操作一堆DOM节点,代码写起来非常啰嗦。

Canvas方案则是一块画布一次性绘制棋盘的线、星位和所有棋子。它只维护一个canvas元素,重绘时直接调用Paint方法从头画一遍,性能开销可控,代码也更集中。我的建议是:做这种棋盘类游戏,默认选Canvas,除非你有很强的DOM动画需求。

2.2 网格坐标系:以交叉点为准,不是以格子为准

五子棋的落子点是交叉点,不是方格中心。这一点如果一开始没想清楚,后面所有坐标换算都会乱。

以15路棋盘为例,横向和纵向各有15个交叉点,两条相邻线之间的距离就是一个格子宽度。我通常把棋盘四周留出一个格子宽度的边距,这样棋盘整体居中,视觉上也更舒服。

假设格子宽度用cellSize表示,边距用padding表示,那么第i条横线和第j条竖线的交叉点坐标就是:

const x = padding + j * cellSize; const y = padding + i * cellSize;

鼠标点击后换算成行列索引,本质上是把鼠标相对canvas左上角的距离,转换成最近的交叉点索引:

const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const col = Math.round((mouseX - padding) / cellSize); const row = Math.round((mouseY - padding) / cellSize);

注意这里用的是Math.round而不是Math.floor。因为点击位置落在某个交叉点周围一定范围内,都算这个交叉点,四舍五入比向下取整更符合直觉。把落子点到交叉点的逻辑单独封装成一个函数,后面调试坐标问题会特别方便。

绘制棋盘线的代码可以这样写:

function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 画横线和竖线 for (let i = 0; i < 15; i++) { ctx.beginPath(); ctx.moveTo(padding, padding + i * cellSize); ctx.lineTo(padding + 14 * cellSize, padding + i * cellSize); ctx.stroke(); ctx.beginPath(); ctx.moveTo(padding + i * cellSize, padding); ctx.lineTo(padding + i * cellSize, padding + 14 * cellSize); ctx.stroke(); } // 四个角附近和中心的星位 const positions = [3, 7, 11]; positions.forEach(i => { positions.forEach(j => { ctx.beginPath(); ctx.arc(padding + i * cellSize, padding + j * cellSize, 4, 0, Math.PI * 2); ctx.fill(); }); }); }

2.3 高分屏适配和棋子外观

第一次写完,我发现自己画出来的棋盘线和棋子都是模糊的。原因很简单:Canvas的CSS尺寸和它内部的绘图缓冲区尺寸不一致。比如CSS里写width: 480px,但canvas元素默认的绘图区只有300×150,浏览器会把绘图区拉伸,导致线条变虚。

解决办法是读取设备的devicePixelRatio(设备像素比),让canvas的实际宽高等于CSS尺寸乘以dpr,再用ctx.scale(dpr, dpr)绘制,这样在Retina屏上就不会模糊。下面这段代码可以放在初始化阶段:

const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; ctx.scale(dpr, dpr);

棋子本身也别直接用纯色圆。黑子用一个径向渐变模拟高光,再加一点阴影,立体感立刻就出来了。白子更要注意,直接用白色圆形在浅色木纹背景上会看不清,最好加一个深色描边。

3. 核心对局逻辑:落子、轮转和五连判定

3.1 棋盘数据模型:二维数组

棋盘的数据结构一定要独立于UI。我的习惯是定义一个chessData二维数组,大小为15×15,初始值全部为0。0表示空,1表示黑子,2表示白子。

const boardSize = 15; let chessData = Array.from({ length: boardSize }, () => Array(boardSize).fill(0)); let currentPlayer = 1; // 1黑棋,2白棋 let history = []; // 每一步行棋记录 let gameOver = false;

为什么单独维护一个二维数组而不是直接读Canvas像素?因为Canvas一旦清空重绘,像素信息就没了,而且从像素判断状态非常不可靠。数据层和UI层分离,是这个小项目里最重要的设计思路。

有了数据层之后,落子变成了三个清晰的步骤:修改数组、画棋子、切换玩家。UI可以随时根据数组重建,悔棋也只需要回退数组状态再重新绘制。

3.2 从点击到落子的完整流程

在Canvas上绑定的click事件,核心逻辑是:把点击位置换算成行列,判断位置合法性,修改数组,然后重绘或者直接绘制一枚棋子。

canvas.addEventListener('click', function (e) { if (gameOver) return; const { row, col } = getPosition(e); if (row < 0 || row >= boardSize || col < 0 || col >= boardSize) return; if (chessData[row][col] !== 0) return; chessData[row][col] = currentPlayer; history.push({ row, col, player: currentPlayer }); drawPiece(row, col, currentPlayer); if (checkWin(row, col, currentPlayer)) { gameOver = true; showResult(currentPlayer); return; } currentPlayer = currentPlayer === 1 ? 2 : 1; if (isAiTurn()) { setTimeout(() => aiMove(), 50); } });

边界校验一定要放在“修改数据”之前。如果不先判断坐标越界,用负数索引访问数组返回undefined,紧接着的判断undefined !== 0会直接通过,导致数组被写入非法数据,后面绘制阶段就会崩溃。

3.3 五连判定:四方向扫描

五子棋的胜利条件是在横、竖、两条斜线四个方向上出现连续5个同色棋子。判定的核心是从当前落点出发,沿着一个方向的两个相反方向统计连续棋子数量,两边加起来再加自身数量,达到5即获胜。

四个方向可以用方向向量表示:横向[0, 1],竖向[1, 0],主对角线[1, 1],副对角线[1, -1]。

function checkWin(row, col, player) { const dirs = [ [0, 1], [1, 0], [1, 1], [1, -1] ]; for (let [dx, dy] of dirs) { let count = 1; // 正方向 for (let step = 1; step <= 4; step++) { let nr = row + dx * step; let nc = col + dy * step; if (nr < 0 || nr >= boardSize || nc < 0 || nc >= boardSize) break; if (chessData[nr][nc] !== player) break; count++; } // 反方向 for (let step = 1; step <= 4; step++) { let nr = row - dx * step; let nc = col - dy * step; if (nr < 0 || nr >= boardSize || nc < 0 || nc >= boardSize) break; if (chessData[nr][nc] !== player) break; count++; } if (count >= 5) return true; } return false; }

这里判断条件用的是count >= 5而不是=== 5。因为六连、七连长连在大多数民间规则下同样算获胜,除非你要严格实现有禁手规则的专业玩法。用>=最省事,也符合普通玩家对五子棋的理解。

3.4 对局状态机

游戏进行中需要一个状态标记gameOver。它的作用不只是提示,更是防止游戏结束后继续落子。如果不需要状态机,玩家对局结束后再点击棋盘,还能继续下子,违不违法就完全看你有没有这行判断。

我习惯再加一个busy标志位来防止AI计算过程中玩家连续点击。AI落子虽然用了setTimeout,但这50毫秒内事件循环可能积压点击,如果每次都合法落子,AI还没走,玩家已经连下好几手。用busy在AI思考期间屏蔽点击,体验会好很多。

4. 让电脑陪练:从评分AI到简单对战策略

4.1 难度定位:先做个“能下且有压迫感”的AI

做AI的时候,很多人第一反应是上minimax(极小极大算法)。但五子棋的状态空间太大,15×15棋盘上每一步候选点非常多,完整搜索对新手项目来说属于过度工程化。

我的建议是:第一版用一个基于评分函数的贪心AI。思路很简单:遍历所有空位,给每个空位打一个分数,挑分数最高的点落子。这个AI玩起来像一个正常对手,该防守的时候会防守,该进攻的时候会进攻,代码量在40行左右,新手可以完整理解每一行的作用。

4.2 评分函数:给每个空位打一个分

评分函数是AI的核心。对于某个空位,分别沿着横、竖、两个斜方向,统计如果在这里放上一枚棋子后,能形成多长的连续子串,以及两端是否开放。

我用的评分规则是:

局面特征分数
活四(两端都开放的连续4子)100000
冲四(只有一端开放的连续4子)50000
活三(两端都开放的连续3子)10000
眠三(只有一端开放的连续3子)1000
活二(两端都开放的连续2子)500
眠二(只有一端开放的连续2子)100
单子10

这个评分表是经验值,不是数学公式。它的意义在于:让AI优先把自己变成活四,其次是阻止对手变成活四,再其次是制造活三。按这个优先级走,AI的下法已经相当自然了。

4.3 攻防结合:进攻分与防守分

AI做决策时,同一个空位需要算两个分数:一个是AI自己走这个点的进攻分,另一个是如果对手走这个点的威胁分。最终得分一般是两者加权:

const finalScore = attackScore * attackWeight + defenseScore * defenseWeight;

如果attackWeight高,AI就更激进,优先下自己的进攻点;如果defenseWeight高,AI就更保守,优先堵玩家。我在默认实现里把进攻和防守权重设为相等,这样AI的行为更像一个正常棋手:有进攻机会就进攻,没有就防守。

要注意的是,防守分不完全是“对手在这个位置的进攻分”,更准确说应该是:假设轮到自己走,可以消除的对方威胁有多严重。一种简化实现是,遍历对手所有“如果落子就能形成活三/冲四”的位置,给AI当前评分加上对手攻势分值。

function evaluatePoint(row, col, player, opponent) { // 模拟自己落子 chessData[row][col] = player; let attack = getScore(row, col, player); // 模拟对手落子 chessData[row][col] = opponent; let defense = getScore(row, col, opponent); // 恢复空位 chessData[row][col] = 0; return attack * 1.0 + defense * 0.9; }

getScore是统计四个方向上连续子数和开放程度的函数。模拟落子再统计,是最直白的实现方式,虽然多花一点时间,但逻辑正确性最容易保证。

4.4 AI实测与调参心得

默认AI走出来的棋风偏防守。玩家开局的几次“活二”都会被它注意到,然后提前堵住。但它也会犯一种经典错误:在优势明显时不主动形成双活三,而是继续搭自己的单子。

原因是评分函数只考虑“当前这一步”,不考虑多步之后形成的复合攻势。如果想提高强度,可以给AI加一个两步前瞻:枚举自己下一步所有落点,再枚举对手的应手,评估两步之后的局面分。这个思路比直接上minimax更容易理解,而且只增加了一层循环,性能上仍然可以接受。

另外,开局阶段AI老爱往棋盘边缘跑,因为它认为边角的空位“开放性”低,分数低。实际下棋的人都知道,开局要抢中心,所以我在开局四手之内给AI加了一个中心偏好权重。36个候选点里优先选择离中心近的位置,模拟效果好了很多。

5. 体验优化:悔棋、重置、轮转和移动端适配

5.1 悔棋为什么不是“撤销最后一步”那么简单

实现悔棋需要一个历史栈。每次落子时把{ row, col, player }压入history数组,悔棋时pop出来,把棋盘对应位置重置为0,再重绘。

难点在人机模式:玩家点悔棋时,通常期望的是“把刚才我和AI这一步都退回去”。所以你不仅要悔掉玩家的最后一步,还要顺带悔掉AI的最后一步,才符合直觉。

function undo() { if (gameOver) return; if (history.length === 0) return; const step = history.pop(); chessData[step.row][step.col] = 0; if (isAiMode && history.length > 0) { const aiStep = history.pop(); if (chessData[aiStep.row][aiStep.col] === aiStep.player) { chessData[aiStep.row][aiStep.col] = 0; } } // 重绘整个棋盘,保证UI和数据一致 drawBoard(); drawAllPieces(); // 重置当前轮到谁 currentPlayer = history.length % 2 === 0 ? 1 : 2; gameOver = false; }

注意这里每次都调用drawBoard把棋盘线重画一遍,再drawAllPieces根据数组把所有棋子重画一遍。一开始我图省事,只把悔棋那一步的棋子擦掉,结果遇到AI连续落子时,UI上的残留棋子无法完全清除,状态就乱了。数据驱动UI,宁可多画一遍,也不要维护“差异状态”。

5.2 重置与轮转

重新开局时,把chessData重新初始化、清空历史、重置当前玩家、设置gameOver = false,然后重绘棋盘。这个逻辑可以单独写成一个resetGame函数。

轮转的意思是让玩家选择自己执黑还是执白。实现起来很简单:人机模式下,如果玩家选执白,那么开局currentPlayer就设置为2,AI直接先手落子。增加这一个选择,能让五子棋的可玩性高不少——同样是下棋,先手和后手的策略完全不同。

5.3 移动端适配:从click到touch

很多第一次做小游戏的开发者只考虑桌面端,但五子棋非常适合在手机上玩。移动端适配需要做三件事。

第一,viewport设置,保证页面不缩放:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

第二,事件绑定。移动端click事件有300毫秒左右延迟,体验很差。更好的做法是把click替换为touchstart或touchend,用e.touches[0].clientX获取触点坐标。为了防止滑动页面时误触发落子,可以在touchmove时取消本次操作,并调用e.preventDefault()。

第三,Canvas的CSS尺寸用百分比或者vh/vw自适应,但内部逻辑坐标保持固定不变,通过canvas.getBoundingClientRect()计算实际缩放比例。

5.4 防呆细节

像五子棋这种交互型页面,防呆设计很大程度上决定了观感。我最后补充的几个细节,都来自用户反馈:

  • 电脑落子过程中,点击棋盘无效;
  • 游戏结束后再点击棋盘,不弹任何提示,也不改变棋盘状态;
  • 胜负提示框弹出后,必须手动关闭才能继续交互;
  • 棋盘两侧点击区域过小时,用Math.round扩大交叉点命中范围,精确点很难点中。

这些细节单独看都不复杂,但组合起来就是“这个游戏能玩”和“这个游戏很流畅”的区别。

6. 踩坑实录与后续扩展思路

6.1 我重构时踩过的四个坑

第一个是Canvas模糊问题。现象是棋盘线条模糊成一片,棋子边缘发虚。排查时我先打印了canvas.width和canvas.style.width,发现一个是480,一个是960,确认是绘图缓冲区和CSS尺寸不一致。最后按devicePixelRatio重新设置画布尺寸解决。

第二个是点击位置偏了。现象是落棋子总落在鼠标点击点的左上方。排查时我在落子前把换算结果打印出来,发现getBoundingClientRect()返回的位置包含了父容器的边框。修复方法是用canvas自身的边界矩形计算相对坐标,而不是用pageX - canvas.offsetLeft。

第三个是人机模式悔棋后,AI的棋子还在棋盘上。排查思路是:数据层已经清空,但UI层没有重绘。如果只依赖Canvas的局部清除,很容易漏掉叠加绘制的棋子。后来统一改成“先清空整块画布,再根据数组重新绘制所有棋子”,问题彻底消失。

第四个是双击导致连续落两子。原因是click事件在部分环境下触发两次,而我的代码里没有防御。解决办法是落子函数内部加一个busy标志位,落子结束后同时延迟松开标志位,把100毫秒内的第二次触发拦掉。

6.2 后续还能怎么扩展

第一版稳定后,可扩展的方向很多。

如果想让AI更强,可以把评分函数升级成模式匹配,比如识别双活三、双四等复合棋形,或者加入一层搜索树,用带深度限制的前瞻搜索替代纯贪心。

如果想做联机对战,用WebSocket做房间匹配和消息转发即可。消息格式可以简单设计为{ type: 'move', row, col }和{ type: 'giveUp', reason: 'timeout' },前端负责渲染同步状态。

如果想更贴近专业规则,需要实现禁手识别:黑棋不允许走三三、四四、长连。这些都属于确定性的模式判断,在现有评分函数基础上增加一个禁手校验函数就能搞定。

我在实际使用中发现,五子棋这个项目最大的价值不是让你背会某个API,而是逼迫你把棋盘数据、界面绘制、玩家操作这三层关系理顺。先设计数据模型,再画界面,最后接交互,这个顺序走一遍之后,再去做其他前端项目,状态管理心里会踏实很多。最后分享一个小技巧:如果你的AI总是思路太直白、每次都能精准堵你,给最终分数加一个极小概率的随机偏移,反而能让对局更像真实对手。看似“变笨”,实际“变活了”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询