【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)=1floor(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 游戏经济系统 数据一致性

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐