资讯详情

手把手教你用原生JavaScript+Canvas从零开发五子棋小游戏

📅 2026/10/19 13:58:25 | 华诺云谱 👁 阅读
手把手教你用原生JavaScript+Canvas从零开发五子棋小游戏
如果你做前端开发有个把月多半会有这种感觉看教程、抄组件都挺顺手但真要独立从零写一个能交互、带状态、有点玩法的小应用大脑容易一片空白。HTML5五子棋小游戏就是拿来填这个空白的。它不像管理系统那样全是表单和表格也不像官网那样偏静态展示而是一个有棋盘、有棋子、有落子逻辑、有胜负判断、甚至有人机AI的完整小项目麻雀虽小五脏俱全。这篇就基于我个人完整实现一遍的经历把“源代码HTML5五子棋小游戏web开发”这件事从选型到踩坑一层层拆开讲清楚。如果你正打算用一个周末练手或者想给简历塞一个拿得出手的独立作品这篇文章可以让你少走不少弯路。我会先把设计思路讲明白再给核心代码实现最后把那些不跑一遍根本发现不了的坑列出来。全程原生JavaScript Canvas不依赖任何框架适合所有人从头跟到尾。1. 项目设计与技术选型1.1 为什么是Canvas而不是DOM或SVG刚决定做五子棋的时候第一个问题不是怎么写棋子而是用什么技术画棋盘。我当时列了三种方案纯DOM CSS网格、SVG、Canvas。纯DOM实现最直观15×15的棋盘不就是225个div吗每个div绑定点击事件落子就往里面塞一个圆点。听起来简单实际做起来会发现两个麻烦。第一棋子要跨格摆放棋盘交叉点才是落子位置如果每个网格是一个div点击事件要精确映射到格子线交叉的位置坐标换算反而绕。第二棋子落在交叉点上会压到相邻格子的边线CSS里处理这种跨元素视觉比想象中麻烦尤其是后面想做棋子阴影、高亮最后一步这些效果时DOM方案会让人抓狂。SVG方案优点是棋子天然是图形元素有事件模型但缺点是大量棋子节点会撑大DOM树后续动画、重绘控制不够直接而且像素控制没有Canvas灵活。最终我选了Canvas原因很实在它本质是一块画布所有绘制逻辑集中在一个draw函数里棋盘、棋子、高亮都由同一套绘图指令输出坐标系一旦建立就非常统一。通俗点说DOM像是在HTML里摆积木Canvas像在纸上画画五子棋这种规则固定、画面简单的游戏画画的方式更顺手性能也完全扛得住。提示如果之后想扩展成联机对战Canvas方案也更容易配合渲染层做局部刷新不必为每个棋子维护独立的DOM生命周期。1.2 棋盘数据结构与坐标模型技术选型定下来之后紧接着要定数据模型。五子棋的标准棋盘是15×15即15条横线、15条竖线落子在交叉点上。我在代码里用一个二维数组来表示const BOARD_SIZE 15; let board []; for (let i 0; i BOARD_SIZE; i) { board[i] new Array(BOARD_SIZE).fill(0); }数组的值约定0表示空1表示黑棋2表示白棋。这个约定贯穿整个项目落子就是给board[row][col]赋值判断胜负就是扫描这个数组。棋盘上的坐标关系是初学最容易绕晕的地方我先说清楚逻辑坐标(row, col)第几行第几列范围0~14。像素坐标(x, y)Canvas里的实际点。两者通过一个格子大小cellSize换算。我用的格子大小是40px棋盘边距padding设为30px。那么交点对应像素坐标的公式是const cellSize 40; const padding 30; function colToX(col) { return padding col * cellSize; } function rowToY(row) { return padding row * cellSize; } function xToCol(x) { return Math.round((x - padding) / cellSize); } function yToRow(y) { return Math.round((y - padding) / cellSize); }这里有一个非常关键的细节反算坐标用Math.round而不是Math.floor。因为交点刚好落在格子的边界上四舍五入才能映射到最近的那条线。如果用floor点击靠近右边界的时候会错位一格直观表现就是“我明明点在第二根线棋子却跑到第三根”。棋盘整体尺寸等于const canvasWidth padding * 2 cellSize * (BOARD_SIZE - 1);注意是(BOARD_SIZE - 1)而不是BOARD_SIZE。15条线只有14个间隔这个错误我第一次就犯过画出来棋盘右边多出一截空白检查半天才发现是尺寸公式的问题。计算出来宽高都是30 * 2 40 * 14 620px正好一个正方形。2. 核心实现细节与胜负判定算法2.1 绘制棋盘与棋子的代码实现绘制棋盘是游戏视觉的基础Canvas的draw函数里依次做三件事画背景、画网格、画星位。function drawBoard() { // 背景 ctx.fillStyle #DEB887; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 网格 ctx.strokeStyle #8B5A2B; ctx.lineWidth 1; // 画横线 for (let row 0; row BOARD_SIZE; row) { ctx.beginPath(); ctx.moveTo(padding, rowToY(row)); ctx.lineTo(canvasWidth - padding, rowToY(row)); ctx.stroke(); } // 画竖线 for (let col 0; col BOARD_SIZE; col) { ctx.beginPath(); ctx.moveTo(colToX(col), padding); ctx.lineTo(colToX(col), canvasHeight - padding); ctx.stroke(); } // 星位天元与四星 const stars [[7, 7], [3, 3], [3, 11], [11, 3], [11, 11]]; stars.forEach(([row, col]) { ctx.beginPath(); ctx.arc(colToX(col), rowToY(row), 4, 0, Math.PI * 2); ctx.fillStyle #8B5A2B; ctx.fill(); }); }这里有个初学时常见的坑线条发虚模糊。原因是Canvas的坐标系统里当lineWidth为1时线条中心刚好落在像素边界上渲染时会被“稀释”成灰色虚影。解决方案是给坐标加0.5偏移ctx.moveTo(padding 0.5, rowToY(row) 0.5);别小看这0.5像素它决定了画出来的棋盘是清晰锐利还是灰蒙蒙一片。绘制棋子的核心是用渐变营造立体感这样棋子看起来不是贴纸而是有厚度的实体function drawPiece(row, col, isBlack) { const x colToX(col); const y rowToY(row); const radius 18; ctx.shadowColor rgba(0, 0, 0, 0.5); ctx.shadowBlur 4; ctx.shadowOffsetX 2; ctx.shadowOffsetY 2; const gradient ctx.createRadialGradient( x - 5, y - 5, 2, x, y, radius ); if (isBlack) { gradient.addColorStop(0, #636363); gradient.addColorStop(1, #141414); } else { gradient.addColorStop(0, #FFFFFF); gradient.addColorStop(1, #C0C0C0); } ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle gradient; ctx.fill(); }高光点设在棋子左上角配合黑色阴影一眼看过去棋子就像浮在棋盘上。这个效果在移动端尤其明显能让整个游戏的质感提升一个档次。2.2 胜负判定怎么写才不出Bug胜负判定是整个游戏的核心算法也是最容易写错的地方。五子棋的规则很朴素同色棋子在横、竖、撇、捺四个方向上任意一个连续五颗即获胜。我在最初的版本里写过一种非常笨的判定方法每下一步棋就遍历整个棋盘上所有棋子对每个棋子检查四个方向。这个写法能跑但毫无必要因为一盘棋的胜负只可能围绕最后落下的那颗棋子产生。更好的思路是从当前落子点出发沿着四个方向向两端延伸统计连续同色棋子的数量一旦某个方向达到5颗就判定获胜。四个方向的向量定义如下const DIRECTIONS [ { dr: 0, dc: 1 }, // 水平方向 { dr: 1, dc: 0 }, // 垂直方向 { dr: 1, dc: 1 }, // 右下对角线 { dr: 1, dc: -1 } // 左下对角线 ];对每个方向分别向正反两个方向“延伸数子”function countDirection(row, col, dr, dc, player) { let count 1; let r row dr; let c col dc; while ( r 0 r BOARD_SIZE c 0 c BOARD_SIZE board[r][c] player ) { count; r dr; c dc; } return count; } function checkWin(row, col) { const player board[row][col]; if (player 0) return false; for (const dir of DIRECTIONS) { const forward countDirection(row, col, dir.dr, dir.dc, player); const backward countDirection(row, col, -dir.dr, -dir.dc, player); if (forward backward - 1 5) { return true; } } return false; }forward backward - 1里的减1是因为当前落子点在两个方向上都重复计算了一次。这一点我建议你在纸上画一下很容易理解但代码里漏掉减1会直接导致4子误判为5子。边界条件用了区间判断防止数组越界。棋盘上有棋子时1和2的值代表不同玩家判断时用board[r][c] player而不是! 0这样才能避免把对方棋子也算进来。这里还有一个优化点最外层循环只遍历四个基本方向就行。因为水平方向的正向是(0, 1)反向是(0, -1)它们本质属于同一条直线。我直接把四个方向都列出来对每个方向做双向延伸对称且不容易遗漏。2.3 落子交互与坐标转换棋盘画好、判定逻辑写好接下来最重要的就是让玩家能点上去落子。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 xToCol(x); const row yToRow(row); if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return; } if (board[row][col] ! 0) { return; } placePiece(row, col, currentPlayer); });这里有一个很容易被忽视的适配问题。如果你的Canvas内部实际尺寸是620px但在CSS里显示宽度被拉伸成了400px那么e.clientX - rect.left拿到的是CSS像素直接当作Canvas内部坐标使用会导致点击偏移棋子落在你点的位置的右下方。所以我在代码里用scaleX和scaleY做了坐标缩放把CSS坐标换算回Canvas内部坐标。移动端开发时这个坑尤其常见不加缩放代码在Retina屏幕上点击误差会让你怀疑人生。落子函数里包含三步更新数组、绘制棋子、检查胜负并切换玩家function placePiece(row, col, player) { board[row][col] player; drawPiece(row, col, player 1); history.push({ row, col, player }); if (checkWin(row, col)) { showWinner(player); gameOver true; return; } if (isBoardFull()) { showDraw(); gameOver true; return; } currentPlayer player 1 ? 2 : 1; statusText.innerText currentPlayer 1 ? 黑棋落子 : 白棋落子; }history数组是给悔棋功能准备的数据结构每走一步就记录一次后面你会看到它的价值。3. 人机AI与交互体验的打磨3.1 六行权值表搞定简单人机AI做完了双人对战接下来是让这个游戏更有可玩性的部分人机AI。很多人听到AI就发怵觉得要懂博弈树、要会剪枝。实际上如果只想做一个“陪你下棋、能赢新手”的AI权值法就够了而且实现起来非常直白。原理是这样棋盘上每个空位都有一个“吸引程度”这个值由它周围已有的棋子决定。如果某个空位旁边已经有3颗黑棋连成线那这个位置肯定是好位置如果旁边是3颗白棋连成线那这个位置就是危险位置必须立刻堵。我对每个空位扫描四个方向统计每个方向上黑白棋的连子情况套用一个分数表棋型分值连五100000活四10000冲四5000活三1000眠三200活二100眠二20AI对每个空位分别计算“黑棋视角得分”和“白棋视角得分”取两者中的较大值作为该位置的权重。防守时白棋视角得分高进攻时黑棋视角得分高综合起来就是最简单的攻防一体。然后权重最大的位置就是落子点。这个算法的核心扫描函数function evaluatePoint(row, col) { let totalScore 0; for (const dir of DIRECTIONS) { for (let color 1; color 2; color) { let count 1; let openEnds 0; // 正向延伸 let r row dir.dr; let c col dir.dc; while (r 0 r 15 c 0 c 15 board[r][c] color) { count; r dir.dr; c dir.dc; } if (r 0 r 15 c 0 c 15 board[r][c] 0) { openEnds; } // 反向延伸 r row - dir.dr; c col - dir.dc; while (r 0 r 15 c 0 c 15 board[r][c] color) { count; r - dir.dr; c - dir.dc; } if (r 0 r 15 c 0 c 15 board[r][c] 0) { openEnds; } const score getScore(count, openEnds); if (color currentPlayer) { totalScore score * 1.1; } else { totalScore score; } } } return totalScore; }你没有看错这是一个非常简化的权值模型只考虑连续同色棋子数和两端是否开放。真正的棋型判断比这个复杂得多比如跳活三、双冲四这种组合棋型简单权值法根本覆盖不到。但这个AI对付刚上手的玩家已经足够。getScore函数就是把连子数映射成分数function getScore(count, openEnds) { if (count 5) return 100000; if (count 4) { if (openEnds 2) return 10000; if (openEnds 1) return 5000; return 1000; } if (count 3) { if (openEnds 2) return 1000; if (openEnds 1) return 200; return 50; } if (count 2) { if (openEnds 2) return 100; if (openEnds 1) return 20; return 10; } if (count 1) { if (openEnds 2) return 30; if (openEnds 1) return 5; return 1; } return 0; }我给自己人机调参时发现AI如果只考虑自己进攻防守会很稀烂容易被玩家连续冲四带走。所以最后用了“进攻加分防守平等”的加权策略自己当前的棋型分数乘以1.1对手棋型分数乘以1.0。这样AI会优先保证自己连子但对手威胁极大时也能优先堵截实战体验好了非常多。3.2 悔棋、重开与状态管理设计完AI交互上还有一个必须做的功能悔棋。一个人机对战游戏里玩家经常因为看错一步而想反悔。这个需求在数据层面就是维护一个历史栈落子时入栈悔棋时弹出上一次记录把对应位置在数组里清空然后重绘整个棋盘。function undoMove() { if (history.length 0 || gameOver) return; const last history.pop(); board[last.row][last.col] 0; // 如果是人机模式AI刚走的那步也要撤回 if (gameMode ai currentPlayer ! playerColor) { const aiMove history.pop(); board[aiMove.row][aiMove.col] 0; } redrawAll(); }这里有一个容易被忽略的逻辑人机模式下黑棋是玩家、白棋是AI或者反过来玩家悔棋时必须把“自己这步”和“AI上一步”一起撤回否则撤回后立刻进入AI回合AI又会在同样的位置落子画面就像没悔棋一样。这个判断条件currentPlayer ! playerColor让代码在人机对战和双人对战之间正确切换。重开一局就简单了function resetGame() { board Array.from({ length: BOARD_SIZE }, () new Array(BOARD_SIZE).fill(0)); history []; currentPlayer 1; gameOver false; redrawAll(); }Array.from配合new Array每行都生成独立的新数组不能直接board new Array(BOARD_SIZE).fill(new Array(BOARD_SIZE))因为fill传入的是同一个数组引用到时候改一行、所有行一起变这个坑在初始化二维数组时特别经典我至今看到还会头皮发麻。3.3 光标跟踪、最后一步标记与获胜高亮细节决定质感这句话在游戏开发里尤其成立。一个只有棋盘和棋子的五子棋顶多算个作业加上下面这三个细节才算“游戏”。第一个细节是鼠标跟随提示。鼠标悬停在棋盘上时当前交叉点周围出现一个半透明的棋子轮廓让玩家提前知道自己会落在哪。实现思路是监听mousemove事件计算鼠标对应的行列记录在全局变量里然后由redrawAll()在绘制最后一步时把提示棋子画出来。为了不让这个提示干扰游戏性我在gameOver或非当前玩家回合时把它屏蔽。canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const x (e.clientX - rect.left) * (canvas.width / rect.width); const y (e.clientY - rect.top) * (canvas.height / rect.height); const col xToCol(x); const row yToRow(y); if (row 0 row 15 col 0 col 15 board[row][col] 0) { hoverPos { row, col }; } else { hoverPos null; } redrawAll(); });第二个细节是最后一步的标记。棋子落定后在刚下的那颗棋中心画一个小红点或小方框玩家扫一眼就知道局面是怎么推演过来的。这个信息量很大尤其在长对局里特别有用。if (history.length 0) { const last history[history.length - 1]; ctx.beginPath(); ctx.arc(colToX(last.col), rowToY(last.row), 4, 0, Math.PI * 2); ctx.fillStyle #FF4500; ctx.fill(); }第三个细节是获胜高亮。判定胜负以后把获胜的那五颗棋子用一条醒目的线连起来游戏结束的瞬间就给人一种“原来是这五颗”的恍然。实现时需要在checkWin里额外返回获胜棋子的坐标数组绘图阶段画一条加粗的彩色线穿过它们。这三个细节加在一起对这个游戏体验的提升远超你的想象。玩家会明显感受到“我下的每一步都有反馈”而不是像写作业一样冷冰冰。4. 常见问题排查与开发实录4.1 点击错位、棋子错格、线条模糊怎么办我在整个开发过程中整理了三类高频问题这里对照写出来。问题一点击落子位置整体偏移点第一根线落在第二根线上。这个几乎都是坐标转换没考虑Canvas缩放导致的。排查步骤很固定先在控制台打印clientX、rect.left和scaleX对比一下就能确认是CSS尺寸和Canvas内部尺寸不一致。解决办法就是4.2里提到的scale换算。问题二棋子画出来挤在棋盘角落或者跑到棋盘外。这是行列坐标和像素坐标搞反了。drawPiece(row, col)接收的参数顺序必须是行在前、列在后而在像素坐标里x colToX(col)、y rowToY(row)。如果你在绘制时传成了drawPiece(col, row)棋子就会沿着y轴对称翻转具体表现就是横竖方向颠倒。排查时直接在drawPiece入口打印row, col和x, y一眼就能看出来。问题三棋盘线条灰蒙蒙像隔了一层纱。这个就是2.1里提到的0.5像素偏移问题加偏移解决。顺便说一下如果你用的是高分屏可以把Canvas的实际尺寸乘以devicePixelRatio再通过CSS把显示尺寸设回逻辑大小这样线宽在Retina屏幕上会清晰很多。const dpr window.devicePixelRatio || 1; canvas.width canvasWidth * dpr; canvas.height canvasHeight * dpr; canvas.style.width canvasWidth px; canvas.style.height canvasHeight px; ctx.scale(dpr, dpr);这一段格式化代码是移动端适配里最值得加的小工具。4.2 性能优化与代码组织心得五子棋对局最多下225手Canvas本身性能瓶颈很小。但如果你把重绘机制写得太粗糙仍然可能出现明显卡顿特别是在移动端。最常见的性能杀手是每次mousemove都调用全量重绘。我实测过桌面端毫无压力但中低端Android手机上鼠标事件触发频繁到每秒十几次全量绘制画面就会有轻微掉帧。解决办法是把重绘拆成两级全量重绘redrawAll只在落子、悔棋、重开时调用。局部绘制drawPiece在鼠标移动时只更新提示棋子所在的4像素范围并不重新绘制整个棋盘。function redrawAll() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); drawBoard(); for (let r 0; r BOARD_SIZE; r) { for (let c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) { drawPiece(r, c, board[r][c] 1); } } } drawHoverPiece(); drawLastMoveMarker(); }代码组织上我建议至少拆成五个模块棋盘绘制、棋子绘制、游戏逻辑、AI逻辑、UI交互。我自己是在一个200多行的HTML文件里写完第一版后面扩展AI和悔棋功能时明显感觉到了可维护性的压力才拆分开来。4.3 扩展方向存档、联机与更强的AI等基础版本跑通之后有几个扩展方向非常推荐。第一个方向是本地存档。棋盘状态本质上就是二维数组加当前玩家直接JSON.stringify后存localStorage就能做到。注意存的时候把history也存上否则刷新后没法悔棋。function saveGame() { const saveData { board: board, history: history, currentPlayer: currentPlayer, gameMode: gameMode }; localStorage.setItem(gobangSave, JSON.stringify(saveData)); }第二个方向是双人同屏对战的体验优化。目前的界面可以支持两个玩家轮流点击但如果能看到实时比分、计时器、先手轮换这个概念就能从一个“小游戏”丰满成一个“对战小应用”。第三个方向是更强的AI。权值法能赢新手但对抗熟悉棋型的玩家会非常吃力。想提升AI水平可以往搜索树方向上探索比如只对权值最高的N个候选点做两层深度搜索用简单的极大极小评估函数。这个方向不是必须的但它能让你从“会写逻辑”进阶到“理解算法”收益极高。我个人的建议是第一版先把双人对战、悔棋、重开、人机都做完存档留到第二版联机放到最后。步子迈太大反而容易劝退。4.4 新手开发前必须知道的三件事写到最后分享三个我从这个项目里沉淀出来的经验。第一先搭好坐标系再写任何功能。五个函数colToX、rowToY、xToCol、yToRow、drawBoard一气呵成做好了后面所有功能都在这个坐标系里运转非常省心。如果坐标系混乱后面每写一个功能都得回来补坐标转换那个调试过程极其折磨。第二用数组驱动渲染。棋盘数组是唯一数据源每次重绘都从数组读取状态不要用“画了多少个棋子”这种外部状态来反推游戏状态。这样能保证UI和数据的同步悔棋时尤其方便。第三调试时善用控制台。我第二次写这个项目时遇到一个诡异的“点右下角落子在左上角”的问题怎么改都不对。后来在代码里打了三行console.log打印row、col、x、y瞬间定位到是列坐标和行坐标的换算翻了。控制台是程序员的眼睛别嫌它丑。如果只是练手这个项目能覆盖的知识点非常密集Canvas绘图、坐标系转换、二维数组操作、事件监听、状态管理、简单算法、数据结构栈。最妙的是它难度适中不会让你写到一半想放弃又能让你在完成后明显感觉到自己进步。我后来把联机对战的扩展也做了用WebSocket在服务端做一个房间匹配客户端这边基本只需要把“落子事件”广播出去棋盘渲染、胜负判定这些基础代码都完全复用。所以说别小看这个看似简单的五子棋它是一个可以养很久的万能项目。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑