直接说结论如果你想找个练手项目把HTML5、CSS、JavaScript全串起来同时又能快速看到成果、拿去跟朋友显摆五子棋小游戏是特别合适的选择。它不像俄罗斯方块那样要处理连续下落和消行的实时逻辑也不像射击游戏那样要摸物理引擎但AI判断、棋盘绘制、落子交互、胜负检测这些前端核心能力全都能练到。我第一次做的时候脑子里想的是“不就一个15x15的棋盘嘛能有多难”结果写下去才发现真正花时间的不是画棋盘而是怎么把“赢”这件事判断得干净利落。这篇文章就把我完整做下来的过程拆开讲包括界面布局、核心玩法逻辑、AI对手的实现思路还有实机测试中遇到的一堆坑希望能帮打算动手的朋友少走点弯路。1. 项目整体设计与思路拆解1.1 核心需求解析这个项目表面上是“做一个能下棋的网页”但拆开看真正要解决的其实是四个问题。第一个问题是棋盘怎么画。HTML5给了我们一张canvas画布但canvas本身只是“一块可以画画的地方”所有格子、交叉点、棋子都得用代码一笔一笔画出来。这里要决定的是用canvas还是用DOM节点拼棋盘两个方案各有优劣后面我会细说。第二个问题是交互怎么处理。玩家点击屏幕或鼠标程序要知道你点的是哪个交叉点然后把棋子画上去。这看着简单但涉及到坐标换算——你拿到的鼠标坐标是像素值要转成“第几行第几列”而且还要处理“点歪了”的情况比如点在了两个交叉点中间这时候得自动吸附到最近的那个交叉点。第三个问题是核心玩法也就是怎么判断胜负。五子棋的胜利条件是“横、竖、左斜、右斜四个方向任意一个方向连续五个同色棋子”这个判断逻辑本身不复杂但如果程序写得不好会出现漏判、重复判断、最后一步不提示等问题。我当时反复测了几十次才把胜负检测那段代码稳定下来。第四个问题是人机对战AI。如果你只做双人对战那这个项目是一个难度如果加上AI对手那完全又是另一个难度。AI不要求多聪明但至少要能拦截玩家的“活四”“冲四”自己也要会凑“活三”否则就是一个摆设。这四个问题分别对应着前端布局、canvas绘图、算法逻辑、基础AI设计每一个都是一块独立的知识点组合在一起就是一个完整的小型项目。1.2 方案选型为什么选择HTML5和原生JavaScript做这个项目之前我其实纠结了一下要不要用框架比如Vue或者React。但后来想明白了这种体量的项目用框架完全是杀鸡用牛刀而且框架会掩盖很多底层细节。用纯HTML5 CSS JavaScript的好处有三个。第一是零依赖随便一个浏览器打开就能跑不需要装Node.js、不需要构建工具、不需要npm install这对新手极其友好。第二是逻辑透明所有的绘图、判断、状态管理都是你亲手写的每一行代码在干什么你都清清楚楚出问题了也知道去哪里排查。第三是运行效率一个15x15的棋盘总共225个交叉点最坏情况下也就225个棋子这个数据量对于浏览器来说完全不是负担根本不需要虚拟DOM那一套。棋盘绘制我选了canvas而不是DOM节点原因是性能更优、代码更省。如果用DOM你需要创建15*15225个小div每个还要绑定事件光是DOM节点的创建和事件绑定就是一笔不小的开销而且后期绘制棋子的边框、阴影效果也很麻烦。canvas只需要一张画布所有内容都在上面重绘逻辑清爽性能也好。AI部分我选择了“评估函数”这种方案而不是深度搜索。深度搜索比如极小化极大算法理论上可以让AI像职业棋手一样思考但实现起来复杂度高新手很容易写崩。评估函数方案是给棋盘上每个位置打分AI选择分数最高的位置落子。代码量小很多效果也足够应付大多数休闲场景。2. 界面布局与视觉呈现2.1 页面整体结构我最终搭出来的页面是这种结构主体容器 ├── 游戏标题区 ├── 棋盘画布区 ├── 状态提示区 └── 操作按钮区棋盘居中放置标题在上面状态提示在棋盘下方按钮放在最下面。页面整体用flex布局控制这样在不同尺寸的屏幕上都能保持居中。实际操作中你可能会遇到一个问题canvas画布的尺寸是固定的比如我设成600x600像素那在手机屏幕上就会溢出。为了解决这个问题我写了一个按窗口宽度动态调整画布大小的函数让棋盘在手机和电脑上都能自适应显示。2.2 棋盘绘制细节棋盘是15x15的格子画的时候要注意几个细节。第一是格间距。我设的是每格38像素加上四周留白边距24像素画布总宽就是38*(15-1)24*2计算下来是584我直接取整成585x585省得出现小数像素导致线条模糊。第二是线条的清晰度。canvas在高DPI屏幕上会出现线条发虚的问题原因是每一根线画在0.5像素偏移上就会模糊。解决办法是画线之前做一次整体缩放让画布的实际像素等于CSS像素乘以设备像素比然后调用ctx.scale()来修正。这一步不做的话在Retina屏幕上整个棋盘都是糊的。第三是交叉点和棋子的绘制。每个交叉点其实是一个坐标位置像素换算公式是const getPosition (row, col) ({ x: edge col * cellSize, y: edge row * cellSize });下棋就是把一个圆棋子画到对应坐标上白棋加一圈深色边框提轮廓黑棋也加边框这样在白色背景上不会“隐形”。2.3 小细节边框、网格、星位棋盘边框我画成了深棕色模拟实木棋盘的质感。里面的交叉点用浅灰色短线表示每个交叉点是一个十字交叉的短线段而不是整张网格线都画满。为什么这么画因为围棋棋盘风格只有交叉点上有线四个方向各自延伸一小段就够了这样视觉上更干净。星位点就是棋盘上的那五个小黑点也要标出来四个角星位在(3,3)、(3,11)、(11,3)、(11,11)天元在(7,7)。别小看这几个点加了之后棋盘一下子就有了“那个味”观感完全不同。3. 核心玩法落子与胜负判断的完整实现这部分是整个项目的灵魂也是我在开发过程中花费时间最多的地方。3.1 坐标换算从鼠标点击到棋盘落子玩家点击canvas时浏览器会给一个鼠标事件的offsetX和offsetY这是鼠标相对于canvas左上角的像素距离。我们要把它转成“第几行第几列”。核心逻辑是拿这个像素值减去棋盘边距然后除以格间距四舍五入取整。转换成代码就是canvas.addEventListener(click, (e) { 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 - edge) / cellSize); const row Math.round((y - edge) / cellSize); if (row 0 || row 15 || col 0 || col 15) return; // 落子逻辑 });这里有个关键点offsetX是通过e.offsetX直接拿的但getBoundingClientRect()方案更稳妥因为当canvas被CSS缩放后比如响应式布局下offsetX不一定准确而通过clientX - rect.left再乘以缩放比例才能精确换算到canvas的实际像素坐标。如果不做这一步轻则落子点偏移重则整盘棋下来位置全是歪的第一次做响应式布局的人大概率会踩这个坑。3.2 落子状态管理用一个二维数组搞定棋盘上每个位置的状态我用一个15x15的二维数组来存0表示空1表示黑棋2表示白棋。const board Array.from({ length: 15 }, () Array(15).fill(0));为什么要用数组而不是直接在canvas上判断因为canvas画上去的东西只是“显示层”它不会记录“这个位置是什么”所以程序判断胜负、AI算分、悔棋回退时都得依赖这个数组。落子时要做三个操作更新数组、重绘棋盘、切换当前玩家。注意顺序不能乱先更新数据层再重绘显示层否则会出现数据没变但棋盘已经画上子的情况。3.3 胜负判断四个方向遍历一次搞定五子棋的胜负判断就是检查以刚落的子为中心在四个方向上有没有连续五个同色棋子。我第一次写的是从棋盘左上角往右下角全盘扫描每次判断棋盘上所有同色棋子是否连成五个。这个方案虽然也能用但效率低而且逻辑绕。后来换成以“当前落子点”为中心向两端扩展的方案代码清晰了很多function checkWin(row, col, player) { const directions [ [1, 0], // 竖直 [0, 1], // 水平 [1, 1], // 右下斜 [1, -1] // 右上斜 ]; for (let [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 15 || nc 0 || nc 15) break; if (board[nr][nc] player) count; else break; } // 反方向 for (let step 1; step 5; step) { const nr row - dx * step; const nc col - dy * step; if (nr 0 || nr 15 || nc 0 || nc 15) break; if (board[nr][nc] player) count; else break; } if (count 5) return true; } return false; }这段代码里最需要注意的就是边界检查。如果棋盘是15x15边界位置比如第0行第0列往下往右扩展时很容易数组越界。不加边界判断程序会直接报错这个坑我掉进去过。另一个容易漏掉的问题是“长连”不算赢。有些规则里六个以上连子算赢无禁手规则有些规则里不允许。我做的是休闲玩法所以只要count大于等于5就判定胜利这个根据你自己的需求调整就行。3.4 重绘机制每次落子后重新画一遍canvas的绘图模式是“画布永动”它不像DOM那样会自动更新显示。所以每次落子后我要做的是整个棋盘重绘一遍。简单版是每次重绘所有内容画边框、画网格线、画星位点然后遍历整个board数组把所有非空位置都画上棋子。这个方案简单粗暴胜在逻辑清晰不会出现“漏画一个子”的问题。15x15的棋盘走完也就225个元素性能完全不需要担心。我实际用的是“增量绘制”方案只画新落的那颗棋子其他不动。这样效率更高但逻辑上要求你保证“每次只画一颗子、并且画了就必须记得上一颗子的位置”如果棋局过程中有悔棋、重开显示就会乱掉。新手建议直接用全量重绘简单可靠。3.5 游戏状态控制进行中、赢棋、平局游戏的状态我用一个字符串来表示playing表示对局中blackWin表示黑方胜whiteWin表示白方胜draw表示平局。每次落子并判断胜负之后程序会更新这个状态同时更新界面上的提示文字比如“黑棋获胜”“白棋获胜”“平局”。平局的判断很直接落子次数到达225次且没有一方获胜就是平局。实际对局里平局很少见但代码要写好否则到最后一步会出现“棋盘满了但还没提示结束玩家还继续点”的怪异情况。4. 人机对战AI从简单的评估函数开始做完了双人对战之后我觉得光是自己和自己下太无聊了所以决定加一个AI对手。现在的AI方案是评估函数打分具体思路如下。4.1 评估函数思路棋盘的每个空位程序都计算一个分数分数越高越值得落子。分数的计算基于这个位置与周围棋子的关系主要看这几个维度如果这个位置能帮助自己形成“活四”四个连续且两端都空的棋型分数最高如果能形成“冲四”四个连续但一端被堵分数次高如果能形成“活三”三个连续且两端都空的棋型分数再次如果能堵住对手的“活四”或“冲四”也会得到高分实际打分的时候我遍历每个空位然后从四个方向去检查这个位置如果放上某方棋子能形成什么样的棋型。写出来大致是这样function evaluatePoint(row, col, player) { let score 0; const directions [[1,0],[0,1],[1,1],[1,-1]]; for (let [dx, dy] of directions) { score evaluateLine(row, col, player, dx, dy); } return score; }evaluateLine这个函数会向正反两个方向延伸统计连续的同类棋子数同时检查两端是否为空活棋还是被堵死棋不同组合给不同分数。4.2 AI决策过程AI每次走棋时先遍历所有空位对每个空位计算两层分数一是“我下这里能得多少分”二是“对手下这里能得多少分”两个分数相加得到最终评分然后选评分最高的位置落子。这个“进攻防守”双管齐下的方案虽然简单但实战效果还不错。它会主动堵你的活三也会在自己有活三的时候尝试凑活四基本的攻防意识都有了。要说缺点也很明显它不会“看两步”没办法预判你下完这步后下一步会怎样所以遇到那种两步连杀比如你先冲四、再活三、最后连五的棋路它反应不过来。但对休闲玩家来说这已经够用了。4.3 先手后手与下棋顺序我做的是“玩家执黑先手AI执白后手”。黑棋先落子在(7,7)天元位置然后AI计算走第一步。如果你想让玩家后手把初始棋盘状态改一下或者加一个“选择先后手”的功能即可这里留给读者自己扩展。5. 关键工具与实操流程5.1 开发工具准备这个项目不需要装任何额外软件我用的是电脑自带的记事本加浏览器。如果你愿意用VS Code会舒服一点因为自带语法高亮和自动补全但这不是必需的。写代码的时候建议一边写一边在浏览器里验证。我是把整个项目拆成了三个文件index.html放页面结构style.css放样式game.js放逻辑。这样分文件的好处是职责清晰后期改动也方便。如果你不喜欢文件多也可以把所有代码都写在index.html中但新手不推荐这样搞太乱了。5.2 实操步骤概览实际操作流程可以按下面的步骤走建立index.html骨架引入CSS和JS文件用CSS把页面居中布局创建canvas画布在JS中初始化棋盘数组写棋盘绘制函数绑定canvas点击事件写坐标换算逻辑落子后更新数组并重绘写胜负判断函数在每次落子后检测实现状态提示和按钮功能重新开始、悔棋写AI评估函数和决策逻辑测试不同屏幕尺寸和边界情况每完成一步就打开浏览器看效果尤其是胜负判断这种核心逻辑每一步都要手动验证别攒到最后一起测那会非常痛苦。6. 常见问题与排查技巧实录6.1 棋盘线条模糊看起来不清晰这个我在前面提到过是canvas在高DPI屏幕上的经典问题。原因是浏览器以CSS逻辑像素为单位布局但实际物理像素是它的两倍甚至三倍导致画线时线宽被拉伸成小数像素视觉上就会发虚。解决办法是调整canvas的实际尺寸乘上devicePixelRatio并用CSS重置它的显示尺寸const dpr window.devicePixelRatio || 1; canvas.width canvasSize * dpr; canvas.height canvasSize * dpr; canvas.style.width canvasSize px; canvas.style.height canvasSize px; ctx.scale(dpr, dpr);这样画出来的线就是物理像素级别的清晰。6.2 点击位置偏移棋子画不到交叉点上这个问题90%是因为canvas尺寸和CSS尺寸不一致。很多人直接在CSS里给canvas设置width: 600px但canvas的width属性没设于是内部坐标和外部显示坐标不匹配坐标换算就错了。解决方法是所有尺寸统一要么全用canvas的width/height属性要么配合getBoundingClientRect()做一次换算。我推荐用getBoundingClientRect()方案天然适配响应式布局。6.3 判断胜负时数组越界数组越界的典型表现是浏览器控制台报错比如Cannot read properties of undefined指的是你访问了不存在的数组元素。原因是判断方向时往边界外扩展了。这个问题一旦出现就是必现的因为15x15棋盘的边界位置一定会走到。我在代码里加边界判断之后这个问题就再也没出现过了。6.4 悔棋功能实现注意事项悔棋功能实现很简单维护一个历史记录数组每次落子把{row, col, player}压进去悔棋时弹出来把对应位置的board值重置为0然后全量重绘。但要注意的是悔棋之后AI和玩家的“当前轮到谁”状态要同步更新否则会出现“悔棋后轮到对了但棋盘差一子”或“棋盘对了但轮到错了”的乱局。我建议把悔棋操作封装成一个函数统一处理历史栈和状态切换。6.5 手机触摸屏的适配手机上玩的时候点击事件用的是touchstart而不是click。虽然click在大多数移动浏览器上也能触发但会有300毫秒延迟手感很差。我加了这样一段代码来同时处理触摸和鼠标事件canvas.addEventListener(click, handleClick); canvas.addEventListener(touchstart, (e) { e.preventDefault(); const touch e.touches[0]; const fakeEvent { clientX: touch.clientX, clientY: touch.clientY }; handleClick(fakeEvent); });这样在手机上用的就是触摸事件没有延迟。注意e.preventDefault()放在监听器里面防止浏览器把触摸事件解释为滚动或缩放。7. 实战测试与性能观察整个写完之后我在三种环境下做了测试PC端的Chrome浏览器、手机端的Safari浏览器、还有一个低配的安卓机。测试结果如下PC端完全没有压力动画流畅没有卡顿手机端画面能自适应缩放触碰落子点击准确低配安卓机上整个棋盘重绘一次耗时不到2毫秒性能没有任何瓶颈这个项目的性能表现是让人放心的因为canvas绘图本身很快再加上数据量小浏览器处理这点工作是绰绰有余。另外我还测试了极端情况比如连续下到棋盘填满平局、不停悔棋再下、AI防守时堵边角棋子。这些问题逐一测试下来我的结论是只要数据结构设计合理多花点时间在边界判断上最终成品是很稳的。8. 后续功能扩展建议项目做完了但想继续玩的话还有几个方向可以扩展。第一个是双人联机对战。用WebSocket能做成局域网对战两个浏览器实时同步棋盘状态。这个功能涉及服务端逻辑需要Node.js环境适合作为进阶项目来练。第二个是AI增强。现在的AI是评估函数不够聪明。如果你想把AI做厉害可以研究极小化极大算法配合alpha-beta剪枝或者用蒙特卡洛树搜索MCTS都是在AI领域比较经典的方案。第三个是棋谱回放。把每一步的落子记录下来做一个“回顾对局”功能能让玩家复盘自己的棋局这个很适合用来分析自己和AI对战的失误点。第四个是皮肤和主题切换。增加木质纹理、水墨风、玻璃风之类的棋盘和棋子样式用CSS滤镜和canvas的渐变来实现。这个纯视觉层面做起来轻松效果却很直观。我个人在实际操作中的体会是这个小项目最宝贵的不是最后那个能玩的游戏而是你亲手把坐标换算、状态管理、胜负判断这些基础能力从头到尾趟了一遍。做完它之后你再去看其他游戏类前端项目比如扫雷、连连看、推箱子会发现思路都是相通的——无非是数据状态、用户交互、绘制渲染三件事的循环组合。最后再分享一个小技巧所有判断逻辑在写完第一版的时候不要急着优化先保证它在各种边界情况下不崩、不出错等稳定了再考虑效率问题。写游戏类项目稳定和正确永远是第一位的。