Canvas五子棋实战:从棋盘绘制到胜负判定的完整实现

发布时间:2026/10/13 4:11:37
Canvas五子棋实战:从棋盘绘制到胜负判定的完整实现 先聊点实在的五子棋这个题材几乎每个前端学习者都会碰上一次。网上搜“Canvas五子棋”出来的代码一大把但很多成品一看就是个半成品——要么赢棋后还能继续落子要么棋盘画得歪歪扭扭要么在Retina屏幕上糊成一片。我自己前后写过三个版本踩遍了各种坑才理出一套清晰完整的实现思路。这篇博文就用HTML5 Canvas从零手写一个五子棋覆盖棋盘绘制、落子交互、胜负判定、悔棋、实时对战扩展这些核心点把每一步的取舍和理由都摊开讲清楚。适合刚学完JavaScript基础、想找一个完整项目练手的初学者也适合想把自己那版五子棋完善起来的同学参考。1. 动手前先定方案为什么选Canvas而不是DOM和CSS1.1 三种实现方案的取舍做一个五子棋不是只有Canvas一条路。最常见的三种做法是纯DOM、SVG、Canvas。我最早那版用纯DOM每个交叉点放一个div棋子用绝对定位的圆形元素结果页面里塞了几百个节点事件绑定密密麻麻加到后面自己都绕晕了。用SVG的话图形化不错但游戏每走一步就要更新大量节点属性逻辑写起来也别扭。Canvas的优势在于整个棋盘就是一个画布线条和棋子本质上是“画”出来的没有几十上百个DOM节点的压力。每一帧重绘都天然干净而且Canvas对动画、粒子效果、后续做棋盘主题变换都友好。五子棋这种场景棋盘固定15×15棋子数量最多也就两百多颗Canvas完全撑得住不会有性能问题。所以我后来一直推荐直接用原生Canvas别过度设计也不需要引入游戏引擎。提示如果只是想要一个能玩的五子棋不要上来就套框架或者引擎。原生JavaScript加Canvas代码量最少逻辑也最直白调试起来反而最轻松。1.2 棋盘尺寸、线距和棋子半径应该怎么定这里有个很常见的设计问题棋盘画多大棋子半径多少有人从网上抄代码抄完发现棋子七零八落不在格点上十有八九是坐标换算没搞明白。我的做法是固定棋盘逻辑边长15格设一个棋盘边距再设一个单元格大小整个画布尺寸由这两个值计算出来。假设边距是margin20格子边长cellSize40那么画布边长就是(14×cellSize)margin×2。为什么是14而不是15因为棋盘两端各有半个格子留白最外两根线到画布边缘也要空出margin。棋子落在交叉点上所以第i行第j列的交叉点坐标为(margin j×cellSize, margin i×cellSize)。棋子半径设为cellSize的0.44倍左右。为什么要留这个余量因为棋子的视觉边界不能紧贴格子线否则相邻两格的棋子看起来会重叠。0.44这个值是我试出来的0.5会显得棋子太大太挤0.4又显小。如果你想要更饱满的视觉效果可以微调到0.46但不建议超过0.48。还有个细节棋盘的线宽。15条横线和15条竖线线宽建议按设备像素比做适配普通屏幕上0.8到1px就够太粗了显得脏。这个我在后面的高清屏适配部分详细说。2. 核心数据结构游戏状态怎么组织才不混乱2.1 棋盘数组和判断轮次的状态设计写游戏最忌讳的是状态散落各处。五子棋的核心状态其实就三样棋盘数据、当前轮到谁、游戏是否结束。棋盘数据我用一个二维数组board15×15初始填0。黑子落子填1白子落子填-1空位保持0。为什么用-1而不是2因为后面做胜负判定时我只需要判断某个方向连线的棋子颜色是否相同用-1和1做乘积或比较都很方便而且判断哪一方胜出时直接看数组值就行。用正负号还有一个好处做AI打分的时候数值的正负可以直接表示阵营。当前轮次用一个变量currentPlayer值为1或-1对应黑子或白子。每落一子就乘-1切换。gameOver标记游戏是否终止负责在后续事件处理里拦截落子。const BOARD_SIZE 15; const board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(0)); let currentPlayer 1; // 1为黑子-1为白子 let gameOver false; let history []; // 用于悔棋这里有个设计点历史栈。悔棋功能必须依赖这个栈每走一步就把{ row, col, player }推进去悔棋时弹出最后一步并清掉对应棋位。如果只存棋盘数组想实现悔棋就得整个数组快照每次占用225个数字的空间虽然也不大但明显不如栈来得优雅。2.2 落子前要做的三件小事点击落子不是“在画布上点一下画个圈”这么简单至少要处理三个问题。第一个是坐标换算。Canvas的click事件拿到的是相对可视区域的坐标clientX/clientY要先减去画布左上角的偏移量getBoundingClientRect()再减去边距并除以格子大小得到行列值。这里必须做四舍五入因为手指或鼠标很难精准点中交叉点。用Math.round()而不是Math.floor()原因很直观点偏上偏下时都能归到最近的格子。第二个是越界和重复落子检查。行列值小于0或大于等于15直接忽略当前棋位不为0说明已有棋子也直接忽略。这两个检查应该在画棋子之前就完成否则会发现画面被奇怪位置的棋子污染。第三个是落子后立即判断胜负而不是等下一次点击再处理。顺序是改棋盘数组存入历史栈重绘整个画面然后调用胜负判定函数。如果赢棋了再设置gameOver并绘制获胜提示。function handleCanvasClick(e) { if (gameOver) return; const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; // 适配CSS缩放 const x (e.clientX - rect.left) * scaleX; const y (e.clientY - rect.top) * scaleY; const col Math.round((x - margin) / cellSize); const row Math.round((y - margin) / cellSize); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; if (board[row][col] ! 0) return; placePiece(row, col, currentPlayer); if (checkWin(row, col)) { gameOver true; drawBoard(); showWinMessage(currentPlayer); return; } currentPlayer * -1; drawBoard(); }另外提醒一句如果你给Canvas设置了CSS样式比如max-width: 100%或者响应式缩放坐标换算时一定要考虑比例。canvas.width是绘图实际像素宽rect.width是CSS显示宽两者不一定相等。最简单的方式就是上面代码里那样乘一个比例系数。2.3 为什么核心状态没有用面向对象有人会问要不要封装一个Game类把状态和方法包起来我的看法是45分钟能写完的小项目用类反而增加序列化、日志记录的复杂度。五子棋的全局状态极其简单函数式写法配合几个顶层变量代码量更少心智负担更小。如果把代码做成可复用的库或者需要多盘游戏对局管理比如锦标赛、房间制那再封装成类也不迟这个看场景走不需要为“代码优雅”硬凹设计模式。3. 绘制细节棋子怎么画才有质感3.1 棋盘底色和网格线的隐藏技巧棋盘不是简单画一张白底就行。真实的木质棋盘带一点暖色视觉效果会好很多。我用#DEB887burlywood作为底色能模拟木色当然你也可以换成更深的#C4A882。绘制顺序很关键先画底色再画网格线然后画星位点最后画棋子。如果顺序反了棋子会被网格线覆盖或者网格线在棋子下面若隐若现观感很差。网格线的绘制用两个循环15条横线15条竖线。这里有个容易忽视的点外侧画线要避让半个格子不然最外一条线会紧贴画布边缘棋子都兜不住。标准棋盘的留白处理方式就是第1节讲的margin机制。星位点也就是棋盘上的黑点标记画在第3行第3列、第7行第7列等五个位置。五子棋的棋盘专业术语里叫“星位”做出来更还原。它的半径很小大概3像素同样用交叉点坐标定位。3.2 棋子用渐变还是纯色纯色画圆当然也能看但棋盘上黑子白子混在一起时纯色棋子区分度不高也没有立体感。我用径向渐变模拟立体棋子核心思路是给每个圆加一圈渐变光晕让棋子看起来有厚度。黑子的实现圆心偏左上位置画一个暗色渐变从深灰到黑色再在棋子右下边缘加一圈微弱的浅色边作为反光。白子则用从白色到浅灰的渐变中心高光明显一点轮廓用浅灰色描边。function drawPiece(ctx, x, y, player) { const radius cellSize * 0.44; ctx.save(); const gradient ctx.createRadialGradient( x - radius * 0.3, y - radius * 0.3, radius * 0.2, x, y, radius ); if (player 1) { gradient.addColorStop(0, #555); gradient.addColorStop(0.5, #333); gradient.addColorStop(1, #000); ctx.shadowColor rgba(0, 0, 0, 0.4); ctx.shadowBlur 4; ctx.shadowOffsetY 2; } else { gradient.addColorStop(0, #f5f5f5); gradient.addColorStop(0.6, #dcdcdc); gradient.addColorStop(1, #b0b0b0); ctx.shadowColor rgba(0, 0, 0, 0.2); ctx.shadowBlur 3; } ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle gradient; ctx.fill(); ctx.restore(); }这里shadow属性会让每一次绘制都带阴影性能有一点开销但棋子最多225颗完全可忽略。更重要的是ctx.save()和ctx.restore()一定要配对否则上一次绘制的阴影或线宽会污染下一次绘制出现莫名其妙的黑边。3.3 高清屏适配不做这个棋盘永远发糊这是很典型的坑。很多人的Canvas五子棋在手机上放大后线条模糊或者文字锯齿明显就是因为没有处理设备像素比DPR。在Retina屏上一个CSS像素对应2个甚至3个物理像素Canvas的默认绘图尺寸是CSS尺寸的1倍所以画出来的图形会被拉大插值自然糊。解决方案是将Canvas的width和height设为CSS尺寸乘以DPR再用ctx.scale(DPR, DPR)把所有逻辑坐标映射回CSS像素空间。这样画出来的线会按物理像素渲染锐利清晰。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);注意这段代码必须在绘制之前执行。如果你现在手里有一个已经写好的五子棋可以检查一下把Canvas的CSS宽度设成300px读取canvas.width如果也是300而不是600或900那它一定在Retina屏上发糊。这个适配写完以后之前第二节提到的点击坐标乘比例系数也需要同步适配两条腿走路才不会乱。4. 胜负判定四方向扫描是最稳方案4.1 判定逻辑的推导过程胜负判定是五子棋项目里我最看重的部分因为它最容易出bug。常见的写法是遍历整个棋盘检查每五个连续棋位是否同色。这种写法代码短但时间复杂度固定为O(225×4)每次落子都要全盘扫描虽然纯数字计算也快但没必要。更高效且更清晰的方案是方向扫描法每次落子后从落子位置出发分别沿横向、纵向、两条对角线共四个方向统计同色棋子的连续数量。只要某个方向连续同色数大于等于5就判定胜出。为什么是从落子点出发因为只有当前落下的棋子才是胜负的关键因素如果这一步不产生五连珠那不管之前有多少连子都不需要处理。实现时最容易被忽略的是双向计数判断横向不能只往右数还应该往左数左右相加再加当前棋子本身才是总连子数。写这个逻辑时要小心边界数组下标从0到14越界就停止。function checkWin(row, col) { const player board[row][col]; const directions [ [0, 1], // 横向 [1, 0], // 纵向 [1, 1], // 主对角线 [1, -1] // 副对角线 ]; for (const [dr, dc] of directions) { let count 1; for (let step 1; step 5; step) { const r row dr * step; const c col dc * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || board[r][c] ! player) break; count; } for (let step 1; step 5; step) { const r row - dr * step; const c col - dc * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || board[r][c] ! player) break; count; } if (count 5) return true; } return false; }很多网上的版本会把dr和dc搞混尤其是在副对角线方向。你看我这个数组里有一项是[1, -1]这代表从当前点向右下走的坐标变化是行加1、列减1。如果你把[-1, 1]也写进去方向就重复了会把同一个方向数两遍导致三连子误判成五连子。这种Bug特别阴写的时候一定要静下心核对四个方向是否互不相同。4.2 禁手规则、长连和胜负提示五子棋在专业规则里有三三禁手、四四禁手、长连禁手但这些是黑棋先手方需要回避的规则。我建议第一次实现时先不做禁手规则直接用“大于等于5连子即胜”的简单规则让项目管理复杂度保持在可控范围。等基本功能全部稳定运行了再作为扩展功能去添加。不过胜负提示是必须做的。游戏结束后在Canvas顶部弹出一条消息或者直接覆盖绘制一段文字告诉玩家是黑棋赢还是白棋赢。我选择了在棋盘上方预留一块状态栏用HTML元素展示当前轮次赢棋后把它切换成胜利文案。这样比Canvas里画文字更简单而且后续想改成多语言或者自定义样式都很方便。还有一种情况需要处理棋盘下满225个棋子但没有人赢即平局。这个在真实对局里不常见但程序里不能漏。我是在每步落子后检查history.length 225如果达到就宣布平局。这个检查只增加一行代码但能避免游戏进入谁都动不了的死状态。4.3 悔棋和重置的边界细节悔棋操作看似简单但有一个边界必须注意游戏结束时不能悔棋。有些玩家输棋想反悔点悔棋后棋子消失但gameOver没重置游戏依然禁用状态体验很怪。所以悔棋函数里要一并重置gameOver。重置一局也需要完整恢复所有初始状态清空棋盘数组、清空历史栈、轮次置为黑棋、gameOver置为false。我习惯把初始化逻辑抽成一个resetGame()函数这样页面加载时调用一次点“再来一局”时也调用它保持唯一入口。如果你把状态初始化代码复制粘贴在事件里改一处忘了另一处大概率会留下很难查的旧状态。function resetGame() { for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { board[r][c] 0; } } history.length 0; currentPlayer 1; gameOver false; drawBoard(); updateStatus(); }另外一个会被忽略的细节悔棋操作允许悔几步规则上无限悔棋当然能做但逻辑上会有人连续悔到自己赢为止。我的处理方式是加上限只能悔一步。现实中朋友之间下棋悔棋通常也不超过一步。如果你做的是练习模式可以允许悔多步这取决于产品定位没有绝对标准。5. 实操过程从空白页面到完整可玩5.1 搭建基础和棋盘绘制我这里用一个简单的方式说明整个开发顺序而不是直接贴几百行代码让你抄。跟着这个顺序走每一步都能验证效果不容易心态崩。第一步创建HTML结构。画布居中上方放一个状态条底部放“重新开始”按钮。Canvas的CSS尺寸先设为640×640实际尺寸由JS根据DPR设置。第二步加载后初始化。写init()函数注册画布点击事件和按钮点击事件调用resetGame()来清空状态并绘制棋盘。这里把画布创建放在页面加载之后执行避免操作到还没有渲染的元素。第三步绘制棋盘函数。先画底色再画网格线然后画星位。这部分完成后你应该能看到一个标准的15×15棋盘。function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #DEB887; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #000; ctx.lineWidth 1; for (let i 0; i BOARD_SIZE; i) { const pos margin i * cellSize; ctx.beginPath(); ctx.moveTo(margin, pos); ctx.lineTo(margin 14 * cellSize, pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, margin); ctx.lineTo(pos, margin 14 * cellSize); ctx.stroke(); } drawStars(); }注意clearRect清除的是物理像素尺寸所以填写canvas.width而不是canvas.style.width否则在高清屏上棋盘底色永远擦不干净会出现重影。这是高清屏适配后最容易踩的坑。5.2 绘制棋子并记录状态第四步把之前的drawPiece函数加进来并和handleCanvasClick联动。这时候棋盘数据、轮次、棋子绘制就打通了。点一下画一个黑子再点一下画一个白子逻辑上正确执行。如果这一步你发现棋子位置跑偏优先检查Math.round是否用成了Math.floor或者坐标换算时忘了减margin。第五步加上checkWin和悔棋功能。这个时候已经是一个完整可玩的单机双人游戏了。建议在这个节点自己先玩几局测试正常对局、边缘落子、快速连点、悔棋等操作确保逻辑稳。5.3 视觉细节和交互体验优化有了基础版本以后再回头打磨体验。我会做三件事一是给最近一颗落子加标识。用一个小红圈或者不同颜色的点画在最新棋子的边缘让对局双方一眼看出谁刚走了哪一步。实现方式是在drawBoard()末尾检查history的最后一项如果是有效棋位就额外绘制一个小圆点。二是状态条文案的实时更新。显示“黑方回合”或“白方回合”赢棋后切换成“黑方胜利”或“白方胜利”点击重新开始后恢复。这个用一行textContent赋值就能搞定但能让整个项目的完成度上一个台阶。三是增加落子音效。这是加分的体验项用Web Audio API合成一个短促的音频反馈。不需要引音频文件核心是让操作有反馈。如果不想引入复杂的音频处理也可以在落子动画上用半透明闪烁效果代替一样提升手感。function playMoveSound() { const audioCtx new (window.AudioContext || window.webkitAudioContext)(); const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value 420; oscillator.type sine; gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime 0.08); oscillator.start(); oscillator.stop(audioCtx.currentTime 0.08); }注意每次点击创建一个新的AudioContext频繁操作有内存开销。更稳妥的写法是全局复用同一个AudioContext这里只是给出一个最小实现供参考。6. 常见问题与排查实录6.1 棋子位置总是偏移一两个像素这是坐标换算的经典问题。三个高频原因忘了减去margin用了Math.floor而不是Math.round没有乘DPR比例系数。排查时先在点击事件里打出row和col的值和鼠标实际位置对照基本十秒钟定位。还有一个隐蔽问题Canvas元素的CSS如果被设置了bordergetBoundingClientRect()返回的坐标体系会和clientX/clientY产生一个边距偏差。解决方式是给Canvas设border: none或者减去border宽度。6.2 赢棋判定在边界失效经常出现这种情况竖着摆五个子赢棋没提示横着摆五个有提示。这通常是数组方向和坐标轴搞反了。比如你检查纵方向时同时用了row1和row1正反两个方向其中一个方向越界后跳出循环计数少了一个。排查方法很简单沿着棋盘的第0列和第14列各摆五个子测试一遍立刻定位是哪个方向的边界判断写错了。6.3 高清屏下画出重影或模糊如果之前没有做DPR适配解决方式就是第三节提到的方法。如果已经做了适配还是有模糊检查一下ctx.scale()是否在每次drawBoard()前被重复调用。因为scale是累积的每画一帧都缩放一次画面会越来越偏移甚至消失。解决办法是只在初始化时执行一次缩放或者在clearRect前先ctx.setTransform(1,0,0,1,0,0)重置变换矩阵。6.4 加点AI对战最简单的评分算法如果你完成了双人版想扩展成“人和电脑对战”我的建议是先别碰复杂的极大极小搜索。最简单有效的方式是打分法遍历每个空位分别假设黑棋和白棋落在这里计算这个点的四个方向的连子潜力给一个分数。连子数量越多分数越高然后电脑选择分数最高的点落子。这个AI水平大概有中级入门棋力能挡住新手但对高水平玩家肯定不够。优点是逻辑很直观四十行代码左右就能完成。它的计算公式大约长这样function evaluateDirection(board, r, c, player) { let score 0; // 统计四个方向上的连子数和空位数然后累加 // 连子数为3且两端都有空位给个较高分 // 连子数为4且一端为空给个高分 // 具体分值需要大量试玩调整 }AI调参是个无底洞我给一个经验值单子1分活二10分活三100分冲四1000分活四10000分成五直接100000分。前面那些数字可以微调成五的优先级必须绝对最高否则AI会眼睁睁看着你连五子而跑去堵一个活三那是灾难性体验。另一个细节是电脑落子后要有思考延迟比如500毫秒否则它会瞬间响应人类玩家看起来像作弊而且玩家感受不到“对抗”的节奏。实现方法很简单在人类落子触发AI决策时用setTimeout包裹电脑落子逻辑。6.5 网络对战能扩展吗想变成联机对弈有两个方案。第一种是极简的“传递坐标”方案两端通过某种实时通信通道互相发送每一次落子的坐标收到坐标后调用placePiece函数。关键难点是同步“轮到谁”和“落子合法性校验”——绝对不能信任对方传来的坐标而不做服务端校验否则作弊太容易了。第二种是完整的服务端房间机制需要处理房间创建、加入、断线重连、观战等逻辑复杂度不是一个量级的。我的建议是前者作为入门联机版本跑通以后再说复杂方案。服务端推荐用原生WebSocket或者Socket.io数据包设计核心就三个字段类型、房间ID、行列坐标。这样一个五子棋联机版的核心框架就能撑起来。7. 一些我踩过并希望你能避开的坑最后分享几条贯穿项目全程的经验。第一变量命名一定要克制。我在第一版里用了b、p、c这种缩写写的时候很爽第二天回来调试完全看不懂逐行解释才发现自己写的啥。后来统一改成board、currentPlayer、cellSize这些完整单词灵魂都通透了。这个项目规模再小也值得用清晰的命名。第二Canvas重绘的粒度。我在悔棋功能里一开始是只重绘那颗被移走的棋子的区域用clearRect擦掉局部再画。后来发现频繁的局部重绘在边界条件下特别容易出错比如擦掉的区域和棋盘线重叠导致棋盘线断了一截。最终选择了最简单粗暴的方案每次操作后整体重绘整个棋盘。别担心性能这个量级的绘制对现代浏览器来说是毛毛雨。第三测试要刻意做“脏操作”。比如快速双击同一个位置、连续点悔棋、在已经结束的棋局里乱点。这些操作普通人不会做但代码里如果没有防御就会出bug。我学到的是凡是用户能触发的操作函数入口都要检查状态合法性。这比把游戏逻辑写得花哨更重要。做一个Canvas五子棋技术栈不复杂但麻雀虽小五脏俱全。我从这个项目里学到的不只是Canvas API更是“一个完整游戏如何从零到一闭环”的开发思路——状态管理、用户交互、边界处理、性能优化每一环都在这个小项目里有体现。把你手上的版本跑起来照着这个思路从棋盘画起很快你也会有一个拿得出手的完整作品。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询