☰
从零实现五子棋网页版:Canvas绘图与前端核心逻辑详解
2026/10/14 6:15:46 网站建设 项目流程

做 web 前端练手项目,我一直偏爱游戏类选题,而五子棋网页版小游戏在其中可以说是性价比最高的一类。它规则足够简单,却把 Canvas 绘图、数据建模、事件交互、状态管理、边界条件排查这些前端基本功全部覆盖了一遍。这篇文章就记录我从零实现一个五子棋网页版小游戏的完整过程——从方案选型到核心逻辑,从代码实现到踩坑修复,尽量把每一个“为什么这么做”都讲透。适合已经具备 HTML/CSS/JavaScript 基础、正在寻找第一个综合项目的读者,也欢迎写了几年页面但没系统写过游戏逻辑的朋友来查漏补缺。

1. 项目拆解:动手之前先把技术账算清楚

五子棋的规则不用多讲:15×15 的棋盘,黑先白后,谁先在横、竖、斜任意方向连成五子谁赢。但“规则简单”和“实现简单”是两回事。动工之前,我把整个项目拆成三个核心问题:怎么画棋盘和棋子、怎么存棋局状态、怎么判断胜负。这三个问题分别对应渲染方案、数据结构和算法,而选型的理由决定了后面所有代码长什么样。

1.1 渲染方案对比:Canvas 为什么是这里的最优选

棋盘和棋子有两种主流画法:一种是用 DOM 元素拼,15×15 的棋盘有 225 个交叉点,每个交叉点放一个 div,再叠加棋子节点;另一种是一整块 Canvas 全部画出来。

两种方案我都试过,结论很明确:这个项目用 Canvas 更划算。核心原因有三个:一是棋子绘制需要圆润的渐变效果和抗锯齿,Canvas 原生的 arc 配合径向渐变几行代码就能画出一个有立体感的棋子,DOM 方案只能用 CSS 圆角加阴影去模拟,效果和成本都不占优;二是 225 个交叉点虽然不算多,但每一手棋都要更新状态、高亮最后一手、绘制悬停预览,如果这些都用 DOM 增删类名和节点,代码会迅速变得繁琐;三是棋盘网格、星位、落子、悔棋重绘,本质上是“不断重绘画面”的场景,这正是 Canvas 的舒适区。

当然 DOM 方案也有它的场景:如果你需要每个棋子上独立绑定事件、做复杂的 CSS 动画,或者要适配读屏功能,那 DOM 更合适。但咱们做的是本地双人对战的休闲小游戏,Canvas 是更干净的选择。后面如果要加 AI 或者联机,Canvas 这套也不影响——渲染层和数据层本来就是分开的。

提示:渲染方案一旦定了,后面改动成本非常大。动手前可以花十分钟用两种方案各写一个小 demo 把棋盘画出来再对比。我的判断是,只要棋盘格子数超过 10×10,且交互包含频繁重绘,就无脑选 Canvas。

1.2 数据建模:棋盘在代码里是什么样

画面是画出来的,但游戏逻辑不能依赖画面。棋盘的状态必须存在一个独立的、与渲染无关的数据结构里。

最直接的做法是用二维数组:board[row][col] 的值为 0 表示空,1 表示黑棋,2 表示白棋。这个二维数组就是棋盘的“逻辑层”,Canvas 是“表现层”。每次落子先改数组,再触发重绘;每次判断胜负也只读数组,不碰画面。这样分层的好处是:以后你想把棋盘换成 DOM 渲染、换成 WebGL,甚至把逻辑搬到 Node.js 里跑,游戏核心逻辑都不用改。

这里有一个细节值得注意:数组下标和 Canvas 像素坐标之间的映射关系。第 row 行第 col 列的交叉点,在画布上的像素坐标是:

const x = MARGIN + col * CELL_WIDTH; // 列方向对应 x const y = MARGIN + row * CELL_WIDTH; // 行方向对应 y

反过来,鼠标点击位置要换算成棋盘坐标,就是:

const col = Math.round((mouseX - MARGIN) / CELL_WIDTH); const row = Math.round((mouseY - MARGIN) / CELL_WIDTH);

这套换算关系是整个交互设计的地基,后面所有功能——落子、悬停预览、悔棋高亮——都要从这里出发。所以我把坐标换算单独抽成了一个函数,而不是散落在各个事件处理里。

1.3 功能范围控制:先把主流程跑通再谈体验

做个人项目最容易犯的错是一开始就想做完美。五子棋看起来什么都可以加:倒计时、悔棋、AI 对手、音效、联机……如果全堆在第一版,你会发现自己代码还没写几行,先被功能清单压垮了。

我建议把功能分成三个迭代版本:

版本功能范围目标
V1本地双人轮流落子、胜负判定、提示信息核心闭环跑通
V2悔棋、重新开始、悬停预览、最后一手标识交互体验完整
V3音效、动画、移动端适配、AI 对手锦上添花

这篇文章主要讲 V1 加 V2 的实现思路,V3 里我挑移动端和音效这两个性价比高的展开聊,AI 对手放到最后补充方向。这样做的好处是,每一步都有一个可运行、可验证的版本,而不是憋一个大版本然后各种报错排不过来的状态。

2. 棋盘与棋子的绘制实现

画棋盘是视觉上要过的第一道关。棋盘不是最简单的一张图,它还承载着坐标信息,画法上有很多细节。

2.1 网格线与星位:基本功里见功夫

标准的五子棋棋盘是 15×15 的网格。绘制前先定义好基础常量:

const BOARD_SIZE = 15; const CANVAS_SIZE = 620; const MARGIN = 30; const CELL_WIDTH = (CANVAS_SIZE - 2 * MARGIN) / (BOARD_SIZE - 1); // 40

注意这里除的是 BOARD_SIZE - 1,因为 15 条线之间只有 14 个间隔。第一次写的时候特别容易写成除以 15,算出来的格子宽度就会偏小,棋盘边缘留白不对称。

我特意把 CANVAS_SIZE 取成 620,配合 30px 边距,算出来的格子宽度正好是 40px。整数格子让坐标换算和绘制都干干净净。如果你用 600 的画布,格子宽度会变成 38.57,画出来不是不能玩,但后续调试时小数位特别容易让人头大。

网格线的颜色不要用纯黑,接近木色的深棕会自然很多。背景用木纹色,这是一块实木棋盘最常见的配色。星位是棋盘上那五个小黑点,起定位作用,本质还是坐标映射以后画一个小圆:

function drawBoard() { ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); ctx.fillStyle = '#DEB887'; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); ctx.strokeStyle = '#5D4E37'; ctx.lineWidth = 1; for (let i = 0; i < BOARD_SIZE; i++) { const pos = MARGIN + i * CELL_WIDTH; ctx.beginPath(); ctx.moveTo(MARGIN, pos); ctx.lineTo(CANVAS_SIZE - MARGIN, pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, MARGIN); ctx.lineTo(pos, CANVAS_SIZE - MARGIN); ctx.stroke(); } const stars = [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]]; ctx.fillStyle = '#5D4E37'; stars.forEach(([row, col]) => { const x = MARGIN + col * CELL_WIDTH; const y = MARGIN + row * CELL_WIDTH; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); }); }

小技巧:如果 canvas 没做高分屏适配,且 lineWidth 为 1,横竖直线容易发虚。把每条线的起点终点坐标都做一次 +0.5 偏移,可以让线正好落在像素边界上,肉眼观感立刻锐利很多。做完后面要说的 DPR 适配后,物理像素多了,这个问题基本会被吸收,但保留这个画线习惯没有坏处。

2.2 棋子渲染:径向渐变做出立体感

棋子如果只画一个纯色圆,视觉上会非常死板。我用 Canvas 的径向渐变给棋子加高光,效果接近真实棋子的光泽感。

核心思路是:用一个圆心偏左上、颜色从亮到暗的渐变来填充圆。黑棋从深灰渐变成近黑,白棋从纯白渐变成浅灰。高光点不在圆心而是偏左上,因为真实光线通常从左上角来。半径取 CELL_WIDTH * 0.42,是让相邻棋子之间保留一点缝隙,避免两子半径之和等于单元格宽度导致“贴死”。

function drawPiece(row, col, player) { const x = MARGIN + col * CELL_WIDTH; const y = MARGIN + row * CELL_WIDTH; const radius = CELL_WIDTH * 0.42; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); const gradient = ctx.createRadialGradient( x - radius * 0.35, y - radius * 0.35, radius * 0.15, x, y, radius ); if (player === 1) { gradient.addColorStop(0, '#666'); gradient.addColorStop(0.6, '#333'); gradient.addColorStop(1, '#111'); } else { gradient.addColorStop(0, '#fff'); gradient.addColorStop(0.6, '#f0f0f0'); gradient.addColorStop(1, '#c8c8c8'); } ctx.fillStyle = gradient; ctx.fill(); ctx.lineWidth = 0.8; ctx.strokeStyle = player === 1 ? '#000' : '#b0b0b0'; ctx.stroke(); }

这套渐变参数是我调了几轮之后定下来的。第一版高光半径取太大,棋子像玻璃球;调小了以后才接近木棋子的哑光质感。你在自己调的时候不用迷信这个参数,关键点是高光偏移量别超过半径的三分之一,否则棋子会显得“脏”。

2.3 悬停预览与最后一手标识:体验感的关键细节

这两处不算功能,但对体验的提升非常明显。

悬停预览:鼠标移到棋盘上时,如果当前位置合法且为空,就在交叉点上画一个半透明棋子,提示“你将在这里落子”。实现方式是监听 mousemove,计算最近的交叉点坐标,如果和上一次悬停位置不同,就触发一次整盘重绘,再在新位置画预览:

function drawHoverPreview(row, col) { if (gameOver || board[row][col] !== 0) return; const x = MARGIN + col * CELL_WIDTH; const y = MARGIN + row * CELL_WIDTH; const radius = CELL_WIDTH * 0.42; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = currentPlayer === 1 ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.5)'; ctx.fill(); }

最后一手标识:每落一子,在最后一手棋子的中心画一个小红圈,方便双方快速定位当前局面焦点。这个功能看起来微不足道,但实际对局中非常实用。我补上之后,明显感觉下棋流畅度高了很多。画法就是在棋子上面叠加一个半径 4px 的小圆,颜色用带一点透明度的红,既醒目又不遮挡棋子的纹理。

3. 核心逻辑实现:落子、判定与状态管理

游戏逻辑是整个项目的灵魂。这一节把最关键的三块讲清楚:点击落子、胜负判定、悔棋与重开。

3.1 点击落子与坐标换算

鼠标在 Canvas 上点击时,需要把鼠标的页面坐标换算成 Canvas 的逻辑坐标,再换算成棋盘的行列坐标。第一步换算特别容易掉坑:直接用 e.clientX 当画布坐标是错的,因为页面一旦发生滚动,或者 Canvas 不在页面左上角,clientX 和画布内坐标就有偏差。

正确姿势是用 getBoundingClientRect():

canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); // rect.width 是 Canvas 在页面上显示的 CSS 宽度 // CANVAS_SIZE 是绘图时的逻辑宽度(620) const mouseX = (e.clientX - rect.left) * (CANVAS_SIZE / rect.width); const mouseY = (e.clientY - rect.top) * (CANVAS_SIZE / rect.height); const pos = getBoardIndex(mouseX, mouseY); if (pos) tryPlace(pos.row, pos.col); });

这里有一个很关键且反直觉的点:缩放比用的是 CANVAS_SIZE / rect.width,而不是 canvas.width / rect.width。因为 canvas.width 在做完高分屏适配之后会被设置成物理像素尺寸(比如 1240),用它换算得到的是物理坐标,和绘图的逻辑坐标系对不上。而 CANVAS_SIZE 是纯逻辑尺寸,用它换算出来的坐标才能直接和 MARGIN、CELL_WIDTH 这些参数一起工作。

第二步换算到行列坐标,得到结果后还要做两个有效性检查:坐标是否在边界内,以及该交叉点是否已有棋子:

function getBoardIndex(mouseX, mouseY) { const col = Math.round((mouseX - MARGIN) / CELL_WIDTH); const row = Math.round((mouseY - MARGIN) / CELL_WIDTH); if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return null; return { row, col }; }

Math.round 会让鼠标落在两个交叉点中间时就近吸附到更近的点。为了让吸附不过度灵敏,我再加了一个条件:如果鼠标距离最近交叉点的像素距离大于 CELL_WIDTH * 0.45,就忽略这次点击。这样即使点在棋盘边缘外一点点,也不会出现“离得老远却落上子”的反直觉现象。

3.2 四方向扫描:胜负判定算法

胜负判定是五子棋的核心算法。最容易想到的做法是每落一子后遍历整个棋盘,检查所有可能的五连位置,但更高效也更优雅的做法是只检查当前落子位置:以刚落下的这个棋子为中心,在水平、垂直、两条对角线四个方向分别向两边数,看每个方向上相同棋子的总数是否达到 5。

const DIRECTIONS = [ [0, 1], // 水平方向,列坐标变化 [1, 0], // 垂直方向,行坐标变化 [1, 1], // 主对角线 [1, -1] // 副对角线 ]; function checkWin(row, col, player) { for (const [dx, dy] of DIRECTIONS) { let count = 1; // 当前落下的这颗棋 // 向正方向数 for (let step = 1; step <= 4; step++) { const r = row + dx * step; const c = col + dy * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (board[r][c] !== player) break; count++; } // 向反方向数 for (let step = 1; step <= 4; step++) { const r = row - dx * step; const c = col - dy * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (board[r][c] !== player) break; count++; } if (count >= 5) return true; } return false; }

这个算法的收益是时间复杂度从 O(n²) 降到接近常数,而且不依赖每次落子后全盘扫描。两个方向的扫描循环上限都是 4,因为加上中心点本身最多数 5 个。

注意判定用 count >= 5 而不是 count === 5。因为五子棋存在“长连”的情况,虽然标准规则里黑棋长连算禁手,但休闲对局的玩家普遍认为超过五个连成一线同样该赢,直接用 >= 更符合直觉,也省去一堆禁手判定逻辑。

3.3 状态管理与悔棋、重开

游戏状态用一组简单变量管理就够了:board 二维数组、currentPlayer 当前玩家、gameOver 是否已结束、moveHistory 落子历史栈。

完整落子流程我封装成两个函数:tryPlace 负责入口校验,placePiece 负责业务动作。

function tryPlace(row, col) { if (gameOver) return; if (board[row][col] !== 0) return; placePiece(row, col); } function placePiece(row, col) { board[row][col] = currentPlayer; moveHistory.push({ row, col, player: currentPlayer }); drawPiece(row, col, currentPlayer); drawLastMoveMark(row, col); if (checkWin(row, col, currentPlayer)) { gameOver = true; showResult(currentPlayer); return; } currentPlayer = currentPlayer === 1 ? 2 : 1; updateStatus(); }

悔棋的实现依赖 moveHistory。因为每一步的坐标和玩家都存在栈里,悔棋就是弹出栈顶元素、把数组对应位置清零、把当前玩家换回上一步的玩家、最后整盘重绘:

function undo() { if (moveHistory.length === 0) return; const last = moveHistory.pop(); board[last.row][last.col] = 0; currentPlayer = last.player; gameOver = false; redrawAll(); updateStatus(); }

这里有一个常见的状态陷阱:游戏结束后悔棋按钮还能不能用?我的处理是能用,但每次悔棋必须同步把 gameOver 复位为 false,否则会出现“盘面已经悔棋但游戏还锁死不能落子”的诡异状态,这个坑我真实遇到过。

redrawAll 的实现是一个很有用的“整帧重绘”函数,后面所有需要刷新画面的场景都会调它:

function redrawAll() { drawBoard(); moveHistory.forEach((move) => drawPiece(move.row, move.col, move.player)); if (moveHistory.length > 0) { const last = moveHistory[moveHistory.length - 1]; drawLastMoveMark(last.row, last.col); } }

重开更简单:把 board 重新初始化为全 0,清空 moveHistory,currentPlayer 回到 1,再 redrawAll 一次,同时重置页面上的提示文字。

4. 实操全流程:从零到可运行

这一节把整个项目按步骤过一遍,从 HTML 骨架到 JS 完整逻辑。你可以照着写,也可以拿来对照自己的实现补漏。

4.1 HTML 骨架与页面布局

页面不需要花哨,一个小面板加一块棋盘就够:

<div class="container"> <h1>五子棋</h1> <div class="status"> <span id="turn">黑棋回合</span> <span id="count">第 1 手</span> </div> <canvas id="board" width="620" height="620"></canvas> <div class="controls"> <button id="undoBtn">悔棋</button> <button id="restartBtn">重新开始</button> </div> </div>

CSS 方面,重点是让 Canvas 在屏幕上显示干净利落。给 canvas 加一个轻微的盒阴影模拟立体感,按钮用中性配色就行。有一点值得单独处理:给 canvas 设置 user-select: none 和 -webkit-touch-callout: none,防止快速点击时出现文本选择或长按弹菜单的干扰。

移动端的展示宽度可以这样控制,让画布在小屏自适应缩放:

canvas { display: block; max-width: 92vw; height: auto; border-radius: 6px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); }

因为坐标换算用的是 CANVAS_SIZE / rect.width,画布被 CSS 缩放后点击依旧精准,不需要额外处理。

4.2 JavaScript 主逻辑:搭积木式组织

我把整套逻辑拆成几组函数,虽然不用模块化工程(单页小项目没必要上构建工具),但函数边界划清楚了,代码可读性和可维护性都会好很多。

整个 main.js 的组织结构是:

  1. 常量与全局状态(棋盘大小、画布尺寸、board 数组、当前玩家、历史栈)
  2. 渲染函数组(drawBoard、drawPiece、drawHoverPreview、drawLastMoveMark、redrawAll)
  3. 逻辑函数组(tryPlace、placePiece、undo、restart、checkWin)
  4. 事件绑定(click、mousemove、pointerdown、按钮事件)

初始化时把所有状态复位,然后整盘重绘:

function init() { board = Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(0)); currentPlayer = 1; gameOver = false; moveHistory = []; redrawAll(); updateStatus(); } init();

事件绑定部分只做两件事:监听事件、把事件转成逻辑调用。坐标换算放进了独立的 getBoardIndex,handleClick 内部先取坐标再调用 tryPlace,handleMouseMove 负责更新悬停位置并触发重绘:

canvas.addEventListener('click', handleClick); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseleave', () => { hoverPos = null; redrawAll(); }); undoBtn.addEventListener('click', undo); restartBtn.addEventListener('click', () => { init(); hideResult(); });

红框里有一个很容易忽略的细节:mouseleave 时要把 hoverPos 清空并且整盘重绘,否则鼠标移出棋盘后,半透明预览棋子会残留在最后一个位置。

4.3 视觉打磨:音效、动画与胜负展示

V3 版本里性价比最高的两件事是音效和胜利动画。这个小游戏如果一片死寂,玩起来会干巴巴的。

音效用 Web Audio API 动态生成,不需要加载任何外部音频文件。落子的声音本质上是短促的“嗒”,用振荡器加指数衰减就能做出来:

function playPlaceSound() { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); osc.frequency.value = currentPlayer === 1 ? 320 : 380; osc.type = 'sine'; gain.gain.setValueAtTime(0.4, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.08); osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + 0.08); }

这里有一个浏览器策略问题:AudioContext 必须在用户手势之后才能启动,好在咱们就是在点击落子的事件里播放,天然满足条件。不过每次点击都 new 一个 AudioContext 会有轻微开销,更好的做法是全局只创建一个实例,首次点击时初始化,之后复用。

胜利动画我用 Canvas 简单实现:判定胜负之后,在获胜棋子上画一圈半透明光晕,再用 requestAnimationFrame 做透明度渐隐。光晕不需要精确计算五个棋子的外接矩形,直接对最后一个确定胜利的落子位置画一个半径 12px 左右的圆环,视觉上就能起到强调效果。

胜负提示文案不要用 window.alert,alert 会阻塞页面,体验太原始。用一个浮层 div 展示“黑棋获胜”或“白棋获胜”,再加一个“再来一局”按钮,观感和交互都好得多。

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

写这个项目的过程中,我自己踩过不少坑,也帮几个朋友看过他们的实现。下面这些问题出现频率最高,每个都附了排查思路和解决方案,建议收藏对照。

5.1 高分屏下棋盘模糊

在 Retina 屏幕或高分屏手机上打开页面,你会看到棋盘线条发虚、棋子边缘出现锯齿。原因是 Canvas 的逻辑像素和物理像素不匹配:CSS 上 canvas 显示为 620px,但物理设备可能是 2 倍分辨率。如果不处理,浏览器只用 620×620 个物理像素去拉伸显示,自然会糊。

解决办法是让 Canvas 的绘图缓冲区大小等于物理像素尺寸,再用 scale 缩放坐标系:

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);

注意执行这段代码之后,所有绘制代码的单位还是逻辑像素(620),不用改。同时 click 事件里用 CANVAS_SIZE / rect.width 做坐标换算也不会受影响,因为逻辑坐标系保持一致。

这个坑很隐蔽,因为在小屏幕上画面模糊不仔细看根本注意不到,但一上高分屏笔记本,观感差距立刻拉开。我建议在 init 阶段就完成 DPR 适配,不要拖到后面。

5.2 点击坐标偏移

症状是:鼠标明明点到了第 7 行第 7 列,棋子却落在第 8 行,或者点棋盘边缘没反应。

排查路径有三个:

第一,看监听的是不是 Canvas 元素本身。如果监听的是 window 或外层 div,e.clientX 减去 rect.left 这一步就失去意义。第二,看页面是否有滚动。只要用了 rect.left / rect.top,滚动偏移就不需要单独处理,但如果直接拿 e.clientX 当画布坐标,页面一滚就废。第三,看是否有 CSS 缩放。给 canvas 设置了 transform: scale() 的话,点击坐标必须要除以缩放比,否则也会偏。

另外还有新手常犯的错误:canvas 的 width 属性与 CSS 尺寸不一致时,比如 HTML 写了 width 620,CSS 又设了 max-width: 100%,就必须用比例换算方案,否则点击精度会随窗口宽度变化。这就是我在 3.1 节写 CANVAS_SIZE / rect.width 的原因。

5.3 胜负判定的边界与误判

我见过最多的问题有这么几个:

一是数组越界。扫描方向时没有判断 row ± step 是否在 0 到 14 范围内,导致读取 undefined 报错。解决办法就是在循环体里加边界判断,遇到越界就 break。

二是“五子连珠”和“五子以上连珠”的判定口径不一致。有的实现用 count === 5 判断,遇到六子甚至七子长连就不识别,玩家会非常困惑。休闲玩法建议统一用 count >= 5。

三是悔棋后再判定出现脏数据。如果悔棋时只改了 board 数组而 moveHistory 里还残留旧数据,或者 gameOver 标志没复位,下一手落子后判断胜负就会基于错误局面。排查方法是打印 moveHistory.length 和 gameOver 的值,看是否同步更新。

四是行列写反。row 对应行、y 方向,col 对应列、x 方向。在 checkWin 里 dx 对应列变化、dy 对应行变化,把这两个写反的话,横竖方向会互换。测试时分别摆横排、竖排、两种斜线各一次五连,基本能排查干净。

5.4 移动端与触摸设备

手机上打开页面,最烦人的两个问题是:触摸落子没反应,以及屏幕滚动误触。

触摸没反应最常见的原因是只监听了 click。在移动端 click 事件存在明显延迟,很多 WebView 里尤其严重,体验上就是“点了没反应”。解决方式是监听 pointerdown 事件,它是把鼠标、手指、触控笔统一处理的事件,响应速度远快于 click:

canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const mouseX = (e.clientX - rect.left) * (CANVAS_SIZE / rect.width); const mouseY = (e.clientY - rect.top) * (CANVAS_SIZE / rect.height); const pos = getBoardIndex(mouseX, mouseY); if (pos) tryPlace(pos.row, pos.col); });

滚动误触的问题,可以在 CSS 里给 canvas 设置 touch-action: none,告诉浏览器不要在棋盘区域内处理手势滚动。这和 pointerdown 里调用 preventDefault 二选一即可,我建议优先用 CSS,因为 preventDefault 在某些场景下会连带影响点击事件的触发。

注意:设置 touch-action: none 后,用户在棋盘区域就不能用手指滚动了。如果棋盘本身就占满屏幕且不需要滚动,这是正确的;如果页面还有其它内容需要滚动,最好把棋盘区域控制在页面顶部的小范围内,不要把整个 body 都设掉。

5.5 一个隐蔽的重绘顺序问题

最后分享一个我调试最久的坑:悬停预览出现“残影”。

症状是:鼠标从棋盘上移开,半透明预览棋子没消失;或者预览位置更新后,原来的位置还残留一部分颜色。

原因几乎一定出在重绘逻辑上。如果你在 mousemove 里只把旧预览位置“填成棋盘底色”来擦除,而不是整盘重绘,那么当背景不是纯色、或者有棋子叠加、或者高分屏缩放存在时,擦除就不彻底,残影随即出现。

我的解决方案是“无脑整盘重绘”。在 mousemove 事件里,先判断棋盘坐标有没有变化,没变就直接 return;一旦变了就调用 redrawAll() 重画整个棋盘和全部已有棋子,再在新位置画预览。225 个交叉点的重绘在现在浏览器里也就是 1ms 以内的事,完全不需要为性能做局部擦除优化。这个思路其实可以推广到几乎所有 Canvas 2D 小游戏里:数据量小时,整帧重绘比精确局部更新更简单、更不容易出错。

6. 往后再玩点什么:扩展方向与个人体会

到这里,一个五脏俱全的五子棋网页版小游戏就算完工了。本地双人、悔棋、重开、音效、移动端适配都有,代码量不大但覆盖面已经很广。

如果你想继续往下玩,我推荐两个方向。一个是 AI 对手。五子棋的 AI 不复杂,一个 minimax 加 alpha-beta 剪枝就能写出陪你下棋的基础 AI,不需要引入第三方库。核心是给局面打分——每个空位和已有棋子形成的连子数量越多分越高——然后让 AI 走分最高的位置。做到这一步后再叠加一层深度搜索,AI 的战斗力会有明显提升。另一个是联机对战。本地对战不需要网络,但如果你想做成两个人各拿一台设备对战,就需要引入服务端做房间管理和消息转发。前端部分只需要把“落子”变成“发送消息”,把“对方落子”变成“接收消息并渲染”,现有的数据层和渲染层结构完全复用,不需要推倒重来。

我个人做完这个项目最深的一个体会是:把数据层和渲染层分开,是整个架构里收益最大的一笔投资。数据层就是 board 数组和 moveHistory 栈,它们不关心画面长什么样;渲染层只根据数据重绘。所以后来 AI 登场的时候,AI 完全不需要知道 Canvas 是什么,它只需要读写 board 数组;联机的时候,消息内容也只需要携带 row、col、player 三个字段。反过来,如果一开始就把棋盘状态散落在各种 DOM 操作里,那后面每加一个功能都要翻一遍渲染代码,改起来会非常痛苦。

做项目的意义从来不是“写一个五子棋”,而是通过一个完整的小闭环,把前端最核心的几种能力串起来练一遍。把这个项目啃下来之后,再去看那些更复杂的 Canvas 游戏,你会发现很多思路是相通的:状态先行、渲染跟随、事件驱动,仅此而已。

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

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

立即咨询