HTML五子棋实现,是前端练习里特别合适的一个目标:规则一眼能懂,交互直观,但又不像那些纯小游戏一样一两百行就糊弄完。你需要在canvas上画棋盘和棋子,处理鼠标点击和坐标换算,写胜负判定,还要考虑电脑对手到底该怎么走。这篇文章就基于我最近整理的一版单文件实现,把这些环节逐个拆开讲清楚,包括数据怎么存、棋子怎么画、如何判断五子连珠,以及一个基础但能实际对战的人机AI。最终代码保存为.html文件,浏览器直接打开就能玩,不需要搭建任何环境。
如果你正打算学HTML+CSS+JS的综合实战,或者课设想做个带点算法含量的网页小游戏,这篇内容可以直接照着敲。代码量不大,但该踩的坑一个不少:canvas坐标偏移、缩放后点击错位、连子判断漏边界、AI只看眼前棋,这些问题我都会结合自己的调试过程展开聊。
1. 整体设计:棋盘模型与技术选型
1.1 技术选型:为什么是纯HTML+CSS+JS单文件
做网页版五子棋,第一件事是决定棋盘用什么方案实现。大家翻教程时常见两种:一种是用canvas绘制整个棋盘和棋子,另一种是纯DOM加CSS定位,用节点来模拟棋子。我自己三版里都试过,最终锁定canvas方案。原因很简单,棋子绘画效果可控,画渐变、画阴影、做高DPI适配都顺手,而且五十次落子就是五十次重绘指令,不会像DOM方案那样在页面上挂几百个节点。
纯HTML+CSS+JS单文件的好处是零依赖、零构建。不需要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 id="app"> <h1>五子棋</h1> <div id="toolbar"> <span id="status">黑棋落子</span> <button id="restartBtn">重新开始</button> <button id="undoBtn">悔棋</button> <button id="modeBtn">切换人机模式</button> </div> <canvas id="board"></canvas> </div>CSS部分尽量简洁,但棋盘背景、按钮间距和整体居中还是要有的:
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; }连子得分的设计可以参考下表:
| 连子数 | 两端开放 | 一端被封 | 两端被封 |
|---|---|---|---|
| 1 | 10 | 0 | 0 |
| 2 | 100 | 10 | 0 |
| 3 | 1000 | 100 | 0 |
| 4 | 10000 | 1000 | 0 |
| 5及以上 | 100000 | 100000 | 100000 |
AI遍历所有落子候选时先做一步剪枝:只考虑距离已有棋子两格以内的空位,棋盘前几手完全远离战场的位置就不必算了。这一步能让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 lang="zh-CN"> <head> <meta charset="utf-8"> <title>HTML五子棋</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 id="app"> <h1>五子棋</h1> <div id="toolbar"> <span id="status">黑棋落子</span> <button id="restartBtn">重新开始</button> <button id="undoBtn">悔棋</button> <button id="modeBtn">切换人机模式</button> </div> <canvas id="board"></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> </html>4. 常见问题与排查技巧实录
4.1 棋子对不准网格线、点偏
棋盘上的棋子跟线对不齐,最常见的原因是canvas的CSS尺寸和内部像素尺寸不一致。比如你给canvas设置了width="544",同时在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.5,AI就会过度防守,你摆一个冲四它去堵,你摆另一个冲四它又去堵,结果它自己永远不进攻。反过来定成0.5,AI会无视你的四连直接发展自己的棋,单方面被秒杀。我的调参建议是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每一步的决策依据,排查棋风问题会快很多。这个习惯我保留了挺久,后来做其他棋类小游戏也一样受用。