ArkTS + Canvas + Preferences:拆解鸿蒙冒险大转盘的本地状态设计
做一个本地小游戏,界面通常不是最难的。真正容易乱的是:随机结果由谁决定,动画播放完后怎样保证结果没变,哪些状态只属于这一局,哪些数据需要跨设备带走。
华为云开发者社区的鸿蒙实践案例“冒险大转盘”,用一个纯本地元服务把这些问题串了起来。它没有服务器,也不依赖游戏账号;用户先确认本局任务,再轮流转动转盘,已完成任务退出当前候选,自定义任务通过 Preferences 保存,也可以用 JSON 或 TXT 文件迁移。
这篇不讨论视觉素材怎么画,而是从公开仓库 master@1a133629 的设计与源码出发,拆解一条更适合复用的状态链路。
一、先分清三种任务数据
项目里没有用一个对象包打天下,而是按生命周期拆成了三层。
第一层是转盘运行时使用的 TaskItem,它负责本次抽签:
export interface TaskItem {
id: string
type: TaskType
level: RiskLevel
text: string
tags: string[]
isCustom: boolean
}
type 区分真心话和大冒险,level 控制挑战程度,id 则用于本局完成集合和去重。
第二层是本地持久化的 LocalTaskRecord。它比运行时对象多了 enabled、createdAt 和 updatedAt,因为任务库需要支持启停、编辑和排序这些长期操作。
第三层是跨设备迁移的 TransferTaskRecord,只保留 type、level、text 和 enabled。本地 ID、创建时间不会写进迁移文件;导入时可以重新生成本地 ID。
这个拆分有一个直接好处:页面、存储和文件格式不必互相妥协。运行时需要字段就加在运行时对象里,持久化需要元数据就留在本地记录里,迁移只承诺真正应该携带的业务信息。
二、两层随机,而不是一次 Math.random()
转盘看起来只有一次随机,代码里实际上分成了两层。
第一层发生在开局:TaskEngine.pickForWheel() 按玩法、挑战程度筛选内置任务,再从启用且符合条件的小任务中选出自定义任务。自定义任务最多占本局数量的一半,余下名额由内置任务补齐,最后用 Fisher-Yates 洗牌。
这一步解决的是“本局拿哪些任务”。
第二层发生在每次点击“开始”时。WheelRoundState.pickAvailableIndex() 先过滤掉当前已完成的任务,再从剩余索引中选一个目标:
static pickAvailableIndex(
tasks: TaskItem[],
completedTaskIds: string[],
randomValue: number
): number {
const indices = WheelRoundState.availableIndices(tasks, completedTaskIds);
if (indices.length === 0) return -1;
const normalized = Math.max(0, Math.min(randomValue, 0.999999));
return indices[Math.floor(normalized * indices.length)];
}
这一步解决的是“这一转落到谁”。
两层随机分开后,规则就不必绑在 Canvas 动画上。页面可以单独测试候选池、启用状态、挑战程度和本局去重,动画只负责把已经确定的目标表现出来。
三、先定目标,再算停在哪里
很多转盘实现会先随机一个角度,等停下来后再根据指针反推结果。这样也能工作,但结果逻辑会依赖动画细节。
“冒险大转盘”的顺序相反:
- 从未完成任务中选出目标扇区。
- 根据目标扇区计算停止角度。
- 播放减速动画。
- 动画结束后用最终角度反算实际命中扇区,并展示结果卡片。
在 WheelCanvas 中,目标中心角、额外旋转圈数和少量抖动共同生成最终角度;抖动限制在扇区宽度的较小比例内,避免视觉落点越过扇区边界。
动画使用 easeOutQuart 缓动,先快后慢。旋转过程中按钮禁用;停稳后,指针先做一次轻微回弹,再显示命中扇区。这样“看起来停在哪里”和“逻辑认为选中谁”就不会成为两套互不相关的随机。
对交互类应用来说,这个顺序值得保留:业务结果先确定,表现层只描述结果如何出现。
四、Canvas 只画转盘,不承载长文本
案例的转盘主体由 ArkUI Canvas 绘制。扇区、文字、指针和中心按钮都在画布上完成,外层再通过:
.rotate({ x: 1, y: 0, z: 0, angle: 10, perspective: 650 })
获得轻微透视。侧壁用多层圆形叠加和阴影制造厚度,颜色按玩法生成。
已完成任务不是从数组中删掉,而是保留原扇区位置,改成灰色,并把标签改成“完成”。这有两个作用:用户能看见进度;转盘尺寸也不会因为每次完成而重排。
任务正文并不会塞进扇区。扇区只显示编号和“真心/冒险”,完整内容放到居中的结果卡片。这样任务变长时,转盘仍然保持稳定,结果页则有足够空间阅读和交接。
五、本局状态与长期数据要分开
WheelPage 维护 completedTaskIds。用户点击“交给下一位”后,当前任务 ID 才加入集合;跳过后交接也会退出本局候选。这个集合只属于当前一局,不写入 Preferences,新开一局会重新计算。
这是很关键的一点:完成集合代表临时游戏状态,不应该和自定义任务库混在一起。否则用户重开一局,还要额外清理上一局留下的完成标记。
需要长期保存的是另一类数据:
- 自定义任务及其启停状态;
- 上次选择的玩法、任务数量、挑战程度和音效设置;
- 用户已经确认过的安全提示。
LocalTaskStore 与 PreferenceStore 分别使用不同的 Preferences 实例,并通过 flush() 提交写入。任务库的读写串行排队,避免同一时间发生两次读改写后互相覆盖。
数据边界清楚后,页面行为也更容易解释:本局变化只在页面上流转,需要留下的设置和任务才进入本地存储。
六、迁移文件只带可重建的数据
任务的本地 ID 适合当前设备,但不适合作为跨设备身份。迁移包因此采用独立格式:
{
"format": "adventure-wheel-tasks",
"version": 1,
"tasks": [
{
"type": "truth",
"level": "light",
"text": "说出一个最近学会的小技能",
"enabled": true
}
]
}
导入时先校验格式、版本、字段类型和正文规则,再统计新增、重复和无效数量。重复判断使用“类型 + 规范化正文”,不覆盖现有任务,只合并有效的新条目。单文件限制为 1 MiB,单次最多 1000 条。
文件选择使用系统 DocumentViewPicker,读写都针对用户选择的 URI。文件句柄放在 finally 中关闭,避免读取异常后一直占用。
“分享我的任务”写入的是扩展名为 TXT、正文仍为同一 JSON 契约的文本文件,并通过只读 URI 权限交给系统。这里要区分两个结果:接收应用被成功拉起,不等于对方已经实际接收并保存文件。成功提示应该以后续可观察结果为准。
七、写需求时,把状态和契约说明白
如果把这套结构交给 AI 生成,只说“做一个好看的转盘”通常不够。更有效的需求会直接写清状态边界:
请实现一个本地聚会转盘:
1. 运行时任务、持久化任务、迁移任务使用不同数据模型;
2. 开局先筛选并生成不重复清单,确认后才进入转盘;
3. 每次从本局未完成任务中选择目标,再计算停止角度;
4. 已完成任务保留扇区位置并显示“完成”,本局完成集合不持久化;
5. 自定义任务和设置存入 Preferences;
6. 迁移文件包含 format、version、tasks,只导入并合并有效新任务;
7. 构建成功后给出 HAP 实际路径,运行检查与构建检查分开汇报。
这比逐条描述动画曲线更有用。AI 可以决定具体组件怎么组织,但状态是否持久化、随机在哪一层发生、文件能否覆盖已有数据,必须由需求明确。
结语
“冒险大转盘”值得借鉴的不是某个动画参数,而是几条清晰的边界:运行时对象与持久化记录分开,本局状态与长期数据分开,业务结果与动画表现分开,迁移契约与本地存储分开。
这些边界建立后,Canvas 绘制、Preferences 保存和文件迁移都能独立演进。对鸿蒙元服务这类离线优先的小应用来说,这往往比把功能堆进一个页面更重要。
核验依据:公开仓库 harmony-practice-center/HarmonyOS-AtomSer-AdventureWheel 的 master@1a13362905ba1737beadcd35d8f685d4b2653a84,README 文档 v1.2 与 docs/设计说明.md。仓库地址:https://gitcode.com/harmony-practice-center/HarmonyOS-AtomSer-AdventureWheel
- 点赞
- 收藏
- 关注作者
评论(0)