HTML五子棋实现是前端练习里特别合适的一个目标规则一眼能懂交互直观但又不像那些纯小游戏一样一两百行就糊弄完。你需要在canvas上画棋盘和棋子处理鼠标点击和坐标换算写胜负判定还要考虑电脑对手到底该怎么走。这篇文章就基于我最近整理的一版单文件实现把这些环节逐个拆开讲清楚包括数据怎么存、棋子怎么画、如何判断五子连珠以及一个基础但能实际对战的人机AI。最终代码保存为.html文件浏览器直接打开就能玩不需要搭建任何环境。如果你正打算学HTMLCSSJS的综合实战或者课设想做个带点算法含量的网页小游戏这篇内容可以直接照着敲。代码量不大但该踩的坑一个不少canvas坐标偏移、缩放后点击错位、连子判断漏边界、AI只看眼前棋这些问题我都会结合自己的调试过程展开聊。1. 整体设计棋盘模型与技术选型1.1 技术选型为什么是纯HTMLCSSJS单文件做网页版五子棋第一件事是决定棋盘用什么方案实现。大家翻教程时常见两种一种是用canvas绘制整个棋盘和棋子另一种是纯DOM加CSS定位用节点来模拟棋子。我自己三版里都试过最终锁定canvas方案。原因很简单棋子绘画效果可控画渐变、画阴影、做高DPI适配都顺手而且五十次落子就是五十次重绘指令不会像DOM方案那样在页面上挂几百个节点。纯HTMLCSSJS单文件的好处是零依赖、零构建。不需要npm、不需要webpack不需要起一个本地服务。双击HTML文件就能跑这本身就是一个卖点尤其是课设演示或丢给朋友试玩的场景一个文件传过去就行。再加上浏览器本身就是跨平台的Windows、macOS、手机浏览器基本都能跑只要注意几个兼容性细节。做这个选择时我跳过了框架。React和Vue在这类小游戏里属于杀鸡用牛刀数据流和重绘逻辑并不复杂非要引入框架反而把“棋盘坐标映射到界面”这一核心问题搞混了。原生JS配合canvas一行行代码都是直白的状态操作理解起来更清楚。1.2 棋盘数据结构二维数组与状态流转五子棋的棋盘是15乘15的交叉点网格我在代码里用嵌套二维数组表示const BOARD_SIZE 15; const board Array.from({length: BOARD_SIZE}, () Array(BOARD_SIZE).fill(0));这里规定0代表空、1代表黑棋、2代表白棋。为什么不直接操作canvas而是先维护一份纯数据两个原因。第一胜负判定需要反复查看棋盘任意位置的值如果状态散落在DOM里每次判断都要从界面反查数据又慢又乱第二悔棋和重开这两个动作本质上是恢复某几个位置的状态持有数组可以快速重置再全量重绘一次即可。游戏的状态流转我拆成三步落子前检查坐标是否合法、是否已有棋子落子后更新棋盘数组并绘制最后检查胜负。当前轮到谁用一个turn变量控制黑先白后。人机模式下这个变量既是“谁落子”的标识也是电脑AI何时出手的开关。很多新手把状态堆在全局变量里写到后面自己都理不清我建议至少用一个集中对象管理哪怕代码少养成这个习惯后面扩展功能会省很多心。const state { board: [], turn: 1, gameOver: false, mode: pvp, history: [] };history是给悔棋和AI复盘用的。每次落子把坐标和棋手推进数组悔棋时从尾部弹出棋盘重绘一次就回到之前的状态。2. 核心细节绘制、交互与胜负判定2.1 棋盘与棋子的绘制细节棋盘绘制我全部走canvas 2D接口。第一步是设置canvas尺寸许多初版代码会直接把尺寸写死成canvas.width 500但格子数量和边距耦合在一起后面想调盘面大小就非常难受。我按参数计算尺寸const CELL_SIZE 36; const PADDING 24; canvas.width PADDING * 2 CELL_SIZE * (BOARD_SIZE - 1); canvas.height canvas.width;PADDING是棋盘边框留白CELL_SIZE是相邻两条线之间的像素距离。注意是乘BOARD_SIZE - 1而不是乘BOARD_SIZE因为15条线之间只有14个间距这是最容易写错的地方差了整整一个格宽。绘制网格线时横线和竖线的位置都从PADDING开始按格子间距递增function drawBoard() { ctx.fillStyle #e5c98f; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #3a2b1a; ctx.lineWidth 1; for (let i 0; i BOARD_SIZE; i) { const pos PADDING i * CELL_SIZE; ctx.beginPath(); ctx.moveTo(PADDING, pos); ctx.lineTo(PADDING CELL_SIZE * (BOARD_SIZE - 1), pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, PADDING); ctx.lineTo(pos, PADDING CELL_SIZE * (BOARD_SIZE - 1)); ctx.stroke(); } drawStars(); }棋盘上按标准规则要标出五个星位三个角星加上天元function drawStars() { const stars [[3, 3], [3, 11], [11, 3], [11, 11], [7, 7]]; ctx.fillStyle #3a2b1a; stars.forEach(([row, col]) { ctx.beginPath(); ctx.arc(PADDING col * CELL_SIZE, PADDING row * CELL_SIZE, 4, 0, Math.PI * 2); ctx.fill(); }); }棋子绘制要注意层次感。纯色圆形棋子看起来像贴纸我用径向渐变让黑棋和白棋都有一点立体感function drawPiece(row, col, player) { const x PADDING col * CELL_SIZE; const y PADDING row * CELL_SIZE; const radius CELL_SIZE * 0.42; const grad ctx.createRadialGradient(x - 2, y - 2, 2, x, y, radius); if (player 1) { grad.addColorStop(0, #ddd); grad.addColorStop(1, #111); } else { grad.addColorStop(0, #fff); grad.addColorStop(1, #bbb); } ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle grad; ctx.fill(); ctx.strokeStyle rgba(0,0,0,0.2); ctx.lineWidth 1; ctx.stroke(); }渐变中心点稍微往左上偏移两个像素光就是从那个方向打过来的。体验上的差别说大不大但至少棋盘看起来不会像小学生作品。2.2 落子交互与坐标换算交互层最简单的思路是监听canvas的click事件拿到鼠标在canvas内的位置换算成对应的行列号再落子。这里有个大坑我第一版就踩了直接使用e.offsetX和e.offsetY换算时一旦页面缩放比例不是100%或者canvas被CSS放大/缩小坐标就会整体错位。原因在于offsetX返回的是当前DOM事件在CSS像素坐标系下的坐标而canvas内部的绘图坐标系是物理像素坐标系。当canvas的CSS尺寸和它的width/height属性不一致时这两个坐标系之间就存在一个缩放系数。正确的做法是用getBoundingClientRect()拿到canvas在屏幕上的实际矩形位置再乘上比例系数canvas.addEventListener(click, (e) { if (state.gameOver) return; if (state.mode ai state.turn 2) return; const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const x (e.clientX - rect.left) * scaleX; const y (e.clientY - rect.top) * scaleY; const col Math.round((x - PADDING) / CELL_SIZE); const row Math.round((y - PADDING) / CELL_SIZE); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; placePiece(row, col); });换算公式是(鼠标位置 - 棋盘边距) / 格子宽度然后四舍五入到最近的交叉点。这个四舍五入非常关键它允许玩家点在小格子的偏左或偏右位置都能落到那根线上手感会宽松许多。如果不四舍五入而是直接用Math.floor玩家必须精确点中线的某个侧边区域体验会变得非常生硬。我后来测试时把浏览器窗口缩放到75%旧版代码立刻出现问题点击棋盘中心棋子落在偏离半格的位置。利用上面的缩放换算后任何缩放比例下坐标都准确。这个问题的本质是混淆了“元素在屏幕上的显示位置”和“canvas内部的逻辑坐标”以后做任何canvas项目包括图片编辑器、画板、图表库都需要意识到这个区别。2.3 胜负判定算法五子连珠检测每次落子后都要检测从该点出发是否存在五连。不需要每次全盘扫描只需要以刚落下的棋子为中心检查四个方向水平、垂直、主对角线、副对角线。每个方向分别往两端延伸把相同棋子的数量统计出来累计值达到5就判胜。用方向向量来写会非常直观function checkWin(row, col, player) { const dirs [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of dirs) { 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; }四个方向覆盖了全部的直线可能性往正方向延伸和负方向延伸合并统计所以某个方向只需要检查一次。边界条件要写清楚一旦出界就停止延伸否则在角落位置会出现越界访问或者漏判的情况。这个算法的时间复杂度是常数量级每个方向最多向外查4步四方向加一起不超过32次数组访问。相比每次点击后遍历全盘找一个五连这个做法的性能好得多人也看得懂。后面如果要加“禁手”规则或识别活四、冲四这类棋形也都基于同样的方向延伸思路加一些额外的状态统计即可。3. 完整实操从零手写一版可玩的五子棋3.1 页面骨架与界面样式先搭HTML结构。顶部是状态栏和操作按钮中间是canvas棋盘。注意canvas不要既指定width属性又在CSS里写死另一个宽度容易造成坐标系混乱。我把canvas的实际尺寸交给JS初始化CSS里只给圆角和阴影div idapp h1五子棋/h1 div idtoolbar span idstatus黑棋落子/span button idrestartBtn重新开始/button button idundoBtn悔棋/button button idmodeBtn切换人机模式/button /div canvas idboard/canvas /divCSS部分尽量简洁但棋盘背景、按钮间距和整体居中还是要有的body { background: #e8d5b0; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; font-family: Microsoft YaHei, sans-serif; } #app { text-align: center; } canvas { background: #e5c98f; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); cursor: pointer; border-radius: 4px; } #toolbar button { margin: 0 8px; padding: 6px 16px; font-size: 14px; }3.2 初始化与绘制逻辑页面加载后执行以下初始化const canvas document.getElementById(board); const ctx canvas.getContext(2d); const statusEl document.getElementById(status); const BOARD_SIZE 15; const CELL_SIZE 36; const PADDING 24; function initGame() { state.board Array.from({length: BOARD_SIZE}, () Array(BOARD_SIZE).fill(0)); state.turn 1; state.gameOver false; state.history []; canvas.width PADDING * 2 CELL_SIZE * (BOARD_SIZE - 1); canvas.height canvas.width; drawBoard(); updateStatus(); }drawBoard每次调用先整体清屏再重画。因为有history数组悔棋和重开的实现就很简单悔棋时弹出最后一步的人机记录、重置棋盘数组、清空画布、重画一次。注意人机模式下悔棋要连续弹出两步否则会出现“把电脑的棋撤掉、轮到玩家但棋盘上还是玩家刚才那步”的怪状态。function undoMove() { if (state.gameOver) return; if (state.history.length 0) return; if (state.mode ai) { for (let i 0; i 2 state.history.length 0; i) { const last state.history.pop(); state.board[last.row][last.col] 0; } state.turn 1; } else { const last state.history.pop(); state.board[last.row][last.col] 0; state.turn state.history.length 0 ? 1 : (state.history[state.history.length - 1].player 1 ? 2 : 1); } state.gameOver false; redrawAll(); updateStatus(); }3.3 落子流程与人机AI的实现placePiece是整个游戏的核心方法负责落子、绘制、胜负判断和回合切换function placePiece(row, col) { if (state.board[row][col] ! 0) return; state.board[row][col] state.turn; state.history.push({ row, col, player: state.turn }); drawPiece(row, col, state.turn); if (checkWin(row, col, state.turn)) { state.gameOver true; const winner state.mode ai ? (state.turn 1 ? 你赢了 : 电脑赢了) : (state.turn 1 ? 黑棋胜 : 白棋胜); statusEl.textContent winner; return; } if (isBoardFull()) { state.gameOver true; statusEl.textContent 平局; return; } state.turn state.turn 1 ? 2 : 1; updateStatus(); if (state.mode ai state.turn 2 !state.gameOver) { setTimeout(() aiMove(), 200); } }人机模式下AI走白棋落子后如果轮到白棋用setTimeout延迟200毫秒再让AI行动给人一点思考的错觉不然棋盘上像瞬间冒出一颗子体验很怪。AI我用了最经典的评分算法遍历棋盘上每个空位分别计算“如果我方下在这里的进攻得分”和“如果对方下在这里的防守得分”两者加权求和选最高分的位置落子。评分依据是四个方向上的连子数。连续子数越多分数越高同时考虑两端是否被封住function pointScore(count, blocked) { if (blocked 2) return 0; if (count 5) return 100000; const table [0, 10, 100, 1000, 10000]; return blocked 0 ? table[count] : Math.floor(table[count] / 10); } function evaluatePoint(row, col, player) { state.board[row][col] player; let total 0; const dirs [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of dirs) { let count 1; let blocked 0; for (let step 1; step 4; step) { const nr row dr * step; const nc col dc * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { blocked; break; } if (state.board[nr][nc] player) count; else if (state.board[nr][nc] 0) break; else { blocked; break; } } for (let step 1; step 4; step) { const nr row - dr * step; const nc col - dc * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { blocked; break; } if (state.board[nr][nc] player) count; else if (state.board[nr][nc] 0) break; else { blocked; break; } } total pointScore(count, blocked); } state.board[row][col] 0; return total; }连子得分的设计可以参考下表连子数两端开放一端被封两端被封110002100100310001000410000100005及以上100000100000100000AI遍历所有落子候选时先做一步剪枝只考虑距离已有棋子两格以内的空位棋盘前几手完全远离战场的位置就不必算了。这一步能让AI快不少尤其是棋盘越下越大的时候function nearBy(row, col) { for (let r Math.max(0, row - 2); r Math.min(BOARD_SIZE - 1, row 2); r) { for (let c Math.max(0, col - 2); c Math.min(BOARD_SIZE - 1, col 2); c) { if (state.board[r][c] ! 0) return true; } } return false; }最后在AI主函数里把进攻分和防守分加权合计。防守系数我调到了0.9也就是AI会优先堵截对手的四连和三连但不会变成纯粹的“被动堵眼工具”该进攻连五的时候绝不手软function aiMove() { let bestScore -1; let bestRow 7; let bestCol 7; let found false; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (state.board[r][c] ! 0) continue; if (!nearBy(r, c) state.history.length 0) continue; const attack evaluatePoint(r, c, 2); const defend evaluatePoint(r, c, 1) * 0.9; const score attack defend; if (score bestScore) { bestScore score; bestRow r; bestCol c; found true; } } } if (found) { placePiece(bestRow, bestCol); } else { // 开局棋盘全空时走天元 placePiece(7, 7); } }3.4 完整代码清单单文件直接运行把上面所有代码整合到一个HTML文件里直接复制保存为gobang.html打开就行!DOCTYPE html html langzh-CN head meta charsetutf-8 titleHTML五子棋/title style body { background: #e8d5b0; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; font-family: Microsoft YaHei, sans-serif; } #app { text-align: center; } h1 { color: #5a3e2b; } canvas { background: #e5c98f; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); cursor: pointer; border-radius: 4px; display: block; margin: 12px auto; } #toolbar { margin: 10px 0; } #toolbar button { margin: 0 8px; padding: 6px 16px; font-size: 14px; } #status { font-size: 16px; font-weight: bold; margin-right: 12px; } /style /head body div idapp h1五子棋/h1 div idtoolbar span idstatus黑棋落子/span button idrestartBtn重新开始/button button idundoBtn悔棋/button button idmodeBtn切换人机模式/button /div canvas idboard/canvas /div script const BOARD_SIZE 15; const CELL_SIZE 36; const PADDING 24; const canvas document.getElementById(board); const ctx canvas.getContext(2d); const statusEl document.getElementById(status); const state { board: [], turn: 1, gameOver: false, mode: pvp, history: [] }; function initGame() { state.board Array.from({ length: BOARD_SIZE }, () Array(BOARD_SIZE).fill(0)); state.turn 1; state.gameOver false; state.history []; canvas.width PADDING * 2 CELL_SIZE * (BOARD_SIZE - 1); canvas.height canvas.width; drawBoard(); updateStatus(); } function drawBoard() { ctx.fillStyle #e5c98f; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #3a2b1a; ctx.lineWidth 1; for (let i 0; i BOARD_SIZE; i) { const pos PADDING i * CELL_SIZE; ctx.beginPath(); ctx.moveTo(PADDING, pos); ctx.lineTo(PADDING CELL_SIZE * (BOARD_SIZE - 1), pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, PADDING); ctx.lineTo(pos, PADDING CELL_SIZE * (BOARD_SIZE - 1)); ctx.stroke(); } drawStars(); } function drawStars() { const stars [[3, 3], [3, 11], [11, 3], [11, 11], [7, 7]]; ctx.fillStyle #3a2b1a; stars.forEach(([row, col]) { ctx.beginPath(); ctx.arc(PADDING col * CELL_SIZE, PADDING row * CELL_SIZE, 4, 0, Math.PI * 2); ctx.fill(); }); } function drawPiece(row, col, player) { const x PADDING col * CELL_SIZE; const y PADDING row * CELL_SIZE; const radius CELL_SIZE * 0.42; const grad ctx.createRadialGradient(x - 2, y - 2, 2, x, y, radius); if (player 1) { grad.addColorStop(0, #ddd); grad.addColorStop(1, #111); } else { grad.addColorStop(0, #fff); grad.addColorStop(1, #bbb); } ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle grad; ctx.fill(); ctx.strokeStyle rgba(0,0,0,0.2); ctx.lineWidth 1; ctx.stroke(); } function redrawAll() { drawBoard(); for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (state.board[r][c] ! 0) drawPiece(r, c, state.board[r][c]); } } } function checkWin(row, col, player) { const dirs [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of dirs) { 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 || state.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 || state.board[r][c] ! player) break; count; } if (count 5) return true; } return false; } function isBoardFull() { for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (state.board[r][c] 0) return false; } } return true; } function updateStatus() { if (state.gameOver) return; const name state.turn 1 ? 黑棋 : 白棋; statusEl.textContent state.mode ai ? (state.turn 1 ? 轮到你黑棋 : 电脑思考中...) : name 落子; } function placePiece(row, col) { if (state.board[row][col] ! 0) return; state.board[row][col] state.turn; state.history.push({ row, col, player: state.turn }); drawPiece(row, col, state.turn); if (checkWin(row, col, state.turn)) { state.gameOver true; statusEl.textContent state.mode ai ? (state.turn 1 ? 你赢了 : 电脑赢了) : (state.turn 1 ? 黑棋胜 : 白棋胜); return; } if (isBoardFull()) { state.gameOver true; statusEl.textContent 平局; return; } state.turn state.turn 1 ? 2 : 1; updateStatus(); if (state.mode ai state.turn 2 !state.gameOver) { setTimeout(() aiMove(), 200); } } canvas.addEventListener(click, (e) { if (state.gameOver) return; if (state.mode ai state.turn 2) return; const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const x (e.clientX - rect.left) * scaleX; const y (e.clientY - rect.top) * scaleY; const col Math.round((x - PADDING) / CELL_SIZE); const row Math.round((y - PADDING) / CELL_SIZE); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; placePiece(row, col); }); function pointScore(count, blocked) { if (blocked 2) return 0; if (count 5) return 100000; const table [0, 10, 100, 1000, 10000]; return blocked 0 ? table[count] : Math.floor(table[count] / 10); } function evaluatePoint(row, col, player) { state.board[row][col] player; let total 0; const dirs [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of dirs) { let count 1; let blocked 0; for (let step 1; step 4; step) { const nr row dr * step; const nc col dc * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { blocked; break; } if (state.board[nr][nc] player) count; else if (state.board[nr][nc] 0) break; else { blocked; break; } } for (let step 1; step 4; step) { const nr row - dr * step; const nc col - dc * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { blocked; break; } if (state.board[nr][nc] player) count; else if (state.board[nr][nc] 0) break; else { blocked; break; } } total pointScore(count, blocked); } state.board[row][col] 0; return total; } function nearBy(row, col) { for (let r Math.max(0, row - 2); r Math.min(BOARD_SIZE - 1, row 2); r) { for (let c Math.max(0, col - 2); c Math.min(BOARD_SIZE - 1, col 2); c) { if (state.board[r][c] ! 0) return true; } } return false; } function aiMove() { let bestScore -1; let bestRow 7; let bestCol 7; let found false; for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (state.board[r][c] ! 0) continue; if (!nearBy(r, c) state.history.length 0) continue; const attack evaluatePoint(r, c, 2); const defend evaluatePoint(r, c, 1) * 0.9; const score attack defend; if (score bestScore) { bestScore score; bestRow r; bestCol c; found true; } } } if (found) { placePiece(bestRow, bestCol); } else { placePiece(7, 7); } } function undoMove() { if (state.gameOver) return; if (state.history.length 0) return; if (state.mode ai) { for (let i 0; i 2 state.history.length 0; i) { const last state.history.pop(); state.board[last.row][last.col] 0; } state.turn 1; } else { const last state.history.pop(); state.board[last.row][last.col] 0; state.turn state.history.length 0 ? 1 : (state.history[state.history.length - 1].player 1 ? 2 : 1); } state.gameOver false; redrawAll(); updateStatus(); } document.getElementById(restartBtn).addEventListener(click, () { if (state.mode ai state.turn 2) { setTimeout(() { initGame(); }, 250); } else { initGame(); } }); document.getElementById(undoBtn).addEventListener(click, undoMove); document.getElementById(modeBtn).addEventListener(click, () { state.mode state.mode ai ? pvp : ai; document.getElementById(modeBtn).textContent state.mode ai ? 切换双人模式 : 切换人机模式; initGame(); }); initGame(); /script /body /html4. 常见问题与排查技巧实录4.1 棋子对不准网格线、点偏棋盘上的棋子跟线对不齐最常见的原因是canvas的CSS尺寸和内部像素尺寸不一致。比如你给canvas设置了width544同时在CSS里写了max-width: 100%当容器宽度小于544时画布被缩放坐标换算没做缩放修正就会偏。解决方法是落子时用canvas.width / rect.width算出缩放比再把鼠标坐标乘上去。修完这个点哪里棋子落哪里误差不超过一两像素。还有一种偏是PADDING引起的系统性偏移。如果画的网格从0开始而换算公式里减了PADDING或者反过来棋盘整体会偏上偏下。排查时先打印一次坐标对应的行列值再对照像素位置基本能定位。4.2 界面缩放开小后点击错位浏览器缩放比例不是100%时offsetX和offsetY很容易给你一个“看起来对但实际不对”的值。原因是offsetX基于目标元素本身的排版坐标而canvas的绘图坐标是另一套。正确方式是从clientX出发先减去getBoundingClientRect()提供的左上角坐标再做像素比例换算。这个方法兼容浏览器缩放和CSS缩放干扰。移动端也有类似问题。手机上viewport缩放后rect.width和canvas.width往往不一致换算公式会自动弥补所以这段逻辑同样适用于触屏前提是监听了click而非只监听mousedown。4.3 边界连子漏判与AI“太傻”写在角落位置的棋子胜负判定必须靠边界检查兜底。我在checkWin里对每个方向都做了越界判断否则延伸时会访问到board[-1]或board[15]轻则漏判重则直接报错。还有一种边界问题是五连横跨两个方向交汇点比如斜线连子从左上到右下那个方向向量[1, 1]要写对。AI“太傻”的根源往往是防守系数配得过高或过低。防守系数是0.9时AI对活三、冲四这类威胁会有反应但如果改成1.5AI就会过度防守你摆一个冲四它去堵你摆另一个冲四它又去堵结果它自己永远不进攻。反过来定成0.5AI会无视你的四连直接发展自己的棋单方面被秒杀。我的调参建议是0.85到0.95之间AI攻守基本平衡。4.4 常见问题速查表现象根本原因解决方案棋子整体偏移固定距离坐标换算等式里漏了PADDING统一使用(坐标 - PADDING) / CELL_SIZE换算行列浏览器缩放后点击错位offsetX与实际绘图坐标不一致用getBoundingClientRect加比例换算棋盘画出来只有一半canvas宽度计算错乘了BOARD_SIZE间距数量应为BOARD_SIZE - 1斜线五连判不出来方向向量写错或漏了某条对角线检查[1, -1]副对角线方向AI总堵不进攻防守系数调过高将防守系数降到0.9附近人机模式悔棋后AI立刻乱走悔棋只弹一步且没重置回合人机模式连续弹两步回合重置为黑棋棋盘和棋子发虚高DPI屏幕没有适配物理像素按devicePixelRatio放大canvas再整体缩放绘制5. 复盘与后续扩展写这个项目我最大的体会是“小游戏不等于简单代码”。五子棋的规则看起来就一句话但真正要处理好棋盘绘制、坐标换算、胜负判定、AI决策这些环节一个文件也不轻松。也正是这种复杂度让它成为特别好的前端综合练习canvas绘图、事件处理、状态管理、基础算法一个项目全练到了。如果你想继续往下扩展有几个方向可以参考。加禁手规则需要改写胜负判定让它识别三三禁手和四四禁手做联网对战可以把每个落子步骤序列化通过WebSocket传给对手本地只负责渲染AI想变强可以引入多层搜索和剪枝在现有评分函数的基础上对分数最高的前几个点做深度为2或3的递归模拟一步“看穿”对手的反击。任何一个方向拿出来都足够再写一篇长文。最后分享一个实用小技巧调试时在aiMove()里临时加一行console.log(bestRow, bestCol, bestScore)能看到AI每一步的决策依据排查棋风问题会快很多。这个习惯我保留了挺久后来做其他棋类小游戏也一样受用。
