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×15Canvas的绘制负担很小可以保证非常流畅的交互体验。1.3 功能范围与扩展空间我做这个项目时把需求分成三个层级基础层15×15标准棋盘、黑白双方轮流落子、胜负自动判定、重新开始。这四项是五子棋的底线缺一不可。增强层人机对战、悔棋功能、选中棋子的落子提示、最后一步落子的标记。这些功能大幅提升体验实现难度也不高。进阶层难度选择简单AI/中等AI/困难AI、棋局保存与复盘、音效反馈、响应式适配手机端。这些是我后来迭代时陆续加的本文也会覆盖其中一部分的实现思路。开发时我建议按照基础层到进阶层的顺序推进每个阶段都有可玩的成果不会做了一半看不到希望。我见过太多人一上来就想做最强的AI结果评估函数写了三天棋盘还没画出来最后放弃。2. 核心细节解析与实操要点2.1 棋盘渲染的每一个坐标五子棋棋盘的核心数据结构非常简单一个15×15的二维数组0表示空位1表示黑子2表示白子。所有界面都围绕这个数组展开。绘制棋盘时有三个关键参数棋盘起点偏移量、格子边长、棋子半径。我推荐这样设置格子边长30px这样15路棋盘加上四周留白Canvas总尺寸是1530220490px。留白用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 classgame-container h1五子棋/h1 div classstatus-bar span idstatusText黑子回合/span button idrestartBtn重新开始/button button idundoBtn悔棋/button /div canvas idboard width490 height490/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×1440460。有人会误算成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_XOX为我方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原理的五子棋小游戏比十页空洞的项目描述更能证明你的代码能力。