前几天群里聊起 HarmonyOS 应用实例做到第 247 个,有人问能不能写一个不依赖任何第三方组件的交互小游戏。我第一反应就是七巧板。这个项目看起来只是“七个图形拖来拖去”,实际动手做会发现里面塞满了 ArkUI 开发的基本功:Path 矢量绘制、手势拖拽、旋转对齐、状态同步、碰撞吸附、计分存储,全是平时做业务页不太会认真琢磨、但一遇到就头疼的东西。
这篇文章我把完整的实现思路、关键代码、踩坑过程都写出来。内容包括七块图板的几何数据怎么定义、PanGesture 拖拽为什么要把起点坐标先存快照、旋转 90 度之后吸附判定怎么处理角度误差、@State 修改数组项不刷新界面怎么破。适合刚学完 ArkTS 基础、想练一个完整小项目的开发者,也适合已经在做 HarmonyOS 应用、想了解手势与图形交互细节的朋友。你不用下载任何图形库,全程只用 ArkUI 自带组件就能跑。
1. 项目概述与整体设计思路
1.1 七巧板游戏的玩法与交互边界
七巧板的核心玩法很简单:把打乱的几个形状拖到目标区域,旋转到正确的角度,全部放好就算过关。但这个“简单”落到代码里,至少要回答三个问题:形状怎么画、怎么拖、怎么判断放对了。
先说形状。七巧板一共七块,五块三角形、一块正方形、一块平行四边形。如果直接用图片素材,开发确实快,但图片资源要适配不同屏幕密度,还要处理放大缩小的模糊问题。我选择用 ArkUI 的 Path 组件画矢量图形,路径数据是字符串,不占资源,缩放到任何尺寸都清晰。
再说拖拽。ArkUI 里 PanGesture 提供拖动手势,关键点在于拖动过程中要用“手势开始时的坐标 + offsetX / offsetY”来实时更新组件位置,而不是直接把组件当前位置当起点累加,否则每次触摸都会有跳变。
最后是判定。每块图形在目标位置上有一个期望的中心点和旋转角度,当图形实际中心与目标中心距离小于阈值、角度也对齐后,就把图形吸附过去并锁定,不再响应拖拽。等七块全部锁定,游戏结束,统计步数和用时。
1.2 为什么用 Path + Position 而不是 Canvas 或 Image
我第一次做类似项目时想过两条路:一是用 Canvas 统一绘制所有图形,二是在 Canvas 上用 Image 加载贴图。
Canvas 方案的问题在于,所有命中检测、拖拽逻辑都要自己手动实现,得维护每个图形的坐标变换矩阵,工作量翻倍。Image 方案的问题是七巧板每个块的颜色、描边、尺寸都不同,准备一套适合多种屏幕的图片素材挺麻烦。而 ArkUI 的 Path 组件天然支持矢量路径、fill 填充和 stroke 描边,再配合 position 定位和 rotate 旋转属性,完全可以实现每个图形独立渲染、独立响应手势,代码量少很多。
还有一个很现实的原因:Path 的路径数据是字符串,可以很方便地放在配置数组里。后面想新增关卡、更换目标图形,只要改配置,不用重写界面逻辑。
1.3 整体模块划分与数据流设计
我把项目拆成四个部分:图形数据层、状态管理、交互逻辑、界面渲染。
- 图形数据层:七块图形的 PathData、宽高、初始位置、目标位置、目标旋转角。
- 状态管理:用一个 BlockState 数组保存每一块的状态,比如当前中心点、旋转角、是否锁定。
- 交互逻辑:PanGesture 更新中心点,吸附函数判断是否需要锁定,计时器和步数在这里维护。
- 界面渲染:用 Stack 做游戏板,ForEach 循环渲染每一块 Path,底部放旋转和重置按钮。
这个结构的核心思想是数据驱动界面:所有坐标变化都反映到状态数组里,界面只负责根据数组内容画出最终位置。这样吸附、动画、成绩统计之间不会互相打架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境与基础工程搭建
2.1 DevEco Studio 与 SDK 版本选择
项目使用 DevEco Studio 5.0 以上版本,SDK 选择 API 12 起。API 12 对 ArkUI 手势和 Path 组件的支持比较稳定,而且开发工具里 Previewer 可以直接模拟触摸事件,省去每次都要连真机的麻烦。
如果你用的是 HarmonyOS 5.0 或更新版本的开发环境,直接在工程创建时选择 Empty Ability 模板就可以。纯 ArkTS 工程,不需要额外申请权限。这个项目不涉及网络请求,也不需要位置、相机之类敏感权限,所以在 module.json5 里保持默认即可。
2.2 新建工程的目录结构
创建完工程后,我这里实际用到的文件结构如下。
text复制entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets
├── model/
│ └── BlockState.ets
├── pages/
│ └── Index.ets
└── common/
└── TangramData.ets
BlockState.ets 里定义图形状态类,TangramData.ets 里放七块图形的 PathData 和位置配置,Index.ets 是主页面。把数据单独拆出去是因为后面调坐标的时候不用翻主页面代码,改完立刻能在预览器里看到效果。
2.3 游戏页面主框架
主页面的整体布局用 Stack 做游戏板,尺寸我设成 600vp × 600vp。游戏板上面放一个半透明目标轮廓,便于玩家知道要拼成什么;目标轮廓之上再渲染七块可拖拽图形。底部放一行按钮:旋转、打乱、重新开始。
界面代码框架如下。
typescript复制@Entry
@Component
struct Index {
@State blocks: BlockState[] = [];
@State selectedIndex: number = -1;
@State steps: number = 0;
@State seconds: number = 0;
build() {
Column() {
Row() {
Text('步数:' + this.steps).fontSize(18)
Blank()
Text('时间:' + this.seconds + 's').fontSize(18)
}
.width('90%')
.margin({ top: 16 })
Stack() {
// 目标轮廓
ForEach(this.targetShapes, (shape: TargetShape) => {
Path()
.commands(shape.pathData)
.width(shape.width)
.height(shape.height)
.fill('#33000000')
.position(shape.position)
}, (shape: TargetShape) => shape.id.toString())
// 可拖拽七巧板
ForEach(this.blocks, (block: BlockState) => {
Path()
.commands(block.pathData)
.width(block.width)
.height(block.height)
.fill(block.color)
.stroke(Color.Black)
.strokeWidth(2)
.position({
x: block.centerX - block.width / 2,
y: block.centerY - block.height / 2
})
.rotate({
angle: block.rotation,
centerX: '50%',
centerY: '50%'
})
.opacity(this.selectedIndex === block.id ? 0.85 : 1)
.gesture(this.buildDragGesture(block))
.gesture(
TapGesture()
.onAction(() => {
this.selectedIndex = block.id;
})
)
}, (block: BlockState) => block.id.toString())
}
.width(600)
.height(600)
.backgroundColor('#E8E4D9')
.borderRadius(16)
.clip(true)
Row() {
Button('旋转90°')
.onClick(() => this.rotateSelected())
Button('打乱')
.onClick(() => this.shuffleBlocks())
Button('重新开始')
.onClick(() => this.resetGame())
}
.margin({ top: 16 })
.justifyContent(FlexAlign.Center)
}
}
}
有朋友可能会问,Path 组件能不能直接设置坐标,不用 position?可以,但是 Path 有自己内部的路径坐标系,直接用 path 属性里写绝对坐标会让后续旋转中心不好控制。我用 position 把组件左上角放到“中心点减去宽高一半”的位置,旋转中心才是图形的几何中心。
3. 七块图板的几何模型与绘制
3.1 七巧板的数学结构与坐标定义
标准七巧板里存在严格的边长比例:小三角形直角边为 1,中三角形直角边为根号 2,大三角形直角边为 2,正方形边长为根号 2,平行四边形长边为 2、短边为根号 2。
我在代码里为了减少浮点数,把小三角形直角边定为 100,那么中三角形和正方形的边长为 141.42,大三角形直角边为 200。所有浮点数保留两位小数,Path 组件完全能处理。
但有一个细节很关键:每块图形的路径坐标,我并不是从左上角 (0,0) 开始画,而是把坐标中心平移到图形本身的几何中心。举个例子,大三角形是一个直角边为 200 的等腰直角三角形,如果直角顶点在左下角,那么三个顶点是 (0,0)、(200,0)、(0,200),几何中心是 (66.67, 66.67),并不在组件中心。直接用 rotate 旋转时,旋转中心默认是组件中心,也就是 (100,100),图形会绕着错误的位置转。
解决方法是把几何中心平移到 (0,0),坐标变成 (-66.67,-66.67)、(133.33,-66.67)、(-66.67,133.33)。这样组件的宽高都是 200,旋转中心正好在图形中心。七块数据我都按这个规则处理。
七块图形的 PathData 和参数如下。
| 块 | PathData(几何中心为原点) | 宽 | 高 | 颜色 |
|---|---|---|---|---|
| 大三角1 | M -66.67,-66.67 L 133.33,-66.67 L -66.67,133.33 Z | 200 | 200 | #F2A03D |
| 大三角2 | M -66.67,-66.67 L 133.33,-66.67 L 133.33,133.33 Z | 200 | 200 | #E86A5A |
| 中三角 | M -70.71,-70.71 L 70.71,-70.71 L -70.71,70.71 Z | 141.42 | 141.42 | #67B26F |
| 小三角1 | M -50,-50 L 50,-50 L -50,50 Z | 100 | 100 | #4A90D9 |
| 小三角2 | M -50,-50 L 50,-50 L 50,50 Z | 100 | 100 | #8E6BCA |
| 正方形 | M -70.71,-70.71 L 70.71,-70.71 L 70.71,70.71 L -70.71,70.71 Z | 141.42 | 141.42 | #F7C948 |
| 平行四边形 | M -150,-50 L 50,-50 L 150,50 L -50,50 Z | 300 | 100 | #5AB8D3 |
这里特别说一下平行四边形。它的路径用四个顶点定义,整体宽度是 300,高度是 100,几何中心正好在原点,旋转后斜边方向会变化,玩法上比三角形更有意思。
3.2 Path 组件渲染原理与避坑点
Path 组件的 commands 属性接收的是 SVG 风格的路径字符串,基本指令有 M 移动到某个点、L 画到某个点、Z 闭合当前路径。我们的七巧板全是直线段,没有任何曲线,所以只需要 M、L、Z 三个指令。
一个常见的坑是 fill 不生效。Path 组件如果没有明确调用 fill 设置颜色,默认可能是透明的。路径字符串结尾一定要有 Z,否则图形不闭合,fill 效果会很奇怪。
另一个坑是浮点数精度。我在调试中发现,直接用 141.421356 这种长小数传给 Path,预览器有时候会把组件宽度算得过大,导致吸附判定出现几像素偏移。所以我在配置里统一保留两位小数,吸附半径稍微放宽到 36vp,实测下来稳定很多。
3.3 用 ForEach 渲染七块图形
状态数组创建时,我规定每块图形的初始位置和目标位置一致,这样一进游戏就已经拼好了。然后点击“打乱”按钮,程序会把每块随机偏移到距目标位置 200 到 300vp 的范围内,并随机给一个旋转角度。
使用 ForEach 渲染 Path 时,key 一定要用 block.id 而不是 index。因为拖动过程中块位置变化会导致 ForEach 重排,如果用 index 做 key,触发界面刷新时可能出现同一块图形被复用、闪烁的问题。
4. 拖拽、旋转与吸附判定的关键实现
4.1 PanGesture 拖拽里的坐标快照技巧
拖拽最朴素的想法是:每次 onActionUpdate 里拿到 event.offsetX,然后让组件坐标加上这段增量。但 event.offsetX 表示的是手势从一开始到现在累计移动了多少,不是本次回调相对上一次回调移动了多少。所以如果你想用“当前坐标加增量”的方式,一定要在 onActionStart 里保存当时的 centerX 和 centerY。
对应代码如下。
typescript复制private buildDragGesture(block: BlockState): PanGesture {
return new PanGesture({ fingers: 1, direction: PanDirection.All })
.onActionStart(() => {
if (block.locked) {
return;
}
this.dragStartX = block.centerX;
this.dragStartY = block.centerY;
})
.onActionUpdate((event: GestureEvent) => {
if (block.locked) {
return;
}
block.centerX = this.dragStartX + event.offsetX;
block.centerY = this.dragStartY + event.offsetY;
this.refreshBlock(block);
})
.onActionEnd(() => {
if (block.locked) {
return;
}
this.steps++;
this.trySnap(block);
});
}
为什么一定要快照?因为 block.centerX 在 onActionUpdate 里已经被改过了,如果下次事件再用当前 centerX 加上 offsetY 的增量,等于把历史偏移重复计算,图形会越拖越飘。
4.2 旋转按钮与角度对齐
拖动过程中也可以旋转,但如果在 PanGesture 上叠加双击旋转手势,处理起来比较麻烦。我实测的一个方案是:在 Path 组件上同时绑定 PanGesture 和 TapGesture,TapGesture 用于选中图形,旋转操作放到界面底部按钮。
旋转按钮的逻辑很简单:把当前选中块的 rotation 加 90,超过 360 后取模回到 0。因为七巧板所有图形旋转 90 度后,外观基本不变,吸附判定只关心与目标角度是否相差 0 或 180 度,所以这里的角度可以只用 0、90、180、270 四个值。
typescript复制private rotateSelected(): void {
if (this.selectedIndex < 0) {
return;
}
const block = this.blocks[this.selectedIndex];
if (block.locked) {
return;
}
block.rotation = (block.rotation + 90) % 360;
this.refreshBlock(block);
}
有朋友想加双击旋转也是可以的。做法是用 GestureGroup 把 TapGesture 的 count 设为 2 和 PanGesture 组合在一起,但要注意双击旋转和拖拽可能互相抢手势,建议在 GestureGroup 里设置 GestureMode.Parallel,再在代码里判断 event.offsetX 很小的时候才执行旋转。
4.3 吸附判定:中心距离与角度误差
吸附判定的思路是:拖动结束或旋转结束后,找出当前块对应的目标配置,计算实际中心点与目标中心点的距离。如果距离小于吸附半径,就把实际中心点直接改成目标中心点,并尝试对齐角度。
目标配置我放在一个数组里,每个块一个 target,包含 targetCenterX、targetCenterY、targetRotation。
typescript复制private trySnap(block: BlockState): void {
const target = this.targets[block.id];
if (!target) {
return;
}
const dx = block.centerX - target.centerX;
const dy = block.centerY - target.centerY;
if (Math.sqrt(dx * dx + dy * dy) > this.snapRadius) {
return;
}
block.centerX = target.centerX;
block.centerY = target.centerY;
block.rotation = this.alignRotation(block.rotation, target.rotation);
block.locked = true;
this.refreshBlock(block);
this.checkWin();
}
private alignRotation(current: number, target: number): number {
const diff = Math.abs(current - target) % 180;
if (diff <= 5 || diff >= 175) {
return target;
}
return current;
}
这里用 % 180 是因为七巧板图形旋转 180 度后形状完全一样,正方形和矩形更是 90 度就能对齐。如果直接比较当前角度和目标角度,用户旋转到 90、而目标是 270,就会判定失败。取模 180 能避免这种无意义的失败。
吸附半径我设置为 36vp。太大会出现明明还差一段距离就“啪”地被吸过去,太小则用户要对得很准才能放上。36 是从我实际体验里调出来的比较舒适的值。
4.4 完成检测、计步与计时
完成检测放在吸附成功之后。每次有一个块锁定了,就遍历 blocks 数组检查是否全部 locked。如果全部锁定,停止计时器,记录最佳成绩。
计步逻辑需要细想一下。如果每次都让步数加一,用户拖一块拖到一半放回原位也算一步,体验上还行,但会导致玩家为了调整位置疯狂拖动刷步数。我把计步放在 onActionEnd 里,并且只有在图形没有被锁定、且现在坐标与拖动前坐标距离大于 10vp 时才加一步。这样能过滤掉“点了一下又没动”的无效操作。
计时器用 setInterval 实现,游戏开始点击打乱后才启动。这里要注意 ArkTS 对 setInterval 的类型定义比较严格,返回类型是 number,放在组件的成员变量里,页面销毁时记得 clearInterval,否则页面退出后计时器还在跑,会报泄漏警告。
typescript复制private startTimer(): void {
if (this.timerId !== 0) {
return;
}
this.timerId = setInterval(() => {
this.seconds++;
}, 1000);
}
private stopTimer(): void {
if (this.timerId !== 0) {
clearInterval(this.timerId);
this.timerId = 0;
}
}
4.5 目标轮廓的绘制与提示
为了让玩家知道要拼成什么,我在游戏板里画了一个半透明的目标轮廓。这个轮廓数据来自 targets 配置,每个目标用一块 Path 展示,颜色统一用 #33000000,叠加起来就是一个淡淡的影子。
这里有一个提升观感的技巧:目标轮廓不要渲染在地板最底层,而是渲染在可拖拽图形下方一层。这样当玩家把图形拖到正确位置附近时,彩色图形会盖在灰色轮廓上面,视觉反馈很明显。
5. 常见问题与排查技巧实录
5.1 Path 坐标中心偏移,旋转后图形跳来跳去
这是我最早遇到、也最容易踩的坑。如果 PathData 里图形没有围绕几何中心设计,组件旋转中心默认是组件左上角到右下角的中心,三角形这种非对称形状就会围绕一个奇怪的点旋转。
排查方法很简单:在预览器里把每一块单独显示,旋转 90 度看看图形是否还在视野中央。如果发现图形往角落跑,说明坐标系中心不对。我一直建议把所有 PathData 都按“几何中心在原点”的方式定义,这也是我前面 7 块数据要手工平移坐标的原因。
5.2 @State 修改数组项内容,界面不刷新
这个坑几乎每个 ArkTS 开发都会遇到。直接写 this.blocks[i].centerX = newX,控制台里数据变了,但界面纹丝不动。原因是 @State 装饰的数组只能被识别为整个数组变化,数组里某个对象的属性变化并不会触发 UI 更新。
解决办法有两种。第一种是把 BlockState 类用 @Observed 装饰,组件里使用 @ObjectLink 接收子对象。但 @ObjectLink 只在子组件里用,我们这里是在同一个页面里直接操作数组,不适合。
第二种是整体替换数组项,我封装了一个 refreshBlock 方法。
typescript复制private refreshBlock(block: BlockState): void {
const index = this.blocks.findIndex(item => item.id === block.id);
if (index === -1) {
return;
}
this.blocks.splice(index, 1, block);
}
splice 会把数组第 index 项替换成新的对象引用,@State 能感知到数组变化,从而触发界面刷新。
5.3 PanGesture 和 TapGesture 互相抢事件
同时给 Path 组件挂拖拽手势和点击手势,默认情况下会有一个手势互斥的问题:手指按下后,如果稍微移动一点,TapGesture 就应该识别失败;但有时候移动了 20vp,TapGesture 还是被触发,选中块的状态不停闪烁。
我的解决办法是:TapGesture 里不立刻改变选中状态,而是加一个很小的延迟判断。或者更简单一点,旋转操作不通过点击图形触发,而是通过底部按钮,这样 TapGesture 只用来选中,即使和拖拽偶有冲突,也不会影响游戏主流程。
如果你一定要双击旋转,可以看 PanGesture.onActionStart 里判断 Math.abs(event.offsetX) + Math.abs(event.offsetY) < 10 再执行旋转,把位移判断留给 PanGesture。
5.4 吸附后图形还能被拖走
吸附成功的标志是 locked = true。我最初只在 onActionUpdate 里判断了 lock,忘了 onActionStart 也要判断,结果图形吸附后,手指再碰到它还是会启动拖拽,一拖就飞出去了。
正确的处理是在 PanGesture 的三个回调里都加上 if (block.locked) return。另外建议锁定后把图形透明度稍微提高,让玩家一眼看出这块已经拼好。
5.5 调试辅助:把中心点画出来
图形吸附判定依赖中心距离,但开发时很难直观看到中心点在哪。我加了一个调试开关,打开后会在每块图形中心画一个 6vp 半径的红色小圆点,目标中心画一个半透明蓝点。把这些小圆点铺开,就能很清楚地看到误差从哪里来。
typescript复制if (this.debugShowCenter) {
Circle({ width: 6, height: 6 })
.fill(Color.Red)
.position({ x: block.centerX - 3, y: block.centerY - 3 })
}
这个调试功能在正式版里去掉即可,但在调坐标阶段非常好用,比对着数字猜位置高效太多。
6. 扩展玩法与数据保存建议
6.1 把目标图形改成关卡配置
目前 targets 数组是在代码里写死的。想扩展更多关卡,可以把 targetShapes 和 targets 都改为从 JSON 配置读取。每个关卡包含七块图形的目标中心点、目标旋转角度、目标轮廓 PathData、背景色。
我实际做的时候,数据文件里每一项都长这样:
typescript复制interface LevelConfig {
id: number;
name: string;
targetCenters: Position[];
targetRotations: number[];
targetShapes: TargetShape[];
}
新关卡要做的工作只剩两件:画 7 个目标轮廓 PathData,以及给每块定义一个合适的中心点。这两件事都可以在预览器里调,不用改任何交互代码。
6.2 用 Preferences 保存最佳成绩
关卡做完之后,自然要把最佳成绩存下来。HarmonyOS 的本地存储用 Preferences 比较简单。在 onPageShow 里读取,在游戏完成时写入。
typescript复制import dataPreferences from '@ohos.data.preferences';
const store = dataPreferences.getPreferencesSync(getContext(), { name: 'tangram_store' });
const best = store.getSync('best_seconds', 0) as number;
store.putSync('best_seconds', this.seconds);
store.flush();
注意 getPreferencesSync 需要传入一个 Context,在页面组件里用 getContext() 取到的就是当前页面的上下文。flush 是异步落盘,一般情况不用等结果,但如果用户马上杀进程,建议在页面隐藏回调里再 flush 一次。
6.3 后续可以继续加的方向
这个项目做完还能往好几个方向扩展。音效方面,Path 拖到位时播放一声短的提示音,体验会好很多;动画方面,吸附成功时加一个 RotateTransition 或者 scale 弹跳,能明显提升手感;玩法方面,可以从“固定槽位拼接”升级成“边缘吸附拼接”,不再限制目标位置,玩法自由度更高。
我个人更推荐做边缘吸附拼接,它才是真正接近物理拼图的体验。实现思路是给每条边预计算法向量,当两块图形的边距离小于阈值且角度接近时,按边的方向把两块图板拼到一起。这个方向代码量会多不少,但写完之后几乎可以做出任何形状的拼图游戏。
写在最后
我在做这个项目的过程中最深的体会是:看似最简单的拖拽和吸附,细节里全是坑。Path 坐标系如果要带偏移,旋转一定乱;@State 数组项不整个替换,界面一定不刷新;吸附角度不做取模,玩家旋转 180 度后永远拼不上。这些坑单独看都不难,合在一起就会让新手卡上好几天。
最后分享一个小技巧:所有数值参数,从吸附半径到目标中心点,都尽量抽成独立常量。我一开始图省事直接在 trySnap 里写死了 30,后来想改成 36,不得不全局搜索替换。抽成常量以后,调手感只需要改一行配置,整个项目都会好维护很多。这个七巧板项目做完之后,你对 ArkUI 手势和状态管理的理解会比单纯看文档强得多。
