资讯动态

HTML5 Canvas五子棋开发实战:从坐标系到人机AI完整实现

发布时间:2026/10/18 5:36:40 来源:尧图企业网站定制
写这个小游戏的起因其实很朴素想做一个不依赖网络、打开就能玩、还能拿来给小孩练脑子的策略小游戏。筛了一圈五子棋是最合适的——规则一句话就能讲清楚但真正下起来有攻防、有陷阱、有布局。我最后选了纯HTML5加原生JavaScript的方案没有用任何框架也没有构建工具双击HTML文件直接在浏览器里跑同时支持双人对战和人机对战。这篇文章会把完整的开发过程、核心算法、技术选型思路以及我在实际调试中踩过的坑全部记录下来给正在做同类型Canvas小游戏、或者想快速上手Web游戏开发的朋友一个可以直接参考的模板。文章会覆盖从棋盘绘制、坐标换算、落子交互到胜负判定和人机AI的完整链路。代码部分我会直接给出关键实现和注释每个环节都会说明“为什么这么写”而不是单纯堆代码。如果你已经有一点JavaScript基础跟着这篇文章走一遍基本能独立复刻一个功能完整、移动端也能正常玩的五子棋小游戏。1. 选型与架构Canvas渲染棋盘是我测试过最优的方案做棋盘类游戏第一个绕不开的问题就是棋盘和棋子到底用DOM实现还是用Canvas绘制。我一开始想得比较简单觉得五子棋交互不复杂用DOM摆棋子好像也行甚至在早期版本里确实这么试过——每个棋子就是一个带圆角的DOM节点点击就在对应位置插入一个节点。结果做到中途我发现这个方案有几个很别扭的地方最后全部推翻重来。1.1 DOM方案和Canvas方案的实测对比DOM方案的第一个问题在于棋子数量上来以后页面的节点数会快速膨胀。15x15的棋盘总共225个交叉点就算整盘下满也就225个棋子听起来不多但如果你同时在开发调试工具里开着性能面板布局和重绘的开销还是会随着棋局推进明显增加。第二个问题是棋子位置的像素级对齐很麻烦——DOM节点的定位依赖绝对定位或者flex布局要做到棋子恰好落在横竖线交叉点上你得反复计算偏移量不同屏幕尺寸下还容易出偏差。第三个问题是如果你想做落子动画、棋子阴影、棋盘木纹质感这类视觉效果CSS实现起来又别扭又卡。Canvas方案在这几个方面几乎是碾压式的优势。它本质上是一个位图画布你只需要在对应坐标上调用绘图API棋盘、格子线、棋子、高亮提示全部由自己控制200多个棋子对Canvas来说完全不构成压力。绘制精度是像素级的管线画在哪儿棋子就落在哪儿不存在DOM布局带来的偏差。而且Canvas天然适合做棋盘类游戏的视觉效果阴影、渐变、圆角、半透明遮罩都是现成的绘图API。对比项DOM方案Canvas方案节点/绘制开销随棋子增加而上涨恒定225个棋子无压力坐标精度依赖布局计算易偏移像素级精确视觉特效CSS实现受限原生绘图API灵活触摸/鼠标事件需要为每个节点绑定统一在Canvas上监听一次代码复杂度需要维护DOM结构纯逻辑绘制结构清晰1.2 项目文件与核心结构划分整个项目我保持了最精简的结构一个HTML文件承载页面骨架一个CSS文件做布局和按钮样式一个JavaScript文件放全部游戏逻辑。不需要模块化打包不需要npm这一点对想快速上手或者想拿去二次开发的人来说很友好。JavaScript内部我按职责拆成了几个相对独立的部分游戏状态模块维护当前棋盘数据、当前轮到哪方、游戏是否结束。绘制模块负责画棋盘、画棋子、画提示框。交互模块监听鼠标和触摸事件完成屏幕坐标到棋盘坐标的换算。AI模块人机对战时计算落子位置。这样拆的好处是后面想加什么功能都在对应模块里改不用从头翻代码。比如某天你想把AI换成更强力的算法只需要替换AI模块的返回值逻辑UI部分完全不用动。2. 绘制与交互坐标换算、棋子反馈和手感优化棋盘绘制本身不复杂但里面有几个细节我调试了很久才做到满意。首先是棋盘尺寸的计算方式其次是点击位置到棋盘交叉点的换算准确性最后是落子前后的视觉反馈。2.1 棋盘尺寸与网格线绘制标准的五子棋棋盘是15x15也就是横竖各15条线实际交叉点是15x15个。这里有个容易踩的坑——很多人会把“格子数量”当成15个然后在画线的时候把循环写错。正确的理解是我们要画15条竖线和15条横线它们围成的格子数量是14x14。Canvas绘制的时候我建议棋盘外圈留出一定边距不要让最外侧的线贴着画布边缘。这样棋手落子在最外圈交叉点时棋子不会画到栽到画布外面去。我的实现在画布四周各预留了一个格子宽度的边距所有线条在这个范围内均匀排列。const BOARD_SIZE 15; const canvas document.getElementById(board); const ctx canvas.getContext(2d); // 逻辑上把画布定义为 600x600实际设备像素缩放后面再处理 const LOGICAL_SIZE 600; const CELL_SIZE LOGICAL_SIZE / (BOARD_SIZE 1); // 预留边距 // 绘制棋盘网格线 function drawBoard() { ctx.clearRect(0, 0, LOGICAL_SIZE, LOGICAL_SIZE); // 先铺一层木色底 ctx.fillStyle #E8B96A; ctx.fillRect(0, 0, LOGICAL_SIZE, LOGICAL_SIZE); ctx.strokeStyle #4A3A22; ctx.lineWidth 1; // 线段两端坐标按交叉点位置计算 for (let i 0; i BOARD_SIZE; i) { const pos CELL_SIZE i * CELL_SIZE; // 竖线 ctx.beginPath(); ctx.moveTo(pos, CELL_SIZE); ctx.lineTo(pos, LOGICAL_SIZE - CELL_SIZE); ctx.stroke(); // 横线 ctx.beginPath(); ctx.moveTo(CELL_SIZE, pos); ctx.lineTo(LOGICAL_SIZE - CELL_SIZE, pos); ctx.stroke(); } drawStarPoints(); // 画天元和星位 }星位就是棋盘上那五个加粗的黑点传统棋盘在天元正中心和四个角星位处会有标记。这个纯属装饰但对棋手定位很有帮助画法就是在对应交叉点画一个小实心圆。2.2 鼠标点击到棋盘坐标的换算公式这个环节是交互的核心也是最容易出细节bug的地方。用户在浏览器里点击Canvas后我们拿到的是相对于视口的坐标而Canvas内部的绘制坐标可能因为CSS缩放而不一致所以换算必须分两步。第一步把鼠标/触摸坐标映射到Canvas的逻辑坐标。Canvas在页面上显示的尺寸和它的内部绘制尺寸不一定一致。我在CSS里通常会把Canvas设置为自适应屏幕宽度比如max-width: 95vw但内部逻辑尺寸固定是600x600。如果直接用clientX - rect.left去算算出来的是相对CSS像素的坐标和内部逻辑坐标存在一个缩放比例差了。canvas.addEventListener(click, handleClick); function handleClick(e) { const rect canvas.getBoundingClientRect(); // 计算画布实际显示尺寸和逻辑尺寸的缩放比 const scaleX LOGICAL_SIZE / rect.width; const scaleY LOGICAL_SIZE / rect.height; const mouseX (e.clientX - rect.left) * scaleX; const mouseY (e.clientY - rect.top) * scaleY; // 第二步由像素坐标推算出最近的交叉点行列 const col Math.round((mouseX - CELL_SIZE) / CELL_SIZE); const row Math.round((mouseY - CELL_SIZE) / CELL_SIZE); if (col 0 || col BOARD_SIZE || row 0 || row BOARD_SIZE) return; tryPlacePiece(row, col); }这里Math.round很关键。因为落子的有效区域是交叉点用户点击的位置不可能精确落在点上我们要做的是找到“最近的那个交叉点”。先用坐标差除以格子宽度取得浮点位置再四舍五入就能得到最近的交叉点行列。这一步测试下来手感最好比Math.floor或者Math.ceil都准确因为用户手指或鼠标偏一点点不应该影响落子位置。2.3 落子手感与视觉反馈交互只有“点击就落子”其实不够。我在实测中发现如果用户移动鼠标悬停在棋盘上时没有任何提示落子前总有一种不确定感尤其是棋盘密集的地方容易点错。所以我加了一个轻量的交互反馈鼠标移动时在最近的交叉点画半透明的“预落子”提示用户当前会落在哪里。这个功能要做得流畅关键在于不要立刻重绘整个棋盘而是把之前的预落子擦掉再画新的。我的做法是在绘制模块里维护一个“当前悬停位置”的变量每次悬停变化时先恢复之前位置的那一小块棋盘再在新位置画半透明棋子。为了恢复棋盘我会保留上一次绘制后的棋盘图像用drawImage把那个区域裁剪出来重绘性能非常稳定。// 保存完整棋盘快照 let boardSnapshot null; function refreshSnapshot() { boardSnapshot ctx.getImageData(0, 0, LOGICAL_SIZE, LOGICAL_SIZE); } function clearHover(prevRow, prevCol) { if (boardSnapshot prevRow 0) { const x (prevCol 1) * CELL_SIZE - PIECE_RADIUS; const y (prevRow 1) * CELL_SIZE - PIECE_RADIUS; ctx.putImageData( boardSnapshot, 0, 0, Math.max(0, x), Math.max(0, y), PIECE_RADIUS * 2, PIECE_RADIUS * 2 ); } }棋子的绘制本身我用了一个径向渐变来模拟立体感黑子和白子的渐变方向不同这样棋盘看起来不会是一片死板的平面圆片。这个细节看着小但对整体观感提升很明显。3. 胜负判定与游戏状态五子连珠检测的算法取舍写五子棋的核心算法时胜负判定是最容易出问题的地方。很多人上手第一版会写一个“遍历全盘”的函数每次落子后扫描整个棋盘所有位置看以每个位置为起点有没有五连子。这个方案能跑但效率低且逻辑绕。我的建议是依赖“最后一手落子”来判定因为一局棋中新增的获胜可能性只可能经过最后一手棋。3.1 棋盘数据模型设计棋盘数据我用一个二维数组存储board[row][col]的取值有三种0代表空1代表黑棋2代表白棋。数组初始化为15x15的全零矩阵。let board []; function initBoard() { board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(0)); }有人会问为什么不用一维数组。二维数组虽然多一层引用但可读性高很多逻辑上board[7][7]就是天元位调试的时候一眼能看出来对应棋盘的位置没必要为了省那一点点内存去牺牲可读性。3.2 以前一手为核心的连子检测核心思路是每次落子后检查以当前落子为中心分别沿着水平、垂直、两个斜方向共四个方向统计同色棋子的连续数量。如果任一方向连续数量达到5则判定当前玩家获胜。const DIRECTIONS [ [0, 1], // 水平向右 [1, 0], // 垂直向下 [1, 1], // 右下斜 [1, -1] // 右上斜 ]; function checkWin(row, col, player) { for (const [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) break; if (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) break; if (board[r][c] ! player) break; count; } if (count 5) return true; } return false; }这个写法有两个好处。首先它只检查落子点附近的连子情况最坏情况下判断次数也极小完全不需要全盘扫描。其次它天然规避了“重复扫描同一段连子”的问题——因为方向是固定从落子点向两边延伸不会出现同一条线被统计两次的情况。注意一下标准的五子棋规则中连成5个或更多都算赢有些变体规则中长连不算但作为小游戏超过5个同样判定胜利即可。上面代码用count 5而不是count 5就是考虑了长连的情况。3.3 游戏状态机与和棋判定游戏的整体状态我用一个简单的状态机来管理包含三个状态PLAYING对局中、WIN已分出胜负、DRAW和棋。状态机的引入让代码结构清晰很多比如AI就不会在游戏结束后继续落子玩家也不能在已经结束的棋局上再点击。和棋判断的逻辑如果棋盘上所有交叉点都被占满且没有任何一方胜利则判定和棋。这里有个实操细节每次落子后我们可以维护一个pieceCount计数器当它等于225时触发和棋判断而不必每次都遍历棋盘数棋子数量。let gameState PLAYING; let pieceCount 0; function handleMove(row, col, player) { if (gameState ! PLAYING) return; if (board[row][col] ! 0) return; board[row][col] player; pieceCount; drawPiece(row, col, player); if (checkWin(row, col, player)) { gameState WIN; showResult(player 1 ? 黑棋获胜 : 白棋获胜); return; } if (pieceCount BOARD_SIZE * BOARD_SIZE) { gameState DRAW; showResult(平局); return; } }这里对非法落子的拦截也很重要——用户点击已经落过子的位置时board[row][col] ! 0会直接拦住避免覆盖已有棋子。4. 人机对战AI从评分制到可调难度挡位五子棋小游戏如果只能两个人轮流下其实已经算一个完整作品了。但很多用户拿到之后第一反应是“我想和电脑下”。所以我又加了一个简单但够用的AI没有上复杂的神经网络也没有用深度的博弈树搜索而是采用“逐点评分”策略配合难度挡位调节实测下来已经有了可玩性。4.1 评分制的核心思想简单来说就是遍历所有空位给每个空位计算一个分数最后选分数最高的位置落子。分数的依据是如果在这个位置落子能在四个方向形成多长的连子、以及是否接近获胜同时还要考虑如果这里让对手占住对手能形成多大的威胁。评分函数的难点在于“方向和点数的权重设计”。我参考了一些经典棋类AI的思路把一条方向上的棋型分成几档五连是最高优先级其次是活四两端都开放的四个连子、冲四一端被封的四连、活三、眠三、活二等。每一档对应不同的评分权重。进攻和防御的权重可以不同比如我默认进攻权重稍高一点让AI更主动。function evaluateLine(count, openEnds) { // openEnds 表示这条连子两端还有几个空位开放 if (count 5) return 100000; if (count 4 openEnds 2) return 10000; // 活四 if (count 4 openEnds 1) return 2000; // 冲四 if (count 3 openEnds 2) return 1000; // 活三 if (count 3 openEnds 1) return 200; // 眠三 if (count 2 openEnds 2) return 100; // 活二 if (count 2 openEnds 1) return 20; // 眠二 return 0; }实际遍历空位时我会以该空位为中心沿四个方向把左右两侧相邻的连续同色棋子数出来并判断两端是否被己方棋子或棋盘边界堵住进而得到count和openEnds。4.2 进攻与防御的权衡单纯看“自己落子能形成多好的棋型”是不够的因为AI还必须会堵对手。我处理的方式是在一个空位上分别计算“如果我下这里的得分”和“如果对手下这里的得分”前者作为进攻分后者乘以一个防御系数作为防守分最终分数取两者之和。function getScoreForPoint(row, col, player) { const opponent player 1 ? 2 : 1; let attackScore evaluatePoint(row, col, player); let defenseScore evaluatePoint(row, col, opponent); return attackScore * 1.1 defenseScore; }这个策略在实战里表现很自然当对手形成活三时AI会放下手头的进攻优先去堵当自己也有成形的大优势时AI会果断进攻。防御系数我用了1.0进攻系数用了1.1也就是AI会稍微倾向于自己进攻但又不至于完全无视防守。4.3 难度挡位的实现思路只有一套评分逻辑的话AI的强度是固定的。我做了三个难度挡位简单、普通、困难。实现的思路其实很简单——难度高时AI从所有空位中选择评分最高的难度低时AI在一个候选集合里随机挑一个加上一些随机扰动让它的落子看起来“时不时犯傻”。function aiMove() { const candidates []; let maxScore -Infinity; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) continue; if (isEmptyNeighbor(r, c) false) continue; // 只评估有邻居的空位 const score getScoreForPoint(r, c, currentPlayer); candidates.push({ r, c, score }); if (score maxScore) maxScore score; } } let chosen; if (difficulty hard) { chosen candidates.find(item item.score maxScore); } else if (difficulty medium) { // 按分数加权偶尔选次优解 const top candidates.filter(item item.score maxScore * 0.9); chosen top[Math.floor(Math.random() * top.length)]; } else { // 简单模式在评分较高的位置里随机 const pool candidates.filter(item item.score maxScore * 0.5); chosen pool[Math.floor(Math.random() * pool.length)]; } return chosen; }这里有一个性能优化点没有必要遍历所有225个位置只需要调查已有棋子周围的空位就够了。我会先判断某个空位的上下左右四邻域是否存在棋子如果没有棋子存在那么它在当前局面下基本不会被选为最佳落子点可以直接跳过。这个剪枝操作让AI的计算量锐减在移动端上计算延迟也几乎感觉不到。如果后面想继续提升AI强度可以考虑在评分制基础上加一层博弈树搜索也就是在“当前最佳落子”基础上往下看两到三步。不过作为轻量小游戏评分制已经能在普通玩家面前打出有来有回的效果了。5. 移动端适配与细节打磨触摸事件、双重响应和悔棋做完桌面对战和人机对战后我顺手把项目挪到了手机上测试结果一连串问题冒了出来。这些问题在PC上几乎不会出现但只要是给大众用的小游戏移动端适配就是必需项。5.1 Canvas在移动端的清晰度问题第一个问题是Canvas在高分辨率屏幕上的模糊。我最初把Canvas内部尺寸固定为逻辑尺寸600x600在普通屏幕上没问题但在高DPI屏幕上浏览器会用物理像素来显示导致画面发虚。解决办法是结合设备的devicePixelRatio对Canvas进行缩放。先获取设备像素比dpr然后把Canvas的实际绘图缓冲区大小设置为LOGICAL_SIZE * dpr再用ctx.scale(dpr, dpr)保证所有绘图API在逻辑坐标系下正常工作。CSS里仍然把Canvas显示尺寸控制在屏幕宽度内。function setupCanvas() { const dpr window.devicePixelRatio || 1; canvas.width LOGICAL_SIZE * dpr; canvas.height LOGICAL_SIZE * dpr; ctx.scale(dpr, dpr); }这段代码要放在任何绘制逻辑之前执行而且不要在每次重绘时重复调用ctx.scale否则会把缩放不断叠加导致画面越来越大。5.2 鼠标事件与触摸事件的统一处理手机上没有鼠标只有触摸事件。我的第一个版本只在Canvas上监听了click事件结果手机上一顿点击没有任何反应。后来改成了同时监听click和touchstart结果又出现了新问题某些移动端浏览器在触摸结束后会额外触发一次模拟的click事件导致一次点击被响应两次棋子上直接落了两颗子。这个问题的标准解法是使用pointer事件统一处理。好消息是现代浏览器对pointerdown、pointermove的支持已经非常完善一套代码同时覆盖鼠标和触摸还能天然避免click和touchstart的双重触发。canvas.addEventListener(pointerdown, handlePointerDown); canvas.addEventListener(pointermove, handlePointerMove); function handlePointerDown(e) { e.preventDefault(); const pos getBoardPosition(e); if (pos) handlePlayerClick(pos.row, pos.col); }这里e.preventDefault()非常重要它可以阻止触摸后的默认行为比如页面滚动、双击缩放。我还需要在CSS上设置touch-action: none否则浏览器会在触摸时尝试进行手势处理影响连续落子的操作体验。5.3 悔棋、重置和各种边界状态辅助功能里悔棋是最常被问到的。实现悔棋逻辑需要维护一个落子历史栈每次落子时把行列坐标push进栈悔棋时从栈顶弹出一次或两次取决于是否AI对战中需要连续悔两步。let moveHistory []; function undo() { if (moveHistory.length 0) return; const last moveHistory.pop(); board[last.row][last.col] 0; pieceCount--; // 如果是人机对战把AI的上一步也弹出 if (gameMode ai moveHistory.length 0) { const aiMove moveHistory.pop(); board[aiMove.row][aiMove.col] 0; pieceCount--; } gameState PLAYING; redrawAll(); }重置功能就是把棋盘数组重新初始化、清空历史栈、恢复状态机同时重绘整个画布即可。这个功能看似简单但一定要记得同时重置pieceCount和gameState我见过有人只清了棋盘数组结果胜方判定和和棋判定仍然沿用旧状态导致下一局逻辑错乱。另一个容易被忽略的边界问题是对局结束后玩家继续点击棋盘。我前面已经提过在handleMove入口处拦截非PLAYING状态这里再强调一次——不要只在UI层判断一定要在逻辑函数入口判断因为逻辑层的判断才是最后一道安全网。6. 实测数据与扩展方向这个小游戏还能走到哪项目完成之后我在几个不同环境下跑了实测。逻辑尺寸600x600的棋盘在普通桌面浏览器上绘制一帧棋盘加全部棋子只需要几毫秒在移动端模拟器上结合devicePixelRatio缩放后的重绘耗时也稳定在十几毫秒以内完全感觉不到卡顿。AI落子计算在最坏的后期局面下棋盘剩余空位较多经过邻域剪枝后需要评估的位置也只有几十个到一百多个在低端Android机上依然能保证毫秒级返回。性能这块我得到的经验是对于这种225格棋盘的小型游戏算法层面的优化远比渲染层面的优化更重要。渲染只要避免频繁全量重绘基本不会有性能问题反而如果AI不剪枝、每次都遍历所有空位后期叠加评分计算才会有明显的等待感。6.1 做这一个项目学到的通用开发经验回过头看这个项目虽然是“小游戏”但麻雀虽小五脏俱全它几乎覆盖了Web前端游戏开发的基础链路状态管理、Canvas渲染、事件处理、简单AI算法、跨端适配。对我个人来说最有价值的其实是状态机设计和坐标换算这两个部分。状态机让逻辑不再是一团乱麻坐标换算则让我意识到只要是Canvas开发任何交互的第一步都应该是“把屏幕坐标正确地映射到业务坐标”这个基础打好了后面做再复杂的游戏都不慌。6.2 可以继续扩展的几个方向如果想把这个小游戏往深了做我梳理了几个方向按难度从低到高排列增加联机对战能力通过WebSocket实现双人在线对弈需要增加房间、匹配、断线重连等机制。提升AI强度在评分制基础上叠加两层或三层的极小化极大搜索配合alpha-beta剪枝。增加复盘回放功能基于落子历史栈以动画形式重现整局棋。美化界面增加棋盘皮肤、音效、胜负动效等做成一个可发布的独立游戏页面。我自己目前在尝试的是在线对战的版本。基础框架完全复用现有代码只需要把落子操作改成通过WebSocket广播并新增一个消息协议来同步双方棋盘状态。因为五子棋的每一步本质上就是一个二元组(row, col)非常容易设计成网络协议这算是纯客户端的小游戏走向联机的一个比较自然的演变。最后分享一个开发中的小技巧Canvas游戏调试时一定要学会使用浏览器开发者工具中的截屏和性能记录功能。我在调坐标换算时就是通过截帧发现棋子偏移了几个像素在调预落子重绘时靠性能面板才定位到是每次鼠标移动都会触发整个棋盘重绘的浪费。这种小游戏看起来简单真正Debug起来和大型项目一样需要方法但只要把基础的数据流和绘制流程理顺了整体开发体验其实很顺畅。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑