【OpenHarmony/HarmonyOS】从 Canvas 拾取物到永久资产:晶石生成、收集、结算与持久化
【OpenHarmony/HarmonyOS】从 Canvas 拾取物到永久资产:晶石生成、收集、结算与持久化
一枚晶石从地图上消失,只代表“拾取动画完成”;只有当 HUD 更新、玩法规则消费、结算只入账一次、应用退出后余额仍然存在,这个经济链路才算真正闭环。本文以 ArkTS 迷宫坦克项目为例,追踪晶石从 Canvas 实体到
Preferences永久资产的全过程,并分析局内货币与局外货币混用时最容易出现的重复结算、显示不同步和异步持久化问题。💎
一、先区分两种“晶石余额”
项目中至少存在三个相关数值,它们含义不同:
| 数据 | 生命周期 | 用途 | 所在对象 |
|---|---|---|---|
Crystal.value |
单个拾取物 | 本次碰撞增加多少晶石 | Crystal |
gameStats.coinsCollected |
一局游戏 | HUD、限时规则、局末结算 | GameEngine |
UpgradeState.coins |
跨局、跨启动 | 商城购买和永久余额 | UpgradeManager |
局内值类似“待结算收入”,永久值才是玩家真正持有的资产。如果每拾取一次就直接写 Preferences,可靠性看似提高,却会把高频游戏循环和磁盘操作耦合;如果只在正常胜利时结算,用户中途退出又可能损失进度。因此需要明确结算点和幂等规则。
flowchart LR
A[随机生成 Crystal] --> B[Canvas 绘制]
B --> C[玩家圆形碰撞]
C --> D[coinsCollected 增加]
D --> E[HUD 显示局内晶石]
D --> F[限时模式延长时间]
D --> G{游戏如何结束?}
G -->|正常胜负| H[GameEngine 结算]
G -->|主动退出| I[Index.stopGame 结算]
H --> J[UpgradeManager.addCoins]
I --> J
J --> K[Preferences 持久化]
K --> L[主页/商城刷新永久余额]
二、Crystal 模型:逻辑坐标与动画偏移分离
晶石实体保存位置、半径、价值和拾取状态:
export class Crystal {
position: Vector2;
radius: number;
value: number;
isCollected: boolean = false;
public floatOffset: number = 0;
private floatTime: number = 0;
constructor(x: number, y: number, value: number = 10) {
this.position = new Vector2(x, y);
this.value = value;
this.radius = GameConstants.MAZE_CELL_SIZE * 0.3;
}
}
position 是用于碰撞的世界坐标,floatOffset 只是绘制偏移。每帧更新:
update(deltaTime: number): void {
this.floatTime += deltaTime;
this.floatOffset = Math.sin(this.floatTime * 5) * 3;
}
collect(): void {
this.isCollected = true;
}
动画不改变逻辑位置,可以避免碰撞半径随视觉浮动。collect() 当前只是布尔赋值,看似多包了一层,但它为未来加入拾取动画、时间戳或事件提供了稳定入口。
三、生成数量如何与波次、难度和模式关联 🎲
每次重置波次时,引擎会清空旧晶石并重新生成。基础数量是 5 + currentWave * 2,再叠加难度和模式倍率:
private spawnCrystals(): void {
let baseCount = 5 + Math.floor(this.currentWave * 2);
if (this.difficulty === 'nightmare') {
baseCount = Math.floor(baseCount * 2.0);
} else if (this.difficulty === 'normal') {
baseCount = Math.floor(baseCount * 1.5);
}
if (this.gameMode === 'puzzle') {
baseCount = Math.floor(baseCount * 6.0);
} else if (this.gameMode === 'time_attack') {
baseCount = Math.floor(baseCount * 10.0);
}
this.crystals = [];
// 在有限次数内寻找安全格并创建 Crystal
}
设计意图很明确:更高难度给予更多资源,解谜和限时模式通过高密度拾取物建立各自节奏。但“生成目标数量”不等于“最终生成数量”。代码只进行 50 次位置尝试,如果安全格不足,最终数量会小于 baseCount。
这会在限时模式放大:目标可能是几十甚至上百,但 maxAttempts = 50 形成硬上限。它保护主线程不被随机搜索拖死,却也意味着倍率不是线性兑现。文章或策划表中应区分“目标数量”和“实际数量”。
每枚当前实际传入的 value 是 1:
const value = 1;
this.crystals.push(new Crystal(x, y, value));
虽然构造函数默认值为 10,但调用处覆盖成 1,所以实际拾取按 1 增加。不能只读模型默认值就宣称“每枚价值 10”。
四、安全落点决定晶石是否可达
晶石和传送门复用 findSafeEmptyCell()。它要求中心格为通路或草地,并检查周围 3×3 区域没有普通墙、砖墙或水域。
这种规则对坦克落点很合理,对半径只有格子 0.3 倍的晶石则略显保守。优点是玩家一定能接近;缺点是狭窄走廊和迷宫角落很少出现奖励,地图探索可能变得集中。
更精细的做法可以为不同实体定义不同安全策略:
interface SpawnRule {
clearanceCells: number;
allowGrass: boolean;
allowNarrowCorridor: boolean;
minDistanceFromPlayer: number;
}
这是演进方案。当前复用统一安全格函数,代码简单、可靠,适合功能规模不大的阶段。
五、绘制:单对象方法与批量路径并存
Crystal.draw() 使用四个顶点画菱形,再添加白色描边和青色光晕:
context.translate(
this.position.x,
this.position.y + this.floatOffset
);
context.beginPath();
context.moveTo(0, -this.radius);
context.lineTo(this.radius, 0);
context.lineTo(0, this.radius);
context.lineTo(-this.radius, 0);
context.closePath();
context.fillStyle = '#00E5FF';
context.fill();
引擎实际渲染时使用 drawCrystalsBatched(),先统一设置样式,再把所有晶石的菱形子路径加入一个 Path,最后集中 fill() 和 stroke()。这减少了每个实体的 save()、restore() 与样式切换。
| 绘制方式 | 优点 | 局限 |
|---|---|---|
Crystal.draw() |
封装完整、便于单体调试 | 大量对象时状态切换多 |
drawCrystalsBatched() |
同色同样式一次提交 | 引擎知道了晶石绘制细节 |
两套代码并存也带来维护风险:以后修改颜色或形状时,若只改一处,单体预览和正式游戏会不一致。可以把“向当前 Path 追加菱形”提取为无状态函数,同时供两条路径复用。
六、拾取判定与局内统计
每帧更新晶石动画后,引擎检查玩家与晶石的圆形距离:
const dx = this.playerTank.position.x - crystal.position.x;
const dy = this.playerTank.position.y - crystal.position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.playerTank.radius + crystal.radius) {
crystal.collect();
this.gameStats.coinsCollected += crystal.value;
AudioManager.getInstance().playSound('coin');
const lastIndex = this.crystals.length - 1;
if (i !== lastIndex) {
this.crystals[i] = this.crystals[lastIndex];
}
this.crystals.pop();
}
倒序遍历和交换删除确保同帧可以安全移除多枚晶石。这里调用了 Math.sqrt(),而道具碰撞使用平方距离;为了减少高频开销,可以改为比较 dx² + dy² 与 (r1 + r2)²,两者在语义上相同。
当前只允许玩家拾取,AI 不会消费晶石。这与道具系统不同,属于玩法选择:晶石服务玩家经济,而增益道具属于战场资源。多人模式若允许双方争夺晶石,则需要把拾取者 ID、归属队伍和网络权威一起纳入数据模型。
七、HUD 如何看到实时变化
页面维护 sessionCoins,通过定时同步读取引擎中的 coinsCollected。战斗 HUD 显示局内值,主页显示 UpgradeManager 中的永久值。
// 页面定时同步的核心语义
if (this.isGameRunning && this.gameEngine) {
this.sessionCoins =
this.gameEngine.gameStats.coinsCollected;
}
这形成了单向数据流:引擎拥有权威局内状态,ArkUI 页面只复制用于响应式显示。不要让 HUD 点击或动画反向修改 gameStats,否则引擎与 UI 都成为数据源。
50ms 轮询约等于 20Hz,晶石数字不需要按 120Hz 刷新,因此能减少声明式 UI 更新压力。更理想的方式是引擎在数值变化时触发 onCoinsChanged 回调,避免无变化时也轮询;但回调必须在页面销毁时解绑。
八、限时模式:晶石不只是货币,也是时间
限时模式把已收集晶石加入总时限:
const elapsed =
(Date.now() - this.gameStats.startTime) / 1000;
const timeLimit =
45 + this.gameStats.coinsCollected * 1;
if (elapsed > timeLimit) {
// 进入结算
}
每枚晶石增加 1 秒,形成“寻找奖励会消耗时间,但拾取后又补回时间”的风险收益循环。HUD 使用同一公式显示剩余时间,规则和呈现必须保持一致。
当前 elapsed 基于真实时间而不是游戏循环累计时间。暂停时游戏循环停止,但 Date.now() 仍前进;恢复后可能发现时间已经耗尽。如果产品预期暂停冻结倒计时,应记录暂停累计时长,或直接用受暂停控制的 survivalTime。
限时模式还会在晶石少于 10 时再次调用 spawnCrystals()。但该函数开头执行 this.crystals = [],意味着剩余的 0~9 枚会先被清空,再生成整批新晶石,而不是“补足到目标数”。从玩家视角可能出现远处晶石突然换位。若设计要求补充,应把清空行为和追加行为拆成两个接口。
九、正常结算:永久入账发生在哪里
在玩家死亡、多人比赛结束或解谜出口触发时,GameEngine 会调用:
UpgradeManager.getInstance().addCoins(
this.gameStats.coinsCollected
);
解谜错误出口先保留约四分之一:
this.gameStats.coinsCollected = Math.round(
this.gameStats.coinsCollected * 0.25
);
UpgradeManager.getInstance().addCoins(
this.gameStats.coinsCollected
);
主动退出则由页面 stopGame() 负责:
if (!this.isGameOver &&
this.gameEngine.gameStats.coinsCollected > 0) {
let coinsToSave =
this.gameEngine.gameStats.coinsCollected;
if (this.gameMode === 'puzzle') {
coinsToSave = Math.floor(coinsToSave * 0.25);
}
UpgradeManager.getInstance().addCoins(coinsToSave);
}
!isGameOver 是避免结算页返回主页时再次保存的关键条件。但资产逻辑分散在引擎多个分支和页面退出函数中,未来新增失败路径时很容易漏掉或重复。
十、Preferences 持久化:内存先变,磁盘后写
UpgradeManager 保存永久余额:
public addCoins(amount: number): void {
this.state.coins += amount;
this.saveState();
}
public async saveState(): Promise<void> {
if (!this.pref) return;
await this.pref.put(
this.KEY_STATE,
JSON.stringify(this.state)
);
await this.pref.flush();
}
addCoins() 没有返回 Promise,也没有等待 saveState()。内存余额立即改变,UI 刷新通常能看到新值;磁盘写入则异步进行。若应用在写入完成前被系统终止,本次收益可能丢失,而且调用方无法获知失败。
更稳健的接口应该返回 Promise<boolean> 或抛出可处理异常,由结算流程等待持久化后再标记完成。对于离线单机经济,还可以使用“待结算交易 ID”实现简单幂等:同一局生成唯一 ID,保存时记录已处理集合,重复调用不再次加币。
十一、当前链路中的几个真实不一致 ⚠️
1. 结算 UI 没有复制 coinsCollected
onGameEnd 中页面新建了 GameStats,逐项复制分数、击毁数和生存时间,却没有复制引擎的 coinsCollected。而结算弹窗使用页面这份对象,可能显示为默认 0。
可演进为直接创建快照函数,避免手工漏字段:
function copyGameStats(source: GameStats): GameStats {
const target = new GameStats();
target.score = source.score;
target.targetsDestroyed = source.targetsDestroyed;
target.survivalTime = source.survivalTime;
target.coinsCollected = source.coinsCollected;
return target;
}
2. coin 音效调用与预加载不一致
拾取时调用 playSound('coin'),但项目音频资源和预加载列表中没有对应 coin.wav 的完整闭环。实际可能静默失败或无法播放。文章不能写成“晶石音效已经完整接入”,应说明需要补资源或映射到现有 powerup 音效。
3. 四分之一保留使用两种取整
错误出口使用 Math.round,主动退出使用 Math.floor,其他分支还出现过 Math.floor。例如收集 3 枚时,round(0.75)=1,floor(0.75)=0。这是可被玩家感知的规则差异,应由统一结算函数决定。
4. 多处保存增加重复入账风险
引擎负责正常结束,页面负责中途退出,这个边界可以成立,但依赖 isGameOver 在所有异步时序中都正确。延迟结算尚未把页面状态切为结束时,用户若快速退出,可能同时触发两条保存路径。需要一次性结算状态,而不只是 UI 布尔值。
十二、建议引入统一结算器
可以把所有资产副作用收口为一个事务式接口:
interface CoinSettlement {
sessionId: string;
collected: number;
retained: number;
reason: 'win' | 'lose' | 'quit' | 'wrong_exit';
}
class EconomyService {
async settleCoins(input: CoinSettlement): Promise<boolean> {
// 1. 校验数值非负、为整数
// 2. 检查 sessionId 是否已经处理
// 3. 增加余额并写入 Preferences
// 4. 记录已处理 sessionId
return true;
}
}
这段是架构建议,不是当前实现。它让引擎只产生结算事实,资产服务处理持久化和幂等,页面只展示结果。以后迁移到云端账户时,也有明确替换点。
十三、数值与安全边界
本地单机资产也应校验:
amount必须是有限数、整数且非负;- 余额累加要防止异常大值和
NaN; - JSON 反序列化后不能直接信任字段类型;
- 商城扣款和奖励入账应使用同一个权威管理器;
- UI 展示价格不能与管理器实际扣款公式不同;
- 联机模式不能信任客户端上报的拾取数量。
当前商城页面展示价格与 UpgradeManager.getUpgradeCost() 的实际扣款规则存在不完全一致的风险。虽然这不是晶石拾取本身的问题,却会破坏整个经济系统的可解释性:玩家看到的价格、按钮校验和实际余额变化必须来自同一份配置。
十四、测试矩阵
| 场景 | 关键断言 |
|---|---|
| 拾取 1 枚价值 1 的晶石 | 局内值增加 1,数组减少 1 |
| 同帧重叠多枚晶石 | 全部正确处理,不漏项、不越界 |
| 普通模式死亡 | 永久余额只增加一次 |
| 解谜错误出口收集 3 枚 | 明确统一取整结果 |
| 结算后返回主页 | 不再次调用入账 |
| 延迟结算时立即退出 | 同一 sessionId 只能结算一次 |
| Preferences 未初始化 | 不崩溃,并给出可观测失败 |
| 写入失败 | UI 不应伪装成已永久保存 |
| 限时模式暂停 | 剩余时间符合产品定义 |
| 结算弹窗 | 显示的晶石与实际结算一致 |
测试既要覆盖数学,也要覆盖时序。资产重复往往不发生在“正常点一次按钮”,而发生在回调、延时器和页面返回同时到达的边界。
十五、总结 ✨
晶石系统的技术难点不在菱形绘制,而在跨越两个生命周期:局内的 coinsCollected 服务即时玩法,局外的 UpgradeState.coins 服务长期成长。中间要经过安全生成、碰撞拾取、HUD 同步、模式规则、一次性结算和异步持久化。
项目已经完成了这条链路的大部分骨架,同时也暴露了真实工程中常见的问题:生成倍率受到尝试上限影响、限时补充会重置剩余晶石、音频资源未完整对应、结算快照漏字段、取整规则不统一、资产保存分散在多个退出路径。把结算收口、增加会话幂等并统一配置后,这套晶石系统才能真正从“可拾取物”成长为可靠的游戏经济基础。🚀
推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI Canvas Preferences 游戏经济系统 数据一致性

更多推荐


所有评论(0)