☰
从零开始用Canvas开发五子棋网页游戏:技术选型、AI博弈与实战解析
2026/10/15 4:01:37 网站建设 项目流程

1. 项目设计与技术选型思路

1.1 这个项目到底在做什么

五子棋网页版小游戏,是前端练手项目里出现频率极高的一种。它看起来简单,但如果把完整需求列出来,会发现麻雀虽小五脏俱全:棋盘渲染、落子交互、胜负判定、悔棋、重置、人机对战AI,每一个模块都是独立的知识点。

我在带新人开发者的过程中,经常推荐这个项目作为第二个练手项目——第一个通常是待办事项列表,练的是基础DOM操作和数据管理;五子棋则跨入了Canvas绘图、坐标换算、算法设计这几个更硬核的领域,做完以后,对前端核心能力的理解会上一个台阶。

这个项目适合两类人:一类是已经掌握了HTML/CSS/JavaScript基础,想练一练真实项目结构的初学者;另一类是想要理解AI决策逻辑、想从“能玩”升级到“能赢”的进阶开发者。本文会从技术选型、核心逻辑、完整实现到踩坑记录,把整个开发过程拆开讲清楚。

我选择的技术方案是:原生JavaScript + Canvas 2D + HTML/CSS,不引入任何框架和第三方库。原因后面会详细说。

1.2 技术选型背后的取舍

很多人做五子棋,第一步就纠结要不要上Vue/React。我的建议是:这个项目最好不要用框架。

原因很简单:五子棋的核心问题是状态管理和Canvas绘制,它的逻辑复杂度完全在原生JS可以掌控的范围内。使用框架反而会引入不必要的抽象——棋盘数据放哪、状态变更怎么触发重绘、AI计算在哪个生命周期执行,这些问题都会牵扯大量精力。用原生JS,状态就放在全局变量里,重绘就调用一个draw函数,整个数据流是直线型的,更容易理解。

还有人会纠结用DOM棋子还是Canvas绘制。我做了一张对比图帮大家理清思路:

方案优点缺点适用场景
DOM棋子实现简单,直接用div/span渲染,CSS控制样式棋盘规模大时DOM节点过多,性能下降;立体感和渐变效果难以精细控制教学演示,简单原型
Canvas绘制性能好,像素级控制,容易实现渐变、阴影等视觉效果需要手动处理坐标换算、碰撞检测、重绘逻辑正式游戏,追求视觉质量
SVG矢量缩放不模糊,支持事件绑定大量节点时性能不如Canvas,维护成本中等需要矢量导出的场景

最终我选择Canvas,因为它处理棋子渐变和高光更顺手,而且游戏棋盘固定为15×15,Canvas的绘制负担很小,可以保证非常流畅的交互体验。

1.3 功能范围与扩展空间

我做这个项目时,把需求分成三个层级:

基础层:15×15标准棋盘、黑白双方轮流落子、胜负自动判定、重新开始。这四项是五子棋的底线,缺一不可。

增强层:人机对战、悔棋功能、选中棋子的落子提示、最后一步落子的标记。这些功能大幅提升体验,实现难度也不高。

进阶层:难度选择(简单AI/中等AI/困难AI)、棋局保存与复盘、音效反馈、响应式适配手机端。这些是我后来迭代时陆续加的,本文也会覆盖其中一部分的实现思路。

开发时我建议按照基础层到进阶层的顺序推进,每个阶段都有可玩的成果,不会做了一半看不到希望。我见过太多人一上来就想做最强的AI,结果评估函数写了三天,棋盘还没画出来,最后放弃。

2. 核心细节解析与实操要点

2.1 棋盘渲染的每一个坐标

五子棋棋盘的核心数据结构非常简单:一个15×15的二维数组,0表示空位,1表示黑子,2表示白子。所有界面都围绕这个数组展开。

绘制棋盘时,有三个关键参数:棋盘起点偏移量、格子边长、棋子半径。我推荐这样设置:

  • 格子边长:30px,这样15路棋盘加上四周留白,Canvas总尺寸是1530+220=490px。留白用20px,保证边缘棋子不会和画布贴死。
  • 棋子半径:12px,略小于格子边长的一半,视觉上既有间隔又不显得小。
  • 棋盘线条:用1.5px宽的深棕色线条,比纯黑更有质感。

我见过很多人把棋盘画出来就完事了,漏掉了一个重要的细节——星位。标准五子棋棋盘上有五个星位,分别在(3,3)、(3,7)、(3,11)、(7,7)、(11,3)、(11,7)、(11,11)这七个位置(以0为下标)。画星位就是画一个小圆点,半径2~3px,能让棋盘看起来专业不少。

Canvas绘制棋盘的代码不复杂,但有一个必须处理的坑:屏幕适配。如果直接按照逻辑尺寸绘制,在Retina屏上会出现明显的模糊。解决方案是读取设备的devicePixelRatio,把Canvas的实际宽高乘以这个系数,再用CSS把显示尺寸固定为逻辑尺寸。这一点我放在后面“常见问题”里详细展开,因为它是很多新手第一次遇到“明明画了,但看起来特别糊”的元凶。

2.2 落子交互背后的坐标换算

用户点击棋盘时,浏览器给我们的只有鼠标在页面上的坐标。要确定用户点了哪个格子,必须做三步换算:

第一步,获取Canvas相对于页面的偏移。用canvas.getBoundingClientRect(),它能返回元素在视口中的准确位置,比offsetTop更可靠,因为它考虑了滚动和CSS变换。

第二步,把鼠标坐标减去Canvas左上角坐标,得到相对于Canvas左上角的坐标。

第三步,用相对于Canvas的坐标减去棋盘留白偏移,再除以格子边长,最后Math.round取整,得到格子坐标。

这里有个容易踩的坑:如果Canvas在手机端或小窗口下被CSS缩放,直接按逻辑尺寸计算会错位。处理方式是把Canvas的实际绘制尺寸和CSS显示尺寸区分开,坐标换算时用“相对坐标 / 显示尺寸 * 逻辑尺寸”来修正。这个细节在响应式适配时特别重要。

落子后要做三件事:更新棋盘数组、把落子位置压入历史栈(用于悔棋)、触发重绘。重绘不是简单地画一个新棋子,而是先清空整个画布,再把棋盘、所有棋子、最后一步标记依次画出来。虽然听起来笨拙,但在15×15的规模下完全没有任何性能问题,代码却大大简化了。

2.3 胜负判定:五个方向的扫描策略

五子棋胜负判定的核心逻辑是:每次落子后,以该棋子为中心,在四个方向(水平、垂直、左斜、右斜)分别检查是否有连续的五个同色棋子。

实现时最直觉的做法是双层循环遍历整个棋盘,但那样浪费了关键的落子信息。其实只需检查刚落下的那颗棋子周围的情况即可。

我常用的实现方式:对每个方向,从当前棋子出发,向正方向数连续同色棋子的数量,再向反方向数,两段加起来,如果大于等于5则胜利。

这里的边界条件是很多新手容易写错的地方。比如在判断时,坐标不能越界——下标小于0或者大于14时,直接跳过循环。我在项目里把棋盘坐标做了统一管理,所有涉及索引的地方都先判断边界,避免数组越界导致程序崩掉。

2.4 AI决策:从评分表到博弈搜索

AI是五子棋项目里最吸引人、也最容易劝退的部分。我的经验是,分两步走:先实现一个基于评分表的“贪心AI”,再视需要升级为带搜索的“博弈AI”。

贪心AI的思路是:遍历棋盘上每一个空位,假设在当前位置落子,计算一个得分;再假设对手在当前位置落子,也计算一个得分。然后把己方得分和对手得分按一定权重相加,选择总分最高的位置落子。

这里的核心是评估函数——如何给一个位置打分。我的做法是:以落子位置为基准,在四个方向上检查棋型,统计连子数量、是否被堵住(有没有活头),然后对照棋型表打分。

棋型得分说明
五连100000已胜利
活四10000两端均无堵,下一手必胜
冲四5000一端被堵,相当于逼对手应一手
活三1000下一手可成活四
眠三500只有一端能发展
活二200有潜力发展成活三
眠二50发展空间有限

贪心AI虽然不懂“好几步之后的结果”,但如果评估函数设计得好,已经能打败很多不会下棋的新手。它的计算量是O(n²),在15×15棋盘中遍历225个位置,每个位置检查4个方向,总共不到2000次操作,毫秒级完成,不做任何优化也流畅。

如果想做更强的AI,就要引入极小化极大搜索(Minimax)加Alpha-Beta剪枝。简单说就是让AI在脑子里预演未来几步:我走一步,对手会走哪里最让我难受,我再怎么应对,如此多步展开,最后选一条整体最优的路径。这个方案效果更好,但计算量大,通常需要限制搜索深度(比如4~6层)才能保证响应速度。

关于AI,我给一个“由浅入深”的建议:先把贪心AI写通,再考虑搜索。很多人一上来就啃Minimax,结果连评估函数都没调对,AI下得像个无头苍蝇。跑通贪心AI之后,你能很明显地感觉到“它开始会堵我了”,这种成就感对继续迭代非常重要。

3. 实操过程与核心环节实现

3.1 搭建基础页面结构

整个项目只需要一个HTML文件、一个CSS文件、一个JS文件。我习惯把CSS和JS直接写在HTML里,方便快速开发和测试,等代码稳定后再拆分成独立文件。

HTML结构非常简洁:

<div class="game-container"> <h1>五子棋</h1> <div class="status-bar"> <span id="statusText">黑子回合</span> <button id="restartBtn">重新开始</button> <button id="undoBtn">悔棋</button> </div> <canvas id="board" width="490" height="490"></canvas> </div>

状态栏里的statusText用于显示当前回合和胜负结果。两个按钮分别绑定重新开始和悔棋功能。Canvas的width和height属性是逻辑尺寸,最终显示尺寸由CSS控制。

CSS部分需要关注的是居中布局和Canvas的最大宽度限制:

.game-container { max-width: 520px; margin: 0 auto; text-align: center; } canvas { width: 100%; max-width: 490px; cursor: pointer; border: 1px solid #d7c5a3; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }

这里有个细节:canvas的CSS设置为width:100%,是为了在手机端能自动收缩。但前面提到过,Canvas的内部坐标和CSS显示尺寸可能不一致,所以后面做坐标换算时要小心。如果项目暂时只做桌面端,可以先把CSS宽度固定为490px,等做响应式时再调整换算逻辑。

3.2 棋盘绘制完整实现

接下来是绘制棋盘和棋子的核心代码。我一步步说。

首先是初始化常量:

const BOARD_SIZE = 15; const CELL_SIZE = 30; const PADDING = 20; const CANVAS_SIZE = CELL_SIZE * (BOARD_SIZE - 1) + PADDING * 2; // 注意:490 = 30 * 14 + 20 * 2 // 棋盘线画在格子交界处,所以是14个间隔

这里的细节值得说一下:如果画15条线,每条线间隔30px,那么线之间的间隔有14个,所以总长度是30×14+40=460。有人会误算成30×15,导致末线的位置偏移。这个错误在数字上只差30px,但棋盘上的棋子坐标全部错位一格,排查起来耗时。

棋盘的绘制函数:

const canvas = document.getElementById('board'); const ctx = canvas.getContext('2d'); function drawBoard() { ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); // 背景 ctx.fillStyle = '#f0d9a8'; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); // 网格线 ctx.strokeStyle = '#5a4a3a'; ctx.lineWidth = 1.5; for (let i = 0; i < BOARD_SIZE; i++) { // 横线 ctx.beginPath(); ctx.moveTo(PADDING, PADDING + i * CELL_SIZE); ctx.lineTo(PADDING + (BOARD_SIZE - 1) * CELL_SIZE, PADDING + i * CELL_SIZE); ctx.stroke(); // 竖线 ctx.beginPath(); ctx.moveTo(PADDING + i * CELL_SIZE, PADDING); ctx.lineTo(PADDING + i * CELL_SIZE, PADDING + (BOARD_SIZE - 1) * CELL_SIZE); ctx.stroke(); } // 星位 const starPoints = [[3,3], [3,11], [7,7], [11,3], [11,11]]; ctx.fillStyle = '#5a4a3a'; starPoints.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(PADDING + x * CELL_SIZE, PADDING + y * CELL_SIZE, 3, 0, Math.PI * 2); ctx.fill(); }); }

棋子要用渐变和阴影做出立体感,这一步是拉开“业余Demo”和“成品游戏”差距的关键。黑子用径向渐变,从深灰到纯黑;白子从白色到浅灰。此外,最后一步落子位置加一个红色小方框标记,方便玩家看清当前局面。

function drawPiece(row, col, player) { const x = PADDING + col * CELL_SIZE; const y = PADDING + row * CELL_SIZE; const gradient = ctx.createRadialGradient( x - 3, y - 3, 2, x, y, 12 ); if (player === 1) { gradient.addColorStop(0, '#4a4a4a'); gradient.addColorStop(1, '#111111'); } else { gradient.addColorStop(0, '#ffffff'); gradient.addColorStop(1, '#d4d4d4'); } ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 4; ctx.shadowOffsetY = 2; ctx.beginPath(); ctx.arc(x, y, 12, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; }

3.3 落子交互与事件绑定

落子逻辑的核心是:把鼠标点击换算成棋盘格坐标,然后判断这个位置是否为空、游戏是否结束。

function handleClick(e) { if (gameOver) return; const rect = canvas.getBoundingClientRect(); const scaleX = CANVAS_SIZE / rect.width; const scaleY = CANVAS_SIZE / 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; if (board[row][col] !== 0) return; board[row][col] = currentPlayer; history.push({ row, col, player: currentPlayer }); if (checkWin(row, col, currentPlayer)) { gameOver = true; const winner = currentPlayer === 1 ? '黑子' : '白子'; statusText.textContent = winner + '获胜'; drawBoard(); drawAllPieces(); return; } currentPlayer = currentPlayer === 1 ? 2 : 1; statusText.textContent = currentPlayer === 1 ? '黑子回合' : '白子回合'; drawBoard(); drawAllPieces(); }

这里的坐标换算是做了适配的。scaleX和scaleY把CSS显示尺寸映射回逻辑尺寸,这样即使Canvas被CSS缩放,点击位置依然准确。这个设计是我在实现手机端适配时加的,当时踩了好几次坑才意识到问题所在。

3.4 胜负判定完整实现

checkWin函数以刚落下的棋子为中心,在四个方向扫描。用方向向量来组织代码,可以少写很多重复逻辑。

function checkWin(row, col, player) { const directions = [ [0, 1], // 水平 [1, 0], // 垂直 [1, 1], // 左斜 [1, -1] // 右斜 ]; for (const [dx, dy] of directions) { let count = 1; // 正方向 for (let step = 1; step < 5; step++) { const nr = row + dx * step; const nc = col + dy * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] !== player) break; count++; } // 反方向 for (let step = 1; step < 5; step++) { const nr = row - dx * step; const nc = col - dy * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] !== player) break; count++; } if (count >= 5) return true; } return false; }

这个写法的关键在于沿着某个方向从落子点开始数,不需要遍历整个棋盘。四个方向各数一遍,一段边界检查和数值比较,整体复杂度极低。我见过有些人用遍历整盘的方式判断,能在15×15的棋盘里写出上百行的循环,其实完全没有必要。

3.5 一个能用的贪心AI实现

贪心AI的核心是评估函数和评分循环。我先定义空位和棋型的评分表,然后遍历每个空位计算总分。

function evaluatePoint(row, col, player) { let score = 0; const directions = [[0,1], [1,0], [1,1], [1,-1]]; for (const [dx, dy] of directions) { // 向两个方向延伸,统计连续棋子数和开放程度 let count = 1; let openEnds = 0; // 正方向 let blocked = 0; for (let step = 1; step < 5; step++) { const nr = row + dx * step; const nc = col + dy * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) { blocked++; break; } if (board[nr][nc] === player) { count++; } else if (board[nr][nc] === 0) { openEnds++; break; } else { blocked++; break; } } // 反方向 for (let step = 1; step < 5; step++) { const nr = row - dx * step; const nc = col - dy * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) { blocked++; break; } if (board[nr][nc] === player) { count++; } else if (board[nr][nc] === 0) { openEnds++; break; } else { blocked++; break; } } // 根据棋型打分 if (count >= 5) score += 100000; else if (count === 4 && openEnds === 2) score += 10000; else if (count === 4 && openEnds === 1) score += 5000; else if (count === 3 && openEnds === 2) score += 1000; else if (count === 3 && openEnds === 1) score += 500; else if (count === 2 && openEnds === 2) score += 200; else if (count === 2 && openEnds === 1) score += 50; } return score; }

AI下棋的主逻辑:

function aiMove() { let bestScore = -Infinity; let bestRow = -1; let bestCol = -1; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (board[r][c] !== 0) continue; // 己方视角 const attackScore = evaluatePoint(r, c, 2); // 假设对手在这里落子 board[r][c] = 1; const defendScore = evaluatePoint(r, c, 1); board[r][c] = 0; const totalScore = attackScore * 1.1 + defendScore; if (totalScore > bestScore) { bestScore = totalScore; bestRow = r; bestCol = c; } } } return { row: bestRow, col: bestCol }; }

attackScore乘以1.1,是让AI在“进攻”和“防守”之间略微偏向前者。这个系数是我反复试出来的,如果不加偏重,AI会一直被动堵玩家,自己缺乏主动做棋的能力,游戏体验很差。调参过程中我发现,1.1到1.2之间是比较合理的区间,系数太大AI会忽略防守,被玩家轻松偷鸡。

人机对战的调用逻辑是:每次黑子(人类)落子后判胜负,如果游戏未结束,则延迟300ms调用aiMove,让AI“想一下”再落子。这个延迟非常重要——如果AI瞬间落子,玩家会感觉对面是台机器,没有对弈的节奏感。300ms加上Canvas重绘时间,刚刚好。

3.6 悔棋与重新开始

悔棋功能的实现依赖于历史栈。每次落子时,把行、列、玩家压入栈中。悔棋时弹出顶端记录,把对应棋盘位置清零,再恢复currentPlayer。

人机模式下,悔棋要连续撤回两步:AI的一手和玩家的一手。否则玩家悔棋后,会发现自己下完,AI紧接着又走了一手。

function undoMove() { if (gameOver || history.length === 0) return; if (mode === 'ai') { // 撤回AI的一步 const aiMoveRecord = history.pop(); board[aiMoveRecord.row][aiMoveRecord.col] = 0; // 撤回玩家的一步 const playerMoveRecord = history.pop(); board[playerMoveRecord.row][playerMoveRecord.col] = 0; } else { const record = history.pop(); board[record.row][record.col] = 0; } currentPlayer = 1; gameOver = false; statusText.textContent = '黑子回合'; drawBoard(); drawAllPieces(); }

重新开始更简单:棋盘数组全部置0、清空历史栈、currentPlayer重置、gameOver重置、重绘。

function restartGame() { board = Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(0)); history = []; currentPlayer = 1; gameOver = false; statusText.textContent = '黑子回合'; drawBoard(); }

3.7 双人对战模式

在“双人模式”下,两个玩家轮流点击落子,代码逻辑和之前handleClick几乎一致。唯一的区别是,currentPlayer在1和2之间切换,不经过AI。

我建议把游戏模式做成一个可选项,放在状态栏里。一个下拉框选择“双人对战”或“人机对战”,切换时自动重置棋盘。这个功能的加入,让游戏的可玩性提升不少——我自己测试AI时用双人模式手动摆棋型,比反复和AI下完整局验证评估函数高效得多。

4. 常见问题与排查技巧实录

4.1 Retina屏下的Canvas模糊问题

直到今天,这依然是Canvas新手的头号问题。现象是:明明代码尺寸都正确,画出来的棋子边缘发虚,线条像被加水稀释过。

原因很简单:物理像素和逻辑像素不一致。在Retina屏上,一个CSS像素对应2个或3个物理像素,而Canvas默认按CSS像素分配物理像素,导致分辨率不足。

解决方案是获取devicePixelRatio,把Canvas的绘制缓冲区放大:

const DPR = window.devicePixelRatio || 1; canvas.width = CANVAS_SIZE * DPR; canvas.height = CANVAS_SIZE * DPR; canvas.style.width = CANVAS_SIZE + 'px'; canvas.style.height = CANVAS_SIZE + 'px'; ctx.scale(DPR, DPR);

这里的关键是ctx.scale(DPR, DPR),它让Canvas上下文的坐标系统保持和逻辑尺寸一致,所有绘制代码不用改动。如果不加这行,就需要把所有坐标都乘以DPR,代码会变得一团糟。

注意:加了DPR适配后,3.3节中的坐标换算逻辑也需要相应调整。因为canvas.getBoundingClientRect().width返回的是CSS尺寸,而绘制时用的是逻辑尺寸,两者在加了DPR后依然一致——Canvas的width属性虽然放大了,但style.width仍然是490px,所以getBoundingClientRect不受影响。坐标换算只乘以CANVAS_SIZE/rect.width即可,与DPR无关,这算是一个不错的副作用。

4.2 棋型统计的边界陷阱

评估函数中最容易出错的地方,是棋型统计时如何处理“边界”和“被堵”。我遇到过一种典型的Bug:己方有三颗连子,但一端贴着棋盘边缘,另一端被对方堵住,这种棋型其实毫无威胁,但代码只看了连子数量是3,就给了上百的分数,导致AI在无关紧要的位置落子。

解决办法是每一步延伸时,遇到棋盘边界和对方棋子都要标记“被堵”,最终统计时要同时考虑连子数量和两端状态。我在2.4节的棋型表里列出了对应的分数,就是基于这个考虑。

另一个容易忽略的是“跳活三”这类复杂棋型:比如棋子排列是XOO_XO(X为我方,O为空位),中间隔了一个空位也能连成五连。简单的线性扫描无法识别这种结构,需要额外判断。如果做的是贪心AI,可以暂时忽略这种情况,问题不大;但如果目标是做一个困难AI,这种棋型的识别能力就是必须的,它决定了AI能不能提前封堵关键点。

4.3 AI响应延迟与性能优化

我的贪心AI评估函数复杂度看起来不高,但在开发过程中我发现一个问题:如果同时检测所有空位、并且对每个位置都做完整的棋型统计,在15×15棋盘上需要约225×4×8 ≈ 7200次操作,浏览器完全抗得住。可一旦把评估函数写得复杂(比如加入了跳子识别、双三判断),操作量就涨到几万次,AI的落子时间会达到几百毫秒,玩家能感觉到明显的卡顿。

优化思路有三个方向:

第一,剪枝。只评估“需要计算”的位置:离已有棋子两格以内的空点优先计算,完全空白区域暂时跳过。这个优化能减少80%以上的无效计算。

第二,提前Termination。如果某个空位已经能形成五连,直接返回该位置,不再继续遍历。

第三,使用Web Worker。把AI计算放到后台线程,避免计算期间阻塞UI交互。这个方案对极小化极大搜索特别有效,但会引入消息通信的复杂度,建议等AI复杂到一定程度再考虑。

我做笔记时总结过:AI的性能瓶颈几乎都出在评估函数写得过于复沓,而不是循环次数本身。保持评估函数简洁,配合落子点附近的剪枝,是性价比最高的方案。

4.4 浏览器兼容与事件细节

canvas.getBoundingClientRect和click事件在现代浏览器中兼容性都很好,但有几点值得注意:

  • 移动端使用click事件会有300ms延迟(旧版浏览器),用touchstart事件可以消除。我测试后发现,在现代浏览器(Chrome移动端、Safari新版本)中,click事件已经基本没有延迟,所以没有单独处理touch事件。如果要做更精细的体验,可以监听pointerdown代替click。
  • 如果页面有横向滚动,clientX获取的是视口坐标,需要加上window.scrollX才能换算到页面坐标,再和canvas偏移做减法。一般在桌面端不会遇到这个问题,但把它写进代码里更保险。
  • 连续快速点击时,可能出现前一次落子还没处理完,后一次click就触发的情况。我通常在handleClick开头做了gameOver判断和落子合法性检查,事实上已经避免了大部分重复落子问题。

4.5 常见问题速查表

问题现象排查方向解决方案
Canvas模糊线条和棋子边缘发虚未处理DPR按devicePixelRatio放大缓冲区并ctx.scale
点击位置错位下棋总落在目标格旁边坐标换算被CSS缩放污染用逻辑尺寸/显示尺寸做比例换算
胜负误判明明有五连却未判胜扫描方向不全或越界用方向向量遍历4个方向,边界检查
AI走固定某几处总是重复同样的落点评估函数未区分进攻/防守引入attackScore和defendScore权重
悔棋回退错乱悔棋后AI立刻又走人机模式只退了一步连续弹栈两步再恢复状态
棋盘首尾线偏移最后一根线位置不对格子数误算为15间距×(路数-1)+2×留白

这张表是我每次带人做同类项目时都会给到的一份自查清单。出现诡异的显示问题,先对照这张表排查一轮,能省下大量时间。

5. 体验优化与扩展方向

5.1 从能玩到好玩的三个细节

如果代码已经跑通了,接下来可以做三个低成本但体验提升明显的优化。

第一个是落子音效。很多前端项目忽略声音反馈,但五子棋这种轮流落子的游戏,声音能带来非常直接的即时反馈。用Web Audio API生成一个简短的木鱼声只需几十行代码,不需要任何音频文件:

function playDropSound() { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.type = 'triangle'; oscillator.frequency.setValueAtTime(520, audioCtx.currentTime); gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.start(); oscillator.stop(audioCtx.currentTime + 0.15); }

第二个是棋型提示。当鼠标悬停在棋盘上时,显示一个半透明的棋子,让玩家知道落子位置。实现方式是在mousemove事件中记录鼠标对应的格子坐标,然后触发重绘,在半透明状态下画一个对应棋子。注意这个操作会重复触发drawBoard,但Canvas重绘开销很小,不会有性能问题。

第三个是终局动画。判定胜负后,把获胜的五颗棋子在棋盘上高亮出来(比如画一个红色圆环),比只显示一段文字更有冲击力。实现时需要保存获胜的具体五子坐标,这里可以在checkWin里返回获胜棋子的数组。

5.2 进阶方向一:困难AI与博弈树

做完贪心AI之后,想挑战更强的AI,就绕不开极小化极大搜索。简单理解:AI递归地模拟未来几步的落子,自己走一步,假设对手走最优应对,再假设自己继续走……直到达到设定的搜索深度,返回局面评分,然后从所有可能的决策路径中选一条对AI最有利的。Alpha-Beta剪枝则是在搜索过程中剪掉“无论如何都不是最优”的分支,大幅减少计算量。

我的建议是搜索深度先设为2,跑通了再加到4。深度每加2,计算量指数级增长。在纯JavaScript里,深度4加上Alpha-Beta剪枝通常还能控制在几百毫秒内,再高就要考虑Web Worker了。这个进阶方向做完,你对“递归”“启发式搜索”“剪枝”这些算法概念的理解会远超看一遍书的水平。

5.3 进阶方向二:棋局保存与复盘

五子棋的棋局数据本质上是一个历史数组,把整局历史序列化保存下来非常容易。可以用JSON格式把history数组存到localStorage,或者导出成字符串分享给朋友。

复盘功能的实现也简单:把历史栈回放一遍,每500ms落一手。代码核心就是遍历history,逐手setTimeout触发重绘。这个功能做出来很有成就感,而且实现成本不高,适合作为迭代的下一站。

5.4 进阶方向三:多端适配与平台接入

如果把项目当成自己的作品来打磨,可以做移动端触屏适配、深浅色主题切换、甚至接一个排行榜服务保存玩家的胜率。

移动端适配最需要注意的是CSS缩放和坐标换算。只要按3.3节的方式,用逻辑尺寸与显示尺寸的比值做换算,同时Canvas的DPR适配做好,手机竖屏下体验不会有明显问题。另外,移动端的页面布局要改一改,棋盘居中、按钮加大,这些用媒体查询就能解决。

一些开发过程中的实在感受

这个项目我陆陆续续迭代过好几个版本,每次都会有新的体会。最初做双人对战版只用了半天,棋盘画完、逻辑写完就收工了。真正让我花时间打磨的是AI——第一次写出带着明显缺陷的贪心AI时,它下棋的新手病我其实都看得出来:总是顾着堵我,自己乱七八糟地连。后来给评估函数加上进攻权重,局面一下子变聪明了,那是一个很有成就感的时刻。

给正在做或准备做这个项目的朋友几条经验:第一,先跑通最简版本,哪怕AI只会堵三连,也先跑起来,再慢慢调棋型和参数。第二,评估函数的设计一定要模块化,没有评估函数的AI像没有味觉的厨师,烧什么菜都靠蒙。第三,交互细节(音效、悬停提示、悔棋)虽然不影响功能,但影响“像不像一个产品”。前端项目最终拼的就是这一层。

如果你把这个项目做完了,可以考虑把它发布到某个静态托管平台,给自己的作品集里添一个能玩的Demo。找工作面试时,一个能现场演示、能聊明白AI原理的五子棋小游戏,比十页空洞的项目描述更能证明你的代码能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询