HarmonyOS七巧板拼图:ArkTS+Canvas实现拖拽旋转与命中检测

发布时间:2026/9/15 3:51:22
HarmonyOS七巧板拼图:ArkTS+Canvas实现拖拽旋转与命中检测 HarmonyOS应用实例247七巧板拼图名字听着像小时候的玩具实际在开发里是一道相当综合的题。它绕开了复杂业务逻辑却把 ArkTS 状态管理、Canvas 绘制、手势事件、坐标系变换、多边形命中检测这堆基本功全串了一遍。我推荐正在学 HarmonyOS 应用开发的朋友拿它当进阶练手项目比照着写一堆无意义列表页管用得多已经写过几个 Demo 想补多媒体和交互短板的也能从这篇里找到可以直接抄走的思路。这类拼图类 App 最大的坑不是画图而是“拖得动、转得动、判得准”。代码量不大但每一步都在考你对坐标系和事件流的理解。下面我把当时从建模、渲染到交互的完整实现过程拆开讲清楚最后附上我踩过的几个问题。1. 七巧板游戏的需求拆解与整体设计1.1 七巧板玩法与功能需求七巧板由 7 个标准几何块组成2 个大的等腰直角三角形、1 个中等等腰直角三角形、2 个小等腰直角三角形、1 个正方形、1 个平行四边形。玩家要做的事情是把散落在画布上的这 7 块板通过拖拽和旋转重新拼成目标图形通常是正方形也可以扩展成小狗、房子等轮廓。开发版本我不打算做关卡先聚焦核心闭环显示 7 个不同颜色、不同形状的板块每个板块支持单指拖拽移动每个板块支持旋转角度可以做成按钮步进也可以直接双指手势旋转板块靠近目标位置时自动吸附判断是否完成拼图提供重置和重新开始按钮。这 5 条看起来简单实际落地时每一条都会牵扯到坐标系换算和状态刷新所以先把需求定义清楚很重要不然后面写代码容易东补一块西补一块。1.2 技术方案选型ArkUI声明式框架 Canvas 手势系统HarmonyOS 应用开发主力是 ArkUI 声明式框架语言使用 ArkTS。这个项目我没有选择用一堆 Image 组件加载七巧板图片而是直接用 Canvas 绘制多边形。原因有三个第一七巧板需要自由旋转。用位图实现旋转要么损失清晰度要么得管理多张旋转角度的资源而 Canvas 对多边形做 rotate 是原生的角度任意绘制质量无损。第二拼图游戏在拖拽过程中需要实时重绘Canvas 在这种场景下比组件树更高效。如果每个块都是一个 Image 或自定义组件拖拽时高频刷新组件位置渲染压力和状态同步成本都会明显增加用一个 Canvas 统一绘制所有块只需要维护一个数组的状态重绘其实就是重新调用一次绘制逻辑。第三Canvas 能顺手画出淡色参考轮廓、网格线、阴影等辅助效果界面表现力更自由。交互层我用的是 onTouch 触摸事件。PanGesture、RotationGesture 在 ArkUI 里也很好用但我最终选择 onTouch 统一处理原因是七巧板操作是“先命中某一块再拖动”我需要自己控制命中的优先级。onTouch 可以拿到原始触摸点结合坐标换算做命中检测逻辑更透明也方便后续扩展双指旋转。1.3 整体代码结构与状态模型项目结构上我按单一页面实现页面内分成几个清晰的模块数据模型定义板块形状、位置、旋转角度的接口初始数据7 个板块的本地坐标顶点数组、基准点、初始摆放位置绘制逻辑根据状态数组把 7 个板块画到 Canvas 上触摸逻辑命中检测、拖拽更新坐标、旋转更新角度判定逻辑与目标位置做差值比较决定是否吸附和完成。状态模型是整个项目的核心。我用一个自定义类PuzzleBlock来保存每个板块的状态然后在页面中用State装饰一个板块数组。为什么用数组而不是 7 个独立变量因为绘制和遍历命中检测都需要循环处理数组是天然适配的结构后续扩展更多关卡也方便。2. 核心细节解析七块板的数学建模与渲染2.1 七巧板的基本几何关系七巧板不是随便画几个三角形就行的。要让 7 块板能真的拼成一个正方形所有边长必须满足固定比例关系。我以一个小三角形直角边长度为基准单位 a推导整组尺寸小三角形等腰直角直角边为 a正方形边长为 a中三角形等腰直角直角边为 a×√2大三角形等腰直角直角边为 2a平行四边形两组对边分别为 a 和 a×√2。验证一下面积也能对上。假设 a100正方形目标边长就是 200√2≈282.8。小三角形面积是 100×100/25000两个小三角是 10000正方形面积 10000中三角面积约 10000两个大三角面积各 20000共 40000平行四边形面积 10000。加起来正好 80000等于目标正方形面积 282.8×282.8。这个比例关系是拼图能拼成功的基础后面定义顶点数据时只要按这个比例来就不会出问题。2.2 用顶点数组描述图形在 ArkTS 里我定义了一个 Point 接口所有图形用顶点坐标数组表示。这里我用的是“本地坐标”也就是图形在没有平移、没有旋转时相对于自身基准点的坐标。基准点我特意选择几何中心附近而不是某个顶点。这样旋转时是绕图形中心转操作手感最自然。每个图形定义如下单位 vp我取 a100大三角1三个顶点 (0,0)、(200,0)、(0,200)基准点 (100,100)大三角2顶点 (0,0)、(200,0)、(200,200)? 不对这里应该补准确。我写代码时其实是把 7 个块都定义成以中心为原点的坐标这样旋转起来很直观。比如大三角形中心在 (100,100)顶点相对中心的偏移是 (-100,-100)、(100,-100)、(-100,100)绘制时再用基准点做平移。实际顶点数组示例interface Point { x: number; y: number; } interface PuzzleBlock { id: number; name: string; color: string; shape: Point[]; pivotX: number; pivotY: number; posX: number; posY: number; rotation: number; targetX: number; targetY: number; targetRotation: number; }以“小三角1”为例本地坐标以中心为原点直角边 100{ id: 4, name: 小三角形1, color: #E6A23C, shape: [ { x: -50, y: -50 }, { x: 50, y: -50 }, { x: -50, y: 50 } ], pivotX: 0, pivotY: 0, posX: 120, posY: 260, rotation: 0, targetX: 290, targetY: 290, targetRotation: 45 }把基准点统一放在中心后面做旋转命中检测会省非常多事。如果基准点在某个顶点旋转时图形会“甩”出去玩家操作起来很别扭。2.3 Canvas绘制与坐标系变换HarmonyOS 的 Canvas 组件用法和 Web Canvas 类似。我在页面里放一个 Canvas绑定一个CanvasRenderingContext2D对象绘制入口放在onReady回调里。绘制单个板块的完整逻辑private drawBlock(context: CanvasRenderingContext2D, block: PuzzleBlock) { context.save(); context.translate(block.posX, block.posY); context.rotate(block.rotation * Math.PI / 180); // 绘制阴影 context.shadowColor rgba(0, 0, 0, 0.3); context.shadowBlur 8; context.shadowOffsetX 2; context.shadowOffsetY 2; context.beginPath(); const points block.shape; context.moveTo(points[0].x, points[0].y); for (let i 1; i points.length; i) { context.lineTo(points[i].x, points[i].y); } context.closePath(); context.fillStyle block.color; context.fill(); context.shadowColor rgba(0, 0, 0, 0); context.strokeStyle #FFFFFF; context.lineWidth 3; context.stroke(); context.restore(); }关键点在第 2 行到第 4 行。先translate到板块当前位置再rotate旋转当前角度。此时绘制的本地坐标就是以中心为原点的坐标绘制结果会自动落在正确位置。save和restore必须成对使用否则前一个板块的旋转会累积到下一个板块上画面很快就会乱掉。清屏重绘我用的是固定背景色重新填充整个画布context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); context.fillStyle #F7F8FA; context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);每次拖拽移动一个像素都会触发一次完整重绘。7 个板块加背景开销非常小完全没必要做局部脏矩形优化。如果以后板块数量多到几十个再考虑用离屏 Canvas 缓存每个板块的静态内容。2.4 触摸命中检测射线法拼图首先要解决“点中了哪一块”。我在onTouch里拿到的是画布上的全局坐标但每个板块可能已经旋转过直接用全局坐标和本地 shape 比较会产生很大误差。正确的做法是把触摸点逆变换回板块本地坐标再用点与多边形的包含关系判断。逆变换分两步先减去板块的中心点 posX/posY再反向旋转当前角度。private toLocalPoint(p: Point, block: PuzzleBlock): Point { const dx0 p.x - block.posX; const dy0 p.y - block.posY; const rad -block.rotation * Math.PI / 180; return { x: dx0 * Math.cos(rad) - dy0 * Math.sin(rad), y: dx0 * Math.sin(rad) dy0 * Math.cos(rad) }; }拿到本地坐标后用射线法判断点是否在多边形内部。射线法的原理是从目标点向右侧发一条水平射线统计它穿过多边形边界的次数奇数次在内部偶数次在外部。这个算法对凹多边形也适用是通用解法。private pointInPolygon(pt: Point, polygon: Point[]): boolean { let inside false; for (let i 0, j polygon.length - 1; i polygon.length; j i) { const xi polygon[i].x; const yi polygon[i].y; const xj polygon[j].x; const yj polygon[j].y; const intersect ((yi pt.y) ! (yj pt.y)) (pt.x (xj - xi) * (pt.y - yi) / (yj - yi) xi); if (intersect) { inside !inside; } } return inside; }命中检测时为了操作体验更友好我从顶层板块开始遍历命中后立刻返回不再判断下层。这样多个板块重叠时用户能拖到最上面的那个不会出现“穿透选到下面”的诡异情况。3. 实操过程详解从建工程到完整拼图3.1 创建工程与页面骨架我用 DevEco Studio 新建了一个 Empty Ability 工程选择 Stage 模型兼容 SDK 版本选 API 9 以上。工程建好后主要文件就一个Index.ets代码全部写在这个页面里。页面骨架采用 Stack 布局底层是 Canvas 画布上层放一个半透明的操作提示和按钮区域。Stack 的好处是子组件天然叠放不需要手动计算绝对位置。Entry Component struct Index { private settings: RenderingContextSettings new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings); State blocks: PuzzleBlock[] []; private canvasWidth: number 0; private canvasHeight: number 0; private selectedId: number -1; private touchedX: number 0; private touchedY: number 0; build() { Stack({ alignContent: Alignment.Top }) { Canvas(this.ctx) .width(100%) .height(100%) .onReady(() { this.initGame(); }) .onTouch((event: TouchEvent) { this.handleTouch(event); }) Row() { Button(重置) .onClick(() { this.resetBlocks(); }) Blank() Text(将七块板拼成正方形) .fontSize(14) .fontColor(#FFFFFF) Blank() Button(旋转) .onClick(() { this.rotateSelected(); }) } .width(100%) .padding(16) .backgroundColor(rgba(0, 0, 0, 0.3)) } .width(100%) .height(100%) .backgroundColor(#F7F8FA) } }3.2 定义数据模型与初始数据初始化时7 个板块的初始位置我故意打散在画布四周避免互相遮挡太多。目标变换则对应一个完整的正方形布局每个板块最终拼到固定位置。以两个大三角形为例目标变换中的 targetRotation 都是 0两个三角形对称摆放正好拼成正方形的左边和上边中三角、正方形、平行四边形和小三角形分别补到右侧和底部。具体坐标在初始化时根据画布尺寸动态计算保证目标正方形居中。private initBlocks() { const unit 100; const targetSize 200 * Math.sqrt(2); const originX (this.canvasWidth - targetSize) / 2; const originY (this.canvasHeight - targetSize) / 2; this.blocks [ { id: 0, name: 大三角形1, color: #F56C6C, shape: [ { x: -unit, y: -unit }, { x: unit, y: -unit }, { x: -unit, y: unit } ], pivotX: 0, pivotY: 0, posX: this.canvasWidth * 0.2, posY: this.canvasHeight * 0.3, rotation: 0, targetX: originX targetSize / 4, targetY: originY targetSize / 4, targetRotation: 45 }, // 其余6个板块类似定义... ]; }我建议初始化时用随机偏移而不是固定写死一个坐标这样每次打开页面散落位置不同游戏变化性更强。但随机偏移要控制范围别把板块甩到画布外面。可以这样处理posX: originX (Math.random() - 0.3) * this.canvasWidth * 0.6, posY: originY (Math.random() - 0.3) * this.canvasHeight * 0.6,3.3 绘制背景与全部图形onReady回调里第一件事是拿到 Canvas 实际尺寸。我通过组件的onAreaChange事件监控画布大小变化动态更新 canvasWidth 和 canvasHeight避免不同机型适配问题。然后调用drawAll逻辑很直接先清屏再画参考轮廓最后循环绘制每个板块。private drawAll() { const ctx this.ctx; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); ctx.fillStyle #F7F8FA; ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight); this.drawTargetOutline(ctx); for (const block of this.blocks) { this.drawBlock(ctx, block); } }参考轮廓我是用半透明灰色画了一个正方形提示玩家目标区域在哪。这个轮廓只在初始化时画一次不需要参与重绘。不过要保证它不干扰玩家视线透明度要低一点。3.4 实现拖拽与旋转拖拽逻辑放在handleTouch里。事件类型有三种Down、Move、Up。Down 的时候遍历板块把触摸点变换到每个板块的本地坐标后做命中检测命中的板块记录 id同时记录手指相对板块中心的偏移量。这里有个细节如果不记录偏移手指按下瞬间板块会“跳”到手指位置。记录偏移后Move 阶段设置 posX 和 posY 时要加上这个偏移。private handleTouch(event: TouchEvent) { const touch event.touches[0]; if (!touch) { return; } const x touch.x; const y touch.y; if (event.type TouchType.Down) { this.selectedId this.hitTest(x, y); if (this.selectedId 0) { const block this.blocks[this.selectedId]; this.touchedX x; this.touchedY y; } } else if (event.type TouchType.Move) { if (this.selectedId 0) { const block this.blocks[this.selectedId]; const dx x - this.touchedX; const dy y - this.touchedY; block.posX dx; block.posY dy; this.touchedX x; this.touchedY y; this.autoSnap(block); this.drawAll(); } } else if (event.type TouchType.Up) { this.selectedId -1; } }旋转我提供了两种方式按钮步进和双指手势。按钮步进每次旋转 45 度七巧板所有块的边都平行或 45 度斜交45 度步进已经足够完成拼图。双指手势更适合高级玩家需要监听两个触摸点计算两指连线角度变化量。第一个版本先做按钮步进逻辑简单也稳定。3.5 目标吸附与完成判定吸附是拼图游戏提升手感的标配。如果要求玩家手拼到精确像素体验非常差。我的做法是在 Move 过程中检测当前板块与目标变换的差值当位移差小于 15vp、角度差小于 5 度时直接把板块置为目标值让玩家感觉“咔哒”一下吸进去了。private autoSnap(block: PuzzleBlock) { const dx Math.abs(block.posX - block.targetX); const dy Math.abs(block.posY - block.targetY); let rotationDiff Math.abs(block.rotation - block.targetRotation); if (rotationDiff 180) { rotationDiff 360 - rotationDiff; } if (dx 15 dy 15 rotationDiff 5) { block.posX block.targetX; block.posY block.targetY; block.rotation block.targetRotation; this.checkComplete(); } }完成判定在每次吸附成功后触发遍历所有板块检查是否都已经吸附到位。如果全部到位弹出一个轻提示并锁定所有板块的拖拽。3.6 重置与新游戏重置按钮要恢复全部板块到初始散落位置和初始角度。实现方式很简单重新调用initBlocks()再drawAll()。这里要注意先释放选中状态否则重置后 selectedId 还指向旧板块下次触摸会误操作。新游戏则是在原有逻辑上随机换目标图形。扩展时只需要提供不同目标的 targetX、targetY、targetRotation 数组比如拼成三角形、小房子等核心逻辑完全复用。4. 常见问题与排查技巧实录4.1 Canvas 画出来是空白怎么办这个问题我遇到太多次了。常见原因有两个第一个是 Canvas 的 onReady 还没触发就调用绘制此时画布宽高还是 0第二个是绘制时用了不存在的颜色值或者 context 没正确初始化。解决办法绘制入口统一放在 onReady 和 onAreaChange 回调里且绘图前先打印 canvasWidth 和 canvasHeight 确认尺寸。另外RenderingContextSettings构造参数传 true 是为了开启抗锯齿别漏掉。4.2 拖动时图形跑到别的地方典型的坐标偏移问题。Move 事件里如果没有记录手指按下时相对板块中心的偏移板块就会立刻跳到手指下方。正确做法是 Down 时保存touchedX、touchedYMove 时用x - touchedX作为增量累加到 posX而不是直接让 posX 等于 x。另一个隐蔽问题如果要让 Canvas 内容随手指平滑移动务必在每次 Move 后调用drawAll()重绘否则画面不更新。4.3 旋转后点不中图形这是新手最容易卡住的问题。绘制时先用 translate 再 rotate触摸判断却直接用原始 shape 坐标肯定不匹配。解决思路在 2.4 节已经讲清楚了把触摸点逆变换回本地坐标。核心是逆旋转公式const rad -rotation * Math.PI / 180; localX (globalX - posX) * Math.cos(rad) - (globalY - posY) * Math.sin(rad); localY (globalX - posX) * Math.sin(rad) (globalY - posY) * Math.cos(rad);注意 ArkUI 的 Canvas 坐标系 y 轴向下所以 rotate 正方向是顺时针逆变换时角度取负即可。4.4 拼好判断不准确完成判断用“所有板块都吸附到位”是最简单的方式但前提是目标变换定义准确。如果你发现玩家明显拼好了却判不出来先检查目标坐标和初始图形是否匹配特别是旋转角度。七巧板中很多块可以旋转 90 度仍然保持形状外轮廓一致判定时最好做角度差归一化处理把差值限制在 0 到 180 度范围内。4.5 高分屏与不同机型适配开发时不要写死像素。HarmonyOS 提供了 vp 单位它是一种与密度无关的像素在不同手机上显示物理尺寸基本一致。我的做法是初始化时按画布宽高比例计算目标正方形边长和所有初始位置保证任意分辨率下目标图形都居中显示。另外 Canvas 的实际像素分辨率是物理像素绘制时如果直接用 vp 计算的坐标在高分屏上会显得模糊。可以在 onReady 里获取 Canvas 像素宽度和 vp 宽度计算缩放比再通过ctx.scale()应用到绘制上下文。4.6 性能与重绘优化7 个简单多边形的重绘开销可以忽略但有一些小习惯值得养成。比如 shadow 相关属性比较耗性能绘制过程中频繁开关 shadow 会产生额外计算。我在大板块绘制时保留阴影小板块或者拖拽特别频繁时临时关闭阴影。如果以后板块数量增多或者背景有复杂纹理可以把每个板块预先绘制到离屏 Canvas 上拖拽时直接 drawImage性能会更好。4.7 问题速查表现象可能原因解决方案画布空白onReady 未触发或尺寸为 0在 onReady/onAreaChange 中初始化图形跳到手指位置未记录按下时的偏移Down 时保存 touchedX/touchedY旋转后无法命中触摸点未做逆变换先减 posX/posY 再反向旋转拼好不判定角度差未归一化差值超过 180 度时用 360 减去图形模糊未处理物理像素与 vp 比使用 ctx.scale 设置适配比例拖拽时事件穿透页面可滚动或组件冲突在触摸回调中调用 event.stopPropagation5. 从进阶角度看这个实例还能怎么扩展做完了基础版七巧板这个项目就成了一个很好的“功能试验田”。我个人比较推荐沿着这三个方向继续加东西。第一个方向是加入关卡和目标图形系统。目前只有一个正方形目标实际上七巧板能拼出的图案非常多。把 targetX、targetY、targetRotation 抽成配置数据每关加载不同配置再配一张预览小图游戏内容量立刻丰富起来。这个扩展不改动任何绘制和交互逻辑纯粹是数据驱动。第二个方向是引入动画。吸附过程可以做一个 200 毫秒的位移动画而不是瞬间跳到位。ArkUI 的 animateTo 可以配合属性动画但 Canvas 内的绘制动画需要自己用 requestAnimationFrame 或者定时器控制。实测下来用系统动画做 Canvas 元素的位置补间并不是最顺的自己写一个简单的线性插值反而更可控。第三个方向是记录步数和计时。在 Move 里累计拖拽次数在开始游戏时启动计时器完成时显示成绩。这个功能做出来后七巧板就从“会动的 Demo”变成了真正能拿去给朋友比赛的成品应用。我在实际开发中最大的体会是这种看起来很小的益智类应用反而能把你对 UI 框架的理解逼到一个新高度。你没法靠现成组件糊弄过去所有交互都得自己用底层 API 组装。做完这个七巧板再去做自定义绘图组件、做小游戏、做画板工具思路都会清晰很多。如果你也在写类似的多边形交互应用遇到坐标变换和命中检测卡壳按这篇文章的思路走一遍至少能少踩一半的坑。