资讯详情

纯JavaScript+Canvas实现五子棋:从棋盘绘制到胜负判断的核心技术解析

📅 2026/10/15 16:53:33 | 华诺云谱 👁 阅读
纯JavaScript+Canvas实现五子棋:从棋盘绘制到胜负判断的核心技术解析
做课题设计的时候老师给了个自由度前端方向可以自己挑题目做。我当时没选那些花哨的博客系统、后台管理系统而是选了基于HTML的五子棋游戏。原因很简单这是一个难度曲线非常舒服的题目界面、交互、逻辑全都具备又没有复杂到需要依赖框架和脚手架纯HTMLCSSJavaScript就能把整套东西跑起来。这篇文章就把我做这个项目时的设计思路、核心代码和踩过的坑整理一遍给正在做同类课题设计、或者想用一个小游戏练手前端的同学一个参考。后面的内容不光是讲代码怎么写更重要的是把“为什么这么做”讲清楚方便你写文档、答辩的时候有话可说。1. 整体设计思路这个五子棋项目到底怎么做1.1 技术选型从需求反推工具的取舍做课题设计第一件事不是写代码而是先想清楚用什么技术方案。五子棋这个题目看起来简单但实现方式至少有三种纯DOM操作摆格子、Canvas绘制、或者直接用现成的游戏引擎和框架。我最终选了Canvas不是因为它是最新最酷的而是因为它最适合这个场景。如果用DOM方案棋盘可以是15x15共225个div每个交叉点放一个元素落子就是给对应的div加一个子元素或改背景。这个方案代码逻辑直白但性能一般而且绘制棋盘线、处理棋子位置会写很多重复的CSS。Canvas方案要清爽得多棋盘线循环绘制棋子用圆形绘制整个绘画行为集中在一个画布上数据结构清晰以后就算加AI、加特效Canvas都有足够的扩展空间。至于为什么不用Vue或React原因也很实际。课题设计答辩时老师大概率会问你某个功能是怎么实现的你总不能拿着一张虚拟DOM diff的图跟老师讲半天框架原理。原生三件套不依赖构建工具双击HTML文件就能在浏览器里跑演示环境零配置这对答辩现场来说是最稳的。1.2 功能拆解哪些功能是必需品哪些是加分项做课题最容易犯的错是一上来就想把功能堆齐结果代码写了一半发现做不动了。我的习惯是先列需求清单分清楚哪些是必需品、哪些是加分项。必需品是这些棋盘绘制必须是标准15x15交叉点棋盘双人轮流落子黑棋先手不能在同一位置重复落子胜负判断任何一方连成五子立即结束重新开始加分项可以有很多悔棋、步数记录、当前玩家提示、最后一手标记、棋盘持久化、人机对战。加分项不是越多越好而是要在核心逻辑稳定的前提下逐步叠加。我最后做了悔棋、当前玩家提示和最后一手标记这三个功能加完以后整个项目的“完成度”马上不一样了演示效果也好看很多。1.3 模块划分别让逻辑代码全堆在一起很多课程设计的代码一眼看过去就是一个HTML文件里几百行互相纠缠的脚本出了bug根本没法查。我在动手之前先把项目拆成了四层状态层只用三个变量。board是15x15的二维数组0代表空、1代表黑棋、2代表白棋currentPlayer记录当前轮到谁history是数组记录每一步的落子坐标和玩家用来支持悔棋。绘制层负责所有Canvas绘制包括棋盘背景、网格线、星位、棋子。绘制层只读状态层的数据不做业务判断。交互层只负责监听鼠标点击和按钮事件把点击位置换算成行列坐标然后调用逻辑层函数。逻辑层落子合法性检查、胜负判断、悔棋处理、重置游戏。逻辑层不直接碰DOM和Canvas。这样分层之后最大的好处是每个函数职责单一出了问题定位极快。比如棋盘上棋子画错了肯定是绘制层的问题明明没连成五子却提示胜利那去查逻辑层的checkWin函数就行。这个分层习惯放到以后做任何前端项目都适用写文档的时候也能直接画一张架构图。2. 核心细节解析棋盘、落子与胜负判断2.1 棋盘绘制参数尺寸、边距与星位的关系棋盘是整个游戏的视觉基础参数没算好后面所有坐标映射都会跟着乱。我定的参数是15路棋盘格子边长CELL_SIZE30像素边距MARGIN30像素。这里边距不是随便选的它要保证最外侧棋子的中心不贴到画布边缘留出手感上的视觉余量。画布尺寸的计算公式是canvas.width CELL_SIZE * (BOARD_SIZE - 1) MARGIN * 2;注意这里是乘(BOARD_SIZE - 1)而不是BOARD_SIZE。15路棋盘有15条线和15个交叉点但相邻交叉点之间的间距只有14段这一点特别容易算错。如果按15去算棋盘就会整体偏大最外圈线条会跑到画布外面去。棋盘底色我选了偏木色的暖色调接近实体棋盘纸的颜色。网格线用深灰色线条宽度1px每画一条线都要用beginPath开头否则canvas会把多条线连成一条路径出现线条变粗或者颜色不对的诡异效果。星位是围棋棋盘上的那五个点虽然五子棋规则不强制要求有星位但从视觉上加上会更专业。标准星位坐标在3、7、11这三个位置用二维坐标写出来就是(3,3)、(7,7)、(11,11)、(3,11)、(11,3)。画星位就是在对应交叉点上画一个半径4像素的小实心圆。2.2 鼠标点击转坐标从像素到格子的换算Canvas监听点击事件时拿到的坐标是像素坐标但棋盘数据是行列坐标中间这层换算如果搞错点下去棋子就会跑到旁边甚至画布外面。换算逻辑很简单用点击坐标减去边距再除以格子边长取整。这里有一个关键细节用Math.round还是Math.floor。Math.floor是把点击位置映射到“当前格子的左上角之前”而Math.round是找到“最近的交叉点”。我们的棋盘线画在交叉点上所以必须用Math.round这样才能保证你点到两个交叉点中间区域时棋子落在离你手指更近的那条线上。换算代码大概是这样的canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; const col Math.round((x - MARGIN) / CELL_SIZE); const row Math.round((y - MARGIN) / CELL_SIZE); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; placePiece(row, col); });特别提醒一点如果页面里给Canvas设置了CSS宽度缩放getBoundingClientRect返回的值会和Canvas内部逻辑尺寸不一致导致坐标换算错误。稳妥的做法是保持CSS尺寸和Canvas属性尺寸一致或者用比例换算。课程设计阶段直接用原生尺寸最省心。2.3 落子绘制棋子的画法其实有讲究画棋子看起来简单一个圆而已但直接画死板的一个实心圆效果会很单薄。我画棋子时用了双层圆形来模拟立体感先画一个半径是格子边长0.42倍的大圆作为底座填充纯色再画一个半径缩小到0.7倍的小圆填充稍微浅一点的颜色。这样棋子从视觉上会有一点点凸起的感觉棋子的边缘也更清晰。我尝试过用渐变色做更逼真的效果比如径向渐变模拟光照效果确实更好但会让绘制函数复杂一点。如果赶课题设计双层圆已经够用如果追求视觉效果可以在双层圆的基础上加一个径向渐变代码只多三行。还有一个提升演示体验的做法标记最后一手。每次落子后给刚下的棋子画一个红色的细圆圈玩家和评审老师都能一眼看出当前局面里最新的一步棋是哪个观看体验会好很多。2.4 胜负判断只扫落子点附近不用全盘扫描五子棋的胜负判断最容易想到的方案是每次落子后遍历整个棋盘检查每个格子四个方向上有没有五连。这个方案也叫“全盘扫描”听起来简单但不优雅逻辑也更绕。我的做法是反向思考只有刚落的这步棋可能形成五连所以只需要从刚落子的位置出发向四个方向分别数同色棋子的数量总数大于等于5就赢。四个方向用方向向量表示const directions [[0, 1], [1, 0], [1, 1], [1, -1]];分别对应水平向右、垂直向下、右下、右上。每个方向都从落子点出发向正方向数一段再向反方向数一段累加连续同色棋子的数量。这里要特别注意数组越界检查游戏棋盘边缘是越界事故的高发区越界后数组访问返回undefined你拿undefined和棋子值比对永远不相等胜负判断就会悄悄失效。完整的判断逻辑长这样function checkWin(row, col) { const player board[row][col]; for (let [dr, dc] of directions) { let count 1; for (let i 1; i 5; i) { const r row dr * i; const c col dc * i; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || board[r][c] ! player) break; count; } for (let i 1; i 5; i) { const r row - dr * i; const c col - dc * i; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || board[r][c] ! player) break; count; } if (count 5) return true; } return false; }这个实现里循环只跑到4再加上初始的1就是5够判断出五连了。特殊规则比如长连超过5子在基础版本里直接视为胜利也没问题如果你想严格实现“五子棋黑棋长连不判负”这种进阶规则可以在此基础上再扩展但课题设计阶段不需要。3. 实操过程与核心实现一步步把代码写出来3.1 页面骨架与基本样式先把HTML骨架搭出来。整个页面分三块顶部的游戏状态信息和操作按钮、中间的Canvas棋盘。按钮有两个重新开始和悔棋。状态栏用来显示当前轮到哪方落子。div classcontainer h1五子棋/h1 div classtoolbar span idstatus黑棋落子/span button idrestart重新开始/button button idundo悔棋/button /div canvas idboard/canvas /divCSS部分不需要写太多让页面居中对齐按钮和状态栏在同一行棋盘加个阴影提升层次感就够了。之所以不用图片素材是因为整个项目要能单文件运行所有视觉元素都靠Canvas代码绘制这样部署和分享都方便。3.2 游戏核心逻辑代码实现核心逻辑代码可以分成四块状态初始化、落子、胜负判断、悔棋。初始化的关键是生成一个干净的棋盘数组let board []; let currentPlayer 1; let history []; let gameOver false; function init() { board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(0)); currentPlayer 1; history []; gameOver false; redrawAll(); }落子函数是核心中的核心。它要检查位置是否为空、写状态数组、画棋子、判断胜负、换人。顺序特别重要一定要先把棋子写进board数组再调用checkWin。如果先判断后写数组checkWin读到的是空位置永远判断不出结果。function placePiece(row, col) { if (board[row][col] ! 0 || gameOver) return; board[row][col] currentPlayer; history.push({ row, col, player: currentPlayer }); drawPiece(row, col, currentPlayer, true); if (checkWin(row, col)) { gameOver true; statusEl.textContent (currentPlayer 1 ? 黑棋 : 白棋) 获胜; return; } currentPlayer currentPlayer 1 ? 2 : 1; statusEl.textContent currentPlayer 1 ? 黑棋落子 : 白棋落子; }重绘棋盘时要注意不能只画新棋子因为悔棋撤销棋子后被撤销的棋子必须从画布上消失。所以重绘的正确姿势是先重画整个棋盘底色和网格线再遍历history数组把所有现存棋子重新画一遍。15x15最多225个棋子重绘一次也就几十毫秒性能毫无压力。3.3 交互功能补全悔棋、重开、提示悔棋功能的核心是history栈。每落一子就push一局信息悔棋就pop一次然后重绘。边界条件要处理游戏结束后不允许悔棋、history为空时不允许悔棋。我见过很多半成品项目悔棋按钮一按棋盘直接乱了就是因为history栈和数据重绘没有配合好。悔棋之后要把currentPlayer改回上一步的玩家否则会出现连续走两次的bug。这一点特别隐蔽逻辑上很容易漏掉。重开功能更简单直接调用init函数重置所有状态。唯一要注意的是如果游戏正在进行重新开始前可以加一个确认弹窗防止误触不确定用不用的话加个window.confirm是最快的方案。还有一种提升体验的做法是轮流出场动画。落子时给棋子加一个从小到大缩放的效果虽然只多几行绘制代码但视觉感受完全不同。实现思路是记录每颗棋子的出生时间绘制时根据当前时间计算缩放比例播放完成后恢复原大小。4. 常见问题与排查实录4.1 实际调试中反复出现的问题做这个项目的过程中我踩过一些比较典型的坑整理成表格给后来者参考问题现象根本原因解决办法点击棋盘边缘区域棋子画到棋盘外面坐标换算后没有做边界检查换算出行列后判断是否在0到14范围内越界直接return同一位置可以反复落子覆盖没有检查board数组对应位置是否为空placePiece开头判断board[row][col] ! 0时直接退出胜负判断在棋盘边缘失效方向循环里数组越界后比对失败每个方向循环里都判断行列是否越界再访问数组悔棋之后棋子颜色错乱悔棋后没有恢复currentPlayer用history.pop()里的player值覆盖currentPlayer点击棋子和线条交错的地方落子位置偏移CSS缩放Canvas导致坐标换算偏差保证Canvas的CSS尺寸与属性尺寸一致游戏结束后还能继续落子没判断gameOver状态placePiece开头增加gameOver判断这里面最值得说的是坐标边界问题。当时我调试时发现棋盘最上面一行靠左的位置怎么点都没反应一度以为是事件绑定问题。后来发现当点击位置接近画布边缘时换算出来的行列坐标可能是负值或者大于14数组下标负访问在JavaScript里不会直接报错只会返回undefined于是数据写不进去表现就是“点了没反应”。加一行边界判断之后问题立刻消失。另一个容易被忽视的问题是棋盘底色重绘。如果你在重绘函数里忘记清屏Canvas不会自动擦掉旧内容结果就是棋盘越画越花落子多了以后整个棋盘全是残影。处理办法是在drawBoard开头调用clearRect清空整个画布然后再画背景色。4.2 几个值得做的功能扩展方向如果你做完基础版本还想往上叠功能有两条路性价比最高。第一条是棋盘持久化。用localStorage保存board数组、currentPlayer和history页面刷新后自动恢复上次的对局。这个功能实现成本极低但答辩时特别能讲属于“用浏览器原生能力解决真实需求”的典型例子。保存时机放在每次落子之后读取时机放在页面加载时。第二条是加一个简单的人机对战AI。很多同学听到AI两个字就害怕其实五子棋最基础的AI不需要机器学习用“打分法”就够了遍历所有空位对每个空位分别在四个方向上数“如果我在这个位置落子我能连成几个、对方能连成几个”给冲三、冲四、活二等情况分配不同的分数选分数最高的位置落子。这个AI虽然下不过高手但足够吊打没认真玩的人演示效果好得出奇。我试过在这两个功能都做完之后整个项目的代码量也就700行左右复杂度完全控制在合理范围。这也是五子棋作为课题设计的魅力所在——上限很高下限很低你可以只做基础版交差也可以一路做到带AI、带联机的高级版。最后再分享一个小技巧整个游戏的状态都集中在board、currentPlayer、history这三个变量上这个设计让我后面加任何功能都省了很多力气。你想加步数统计history.length就是答案你想加回放遍历history就能重建每一步棋。项目做完之后我才意识到当初下意识做的那次“模块划分”才是这个项目真正值钱的地方。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑