完整源码已整理,支持凹凸咬合、精准拖拽、自动吸附、分组合并、胜利判定
HarmonyOS 5.0+,基于 Canvas + MVVM 架构

一、需求背景

前几日玩一款游戏解锁新地图需要拼图成功才能开启,正好最近也要开始设计Canvas相关的课程内容,那就用这个拼图做案例。在鸿蒙平台实现体验真实、运行流畅、效果贴近物理拼图的项目,从基础矩形碎片逐步实现不规则凹凸边缘、精确点击、拖拽吸附、多碎片合并移动等完整功能。

开发过程中遇到不少问题例如凸起图像缺失、图像错位、空白区域误触、拖拽抖动、吸附逻辑偏差、胜利判定不灵敏等问题。本文完整分享实现思路、核心算法、代码细节及问题总结。

核心需求

  • 将完整图片切割为带有平直、外凸、内凹边缘的拼图碎片
  • 碎片边缘自动匹配咬合,保证拼接逻辑正确
  • 碎片支持自由拖拽,拖拽流畅无抖动,可置顶显示
  • 触摸区域严格匹配碎片形状,避免空白区域误触发
  • 碎片靠近正确位置自动吸附对齐
  • 碎片正确拼合后自动合并为一组,拖拽整组移动
  • 全部归位后弹出胜利提示,支持重置与打乱重玩

二、技术选型与架构

拼图属于大量不规则图形、高频刷新、复杂交互场景,必须使用 Canvas 绘制

为什么选择 Canvas 而不使用组件布局

最开始为了快速验证,使用了叠层布局 + Image,处理不规则凹凸点时,使用 clipShape 方法结合 PathShape 定义任意不规则路径裁剪图片相当麻烦。

// 蓝色背景
Image($r('app.media.background'))
    .width(100)
    .height(100)
    .clipShape(new PathShape({
      commands: 'M0 72 L105 72 Q150 0 195 72 L300 72 L300 300 L0 300 Z'
    }))
    .objectFit(ImageFit.Cover)

运行效果图

PathShape图形裁剪

  1. 组件单位默认 vpcommands 单位默认 px,处理这个增加了工作量,其次 commands 字符串需要大量的计算并转换成字符串。
  2. 组件的不断刷新与重建性能上很差。因此 Canvas 是拼图类应用最合理、最高效的方案。一张画布,刷新绘制即可。
模块 技术方案 作用
UI 绘制 Canvas + Path2D 绘制图形路径和图片
图片处理 @kit.ImageKit 获取图片信息以及像素
网络加载 @kit.NetworkKit 从网络加载图像,固定链接生成随机图片,可定制图像尺寸方便验证不同尺寸下碎片效果,第三方开源提供的链接
架构模式 MVVM(Model-View-ViewModel) 划分职责便于代码管理
精确点击 射线法 + 多边形顶点缓存 防止误触

分层职责

  • ModelPuzzlePiece 碎片数据模型,存储形状类型、坐标、图像、顶点缓存、路径缓存、分组ID
  • ViewModelPuzzleViewModel 核心业务层,负责碎片生成、图片裁剪、打乱布局、吸附算法、分组合并、胜利判定
  • ViewPieceComponent 拼图绘制组件与入口页面 FullScreenPuzzle,负责 Canvas 渲染、触摸事件分发、UI 交互

最终效果

拼图

三、实现过程

3.1 凹凸碎片生成算法

算法目标

使拼图边缘完美咬合,实现左右匹配、上下匹配,与真实拼图逻辑一致。

基础设定
  • 凸起高度 BUMP_HEIGHT = 24
  • 曲线起始比例 BUMP_START = 0.35
  • 曲线结束比例 BUMP_END = 0.65
  • 边缘类型:0 平直,1 外凸,2 内凹
  • 固定列数为4列,行数根据图片高度自动计算,最少3行
算法步骤
  1. 将原图按画布尺寸等比例缩放,保证任意图片正常显示不拉伸不变形。
  2. 计算单块碎片宽高,宽度为缩放后图片宽度除以4,高度与宽度一致。
  3. 计算拼图居中偏移坐标 baseXbaseY
  4. 按二维网格逐一生成碎片:
    • 最上方碎片,顶部为平直边缘。
    • 最左侧碎片,左侧为平直边缘。
    • 上方碎片下边缘为外凸,当前碎片上边缘为内凹。
    • 上方碎片下边缘为内凹,当前碎片上边缘为外凸。
    • 左侧碎片右边缘为外凸,当前碎片左边缘为内凹。
    • 左侧碎片右边缘为内凹,当前碎片左边缘为外凸。
    • 内部碎片右侧与下侧采用棋盘格规律交替凸凹。
    • 最右侧与最下方碎片,外侧边缘为平直。

每一步算法代码都很长,我只写关键部分,完整代码请在代码仓库下载运行(代码我也全部添加了注释),文档中会说明计算过程。

// 关键计算 生成规则循环遍历
const top = row === 0 ? 0 : (temp[row-1][col].bottom === 1 ? 2 : 1);
const left = col === 0 ? 0 : (temp[row][col-1].right === 1 ? 2 : 1);
const right = col === cols-1 ? 0 : (row + col) % 2 ? 2 : 1;
const bottom = row === rows-1 ? 0 : (row + col) % 2 ? 2 : 1;

3.2 图片裁剪扩展算法

算法目标

解决矩形裁剪导致凸起区域图像缺失、拉伸的问题。这里要注意尺寸的一致性:使用缩放后的尺寸,不要使用原尺寸,否则图像会错位(这个坑用了半小时才检查出来)。

拼图错位

算法原理

每个碎片的基础矩形为 pieceW × pieceH。对于外凸边(edge === 1),需要额外向外扩展 BUMP_HEIGHT 像素的图片区域,否则凸起部分将没有图像内容。扩展后的裁剪区域尺寸为:

  • 左凸:srcX 减少 bumpsrcW 增加 bump
  • 右凸:srcW 增加 bump
  • 上凸:srcY 减少 bumpsrcH 增加 bump
  • 下凸:srcH 增加 bump

边界限制公式:
srcX = max(0, srcX - bump)
srcW = min(displayW - srcX, srcW + bump)
保证不读取原图范围之外的数据。

算法步骤
  1. 按碎片尺寸计算基础裁剪区域,保持尺寸一致性。
  2. 根据碎片边缘凹凸方向扩展裁剪区域(不做拓展就会把凸出的那块空白了),所以绘制每一个碎片要判断是不是凸出边,是的话就需要增加高度。
  3. 增加边界限制,避免越界读取图片像素。
// bump 凸出的高度。
const bump = this.BUMP_HEIGHT;
// 根据凹凸方向扩展裁剪区域,确保凸起部分有图像内容
if (piece.left === 1) {
    srcX = Math.max(0, srcX - bump);
    srcW = Math.min(this.displayW - srcX, srcW + bump);
}
if (piece.right === 1) {
    srcW = Math.min(this.displayW - srcX, srcW + bump);
}
if (piece.top === 1) {
    srcY = Math.max(0, srcY - bump);
    srcH = Math.min(this.displayH - srcY, srcH + bump);
}
if (piece.bottom === 1) {
    srcH = Math.min(this.displayH - srcY, srcH + bump);
}
srcW = Math.floor(srcW);
srcH = Math.floor(srcH);
if (srcW <= 0 || srcH <= 0) continue;

const tile = ImageUtil.cropTileSync(fullScaledMap, srcX, srcY, srcW, srcH);
if (tile) piece.pixelMap = tile;
图片裁剪工具方法

这是完整的图片工具类,根据裁剪好的碎片坐标大小读取像素,然后创建一个空白 PixelMap 把数据写进去快速得到原始碎片。绘制最终的凹凸碎片就是在这个基础上裁剪,保证不缺失不拉伸而且效率高。

// 图片工具类:裁剪拼图碎片
export class ImageUtil {
  static cropTileSync(
    srcPixelMap: image.PixelMap,
    x: number,
    y: number,
    width: number,
    height: number
  ): image.PixelMap | null {
    try {
      const stride = width * 4;
      const buffer = new ArrayBuffer(width * 4 * height);

      srcPixelMap.readPixelsSync({
        pixels: buffer,
        offset: 0,
        stride: stride,
        region: { x, y, size: { width, height } }
      });

      const newPixelMap = image.createPixelMapSync({
        editable: true,
        pixelFormat: image.PixelMapFormat.RGBA_8888,
        size: { width, height }
      });

      newPixelMap.writePixelsSync({
        pixels: buffer,
        offset: 0,
        stride: stride,
        region: { x: 0, y: 0, size: { width, height } }
      });

      return newPixelMap;
    } catch (err) {
      console.error('裁剪失败', err);
      return null;
    }
  }
}

3.3 凹凸形状绘制

使用 Path2D 构建带二次贝塞尔曲线的凹凸闭合路径,缓存路径避免重复创建,这样裁剪的时候根据每个碎片路径就可以直接裁剪。

二次贝塞尔曲线公式

二次贝塞尔曲线由三个点定义:起点 P 0 P_0 P0、控制点 P 1 P_1 P1、终点 P 2 P_2 P2。参数方程:

B ( t ) = ( 1 − t ) 2 P 0 + 2 ( 1 − t ) t P 1 + t 2 P 2 , t ∈ [ 0 , 1 ] B(t) = (1-t)^2 P_0 + 2(1-t)t P_1 + t^2 P_2, \quad t \in [0,1] B(t)=(1t)2P0+2(1t)tP1+t2P2,t[0,1]

在代码中,我们使用 quadraticCurveTo(cpx, cpy, x, y) 绘制,其中 (cpx, cpy) 是控制点 P 1 P_1 P1(x, y) 是终点 P 2 P_2 P2

  • 外凸:控制点的 y 坐标小于起终点(向上凸),例如 Q(w*0.5, -b, w*e, 0)
  • 内凹:控制点的 y 坐标大于起终点(向下凹),例如 Q(w*0.5, b, w*e, 0)
/**
 * 构建单个碎片的凹凸形状路径(Path2D)
 * 根据碎片的上下左右边缘类型绘制带贝塞尔曲线的闭合路径
 * @param piece 碎片对象
 * @returns Path2D 路径对象
 */
private buildPiecePath(piece: PuzzlePiece): Path2D {
  const b = this.BUMP_HEIGHT;
  const s = this.BUMP_START;
  const e = this.BUMP_END;
  const w = piece.w;
  const h = piece.h;
  const path = new Path2D();
  path.moveTo(0, 0);

  if (piece.top === 1) {
    path.lineTo(w * s, 0);
    path.quadraticCurveTo(w * 0.5, -b, w * e, 0);
  } else if (piece.top === 2) {
    path.lineTo(w * s, 0);
    path.quadraticCurveTo(w * 0.5, b, w * e, 0);
  }
  path.lineTo(w, 0);
  // 右边、下边、左边 同理...
  path.closePath();
  return path;
}

绘制时根据凹凸方向调整图像偏移,最终得到想要的碎片。

let dx = 0, dy = 0, dw = w, dh = h;
if (p.left === 1)   { dx -= b; dw += b; }
if (p.right === 1)  { dw += b; }
if (p.top === 1)    { dy -= b; dh += b; }
if (p.bottom === 1) { dh += b; }
this.ctx.drawImage(p.pixelMap, dx, dy, dw, dh);

3.4 精确点击检测(射线法)

当我们绘制完碎片,看似已经完工,但是点击每一个碎片拖动要跟随手指移动,如果我们点击空白处例如碎片凸起部分旁边空白,碎片是会跟随移动的。所以就要检查点击的点是不是在图形上。

算法原理

射线法(Ray Casting Algorithm)用于判断一个点是否在多边形内部。核心思想:从待检测点向右发射一条水平射线,统计射线与多边形边的交点个数。若交点个数为奇数,点在多边形内部;偶数则在外部。

数学基础:给定一条线段由端点 ( x 1 , y 1 ) (x_1, y_1) (x1,y1) ( x 2 , y 2 ) (x_2, y_2) (x2,y2) 定义,射线为水平直线 y = p y y = p_y y=py,交点横坐标公式为:

x = x 1 + ( x 2 − x 1 ) ( p y − y 1 ) y 2 − y 1 x = x_1 + \frac{(x_2 - x_1)(p_y - y_1)}{y_2 - y_1} x=x1+y2y1(x2x1)(pyy1)

由直线两点式方程推导:

x − x 1 x 2 − x 1 = y − y 1 y 2 − y 1 \frac{x - x_1}{x_2 - x_1} = \frac{y - y_1}{y_2 - y_1} x2x1xx1=y2y1yy1

有效相交条件

  1. 边的两个端点位于射线两侧: ( y 1 > p y ) ≠ ( y 2 > p y ) (y_1 > p_y) \neq (y_2 > p_y) (y1>py)=(y2>py),避免顶点重复计数。
  2. 交点横坐标满足: x > p x x > p_x x>px
【几何示意图】

      y
      ^
      |
 y2   |        B(x₂,y₂)
      |        /
      |       /
 py   |------C(px,py)------> 水平射线
      |     /  交点
 y1   |    A(x₁,y₁)
      +----------------------> x
          x₁      x₀        x₂

【比例原理】
高度比例 = (py - y₁) / (y₂ - y₁)
x 偏移 = (x₂ - x₁) × 高度比例
交点 px = x₁ + x 偏移

最终公式:
px = x₁ + (x₂ - x₁)(py - y₁)/(y₂ - y₁)
代码实现
/**
 * 射线法判断点是否在多边形内
 * @param px 局部 X 坐标
 * @param py 局部 Y 坐标
 * @param vertices 多边形顶点数组(顺序需与路径一致)
 * @returns 是否在多边形内部
 */
private isPointInPolygon(px: number, py: number, vertices: PieceVertices[]): boolean {
  let inside = false;
  for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
    const xi = vertices[i].x;
    const yi = vertices[i].y;
    const xj = vertices[j].x;
    const yj = vertices[j].y;
    const intersect = ((yi > py) !== (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
    if (intersect) inside = !inside;
  }
  return inside;
}

3.5 点击、拖拽、吸附与分组

触摸流程
  • 按下:遍历顶层碎片,确定点击目标,记录偏移与碎片起始位置,执行置顶让碎片放在最上边不遮挡这里要注意碎片合并后要把一个组的碎片都置顶否则就会造成碎片之间穿插。
  • 移动:计算总偏移量,更新碎片位置,保持跟随手指触摸移动流畅。
  • 抬起:执行吸附逻辑,判断是否完成拼图,重置拖拽状态。
吸附算法

吸附的核心是找到当前碎片(或所在组)与目标碎片的最佳匹配位置,并整体移动。距离度量使用欧几里得距离:两点之间线段最短勾股定理。

d i s t = ( t a r g e t X − p . x ) 2 + ( t a r g e t Y − p . y ) 2 dist = \sqrt{(targetX - p.x)^2 + (targetY - p.y)^2} dist=(targetXp.x)2+(targetYp.y)2

其中 t a r g e t X targetX targetX t a r g e t Y targetY targetY 是根据目标碎片当前位置和正确相对偏移计算出的理想位置。

  /**
   * 吸附当前碎片到邻近的正确位置,并合并组、整体移动同组碎片
   * 支持大块与大块之间的合并(检查组内所有碎片与目标碎片的相邻性)
   * @param current 当前拖拽的碎片
   * @param range 吸附阈值(像素)
   */
  snap(current: PuzzlePiece, range = 14) {
    const currentGroup = this.getGroupPieces(current);
    let bestTarget: PuzzlePiece | null = null;
    let bestAdjacent: PuzzlePiece | null = null; // currentGroup 中与 bestTarget 相邻的碎片
    let minDist = range;

    // 遍历所有其他碎片
    for (const t of this.pieces) {
      if (t.groupId === current.groupId) continue;
      // 检查 currentGroup 中是否有碎片与 t 在正确位置上相邻
      for (const p of currentGroup) {
        if (!this.isAdjacentInCorrectPosition(p, t)) continue;
        // 计算 p 应该移动到的目标位置(基于 t 的当前位置 + 正确相对偏移)
        const targetX = t.x + (p.correctX - t.correctX);
        const targetY = t.y + (p.correctY - t.correctY);
        const dist = Math.hypot(targetX - p.x, targetY - p.y);
        if (dist < minDist) {
          minDist = dist;
          bestTarget = t;
          bestAdjacent = p;
        }
      }
    }

    if (bestTarget && bestAdjacent) {
      // 计算整个 currentGroup 需要移动的位移
      const targetX = bestTarget.x + (bestAdjacent.correctX - bestTarget.correctX);
      const targetY = bestTarget.y + (bestAdjacent.correctY - bestTarget.correctY);
      const deltaX = targetX - bestAdjacent.x;
      const deltaY = targetY - bestAdjacent.y;
      // 整体移动 currentGroup
      for (const p of currentGroup) {
        p.x += deltaX;
        p.y += deltaY;
      }
      // 合并两个组
      this.mergeGroups(current, bestTarget);
    }
  }
相邻判断

相邻判断基于正确位置网格索引,避免浮点误差。通过计算碎片在网格中的行列索引,检查行差为1且列相同,或列差为1且行相同代表相邻。也就是上下相邻 或者左右相邻。

 /**
   * 判断两个碎片在正确位置上是否相邻(上下左右,不包括对角)
   * 使用网格索引计算,避免浮点精度问题
   * @param a 碎片A
   * @param b 碎片B
   * @returns 是否相邻
   */
  private isAdjacentInCorrectPosition(a: PuzzlePiece, b: PuzzlePiece): boolean {
    // 根据正确位置计算网格索引
    const colA = Math.round((a.correctX - this.baseX) / this.pieceW);
    const rowA = Math.round((a.correctY - this.baseY) / this.pieceH);
    const colB = Math.round((b.correctX - this.baseX) / this.pieceW);
    const rowB = Math.round((b.correctY - this.baseY) / this.pieceH);
    const rowDiff = Math.abs(rowA - rowB);
    const colDiff = Math.abs(colA - colB);
    return (rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1);
  }
分组合并

分组通过 groupId 实现。合并时,将源组所有碎片的 groupId 改为目标组 ID,实现逻辑合并。拖拽时通过 getGroupPieces 获取整个组,整体移动。

  /**
   * 合并两个碎片所在的组(将 pieceB 的组全部合并到 pieceA 的组)
   * @param pieceA 目标组
   * @param pieceB 源组
   */
  private mergeGroups(pieceA: PuzzlePiece, pieceB: PuzzlePiece) {
    const groupA = pieceA.groupId;
    const groupB = pieceB.groupId;
    if (groupA === groupB) return;
    for (const p of this.pieces) {
      if (p.groupId === groupB) {
        p.groupId = groupA;
      }
    }
  }

3.6 胜利判定

采用相对位置判定:所有碎片的当前坐标与正确坐标的偏移量( d x , d y dx, dy dx,dy)相对于第一个碎片的偏移量一致,即整体平移。允许 ±2 像素误差。

判定成功后自动将所有碎片归位到绝对正确位置,提升视觉效果。

isWin(): boolean {
  const base = this.pieces[0];
  const offsetX = base.x - base.correctX;
  const offsetY = base.y - base.correctY;
  for (const p of this.pieces) {
    const dx = p.x - p.correctX;
    const dy = p.y - p.correctY;
    if (Math.abs(dx - offsetX) > 2 || Math.abs(dy - offsetY) > 2) return false;
  }
  // 胜利后自动归位到绝对正确位置
  for (const p of this.pieces) {
    p.x = p.correctX;
    p.y = p.correctY;
  }
  return true;
}

四、核心问题与解决方案

以下是我踩到的坑与解决方案:

问题 原因 解决方案
凸起边缘图像缺失/拉伸 裁剪未包含凸出区域 按凹凸方向扩展裁剪
点击空白可拖动碎片 矩形碰撞检测 缓存顶点+射线法判断
碎片拖拽抖动 增量偏移累积 记录起始点使用总偏移
碎片错误吸附 未限制正确相邻 网格索引判断上下左右
胜利判定不灵敏 绝对坐标判断 相对偏移一致性判断

五、完整代码结构

entry/src/main/ets/
├── pages/
│   └── Index.ets
├── components/
│   └── PuzzleComponent.ets
├── model/
│   └── PuzzlePiece.ets
├── viewmodel/
│   └── PuzzleViewModel.ets
└── utils/
    └── ImageUtil.ets

六、运行效果

  • 初始状态:图片完整显示,碎片归位
  • 打乱后:碎片随机分布
  • 拖拽操作:支持单块与整组拖动,自动置顶
  • 吸附对齐:靠近正确位置自动匹配并合并分组
  • 完成拼图:弹出胜利提示,锁定拖拽操作
  • 重置功能:碎片恢复初始位置

七、总结

本文完整实现生产级鸿蒙拼图游戏,覆盖不规则图形生成、图片处理、Canvas 绘制、精确点击、拖拽吸附、分组逻辑与胜利判定全流程。

八、代码仓库

  • 工程名称:PuzzleDemo
  • 点击下载:PuzzleDemo

九、结语

本文从实际开发角度完整记录拼图游戏实现思路与细节,代码可直接运行使用。欢迎交流开发问题,共同进步。如果这份内容对你有帮助请收藏点赞。我会经常分享一些基础、实用开发相关内容。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐